Adds website theme serialization system

This commit is contained in:
Your Name
2026-07-06 06:52:18 +00:00
parent b932d65658
commit 8cd54459ec
29 changed files with 418 additions and 4 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -305,6 +305,25 @@ async def _upload_files_to_worker(
raise RuntimeError(f"Failed to upload file {row.original_name}: {out}")
async def _upload_theme_to_worker(
session: AsyncSession,
request: MobileBuildRequest,
host: str,
) -> None:
"""SCP theme.json to the build worker (no-op if file doesn't exist)."""
theme_path = Path(settings.THEME_JSON_PATH)
if not theme_path.exists():
await _append_log(session, request, "[theme] theme.json not found — skipping upload")
return
scp_prefix = _build_scp_prefix(host)
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/theme.json"
code, out = await _run_command(scp_prefix + [str(theme_path), remote_target])
if code != 0:
raise RuntimeError(f"Failed to upload theme.json: {out}")
await _append_log(session, request, "[theme] theme.json uploaded to worker")
async def _run_platform_build(
session: AsyncSession,
request: MobileBuildRequest,
@@ -317,6 +336,9 @@ async def _run_platform_build(
await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker")
await _upload_files_to_worker(platform_files, host)
# Upload theme.json to worker
await _upload_theme_to_worker(session, request, host)
ssh_prefix = _build_ssh_prefix(host)
cmd = ssh_prefix + [
(
+12
View File
@@ -1,15 +1,20 @@
"""Station config router: read/update the singleton station branding config."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import StationConfig
from app.schemas import StationConfigResponse, StationConfigUpdate
from app.theme_export import write_theme_json
from app.user_models import User
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -47,4 +52,11 @@ async def update_station_config(
await session.commit()
await session.refresh(config)
# Regenerate theme.json on disk (best-effort)
try:
await write_theme_json()
except Exception:
logger.warning("Failed to regenerate theme.json after config update", exc_info=True)
return config
+52
View File
@@ -0,0 +1,52 @@
"""Theme export: public JSON endpoint, status check, and admin regeneration trigger."""
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from app.auth import get_current_admin_user
from app.theme_export import get_theme_status, write_theme_json
from app.user_models import User
router = APIRouter()
@router.get("/status")
async def theme_status() -> Dict[str, Any]:
"""Return theme.json file status. Public endpoint."""
return get_theme_status()
@router.get("")
async def get_theme() -> Dict[str, Any]:
"""Return the current theme as a JSON dict. Public endpoint.
Also regenerates the theme.json file on disk as a side effect,
ensuring the file is always up-to-date.
"""
try:
theme = await write_theme_json()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate theme: {e}",
)
return theme
@router.post("/regenerate", status_code=200)
async def regenerate_theme(
_: User = Depends(get_current_admin_user),
) -> Dict[str, Any]:
"""Force regenerate the theme.json file. Admin only.
Returns the newly generated theme dict.
"""
try:
theme = await write_theme_json()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to regenerate theme: {e}",
)
return theme
+4
View File
@@ -32,6 +32,10 @@ class Settings(BaseSettings):
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab"
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa"
# Theme export
WEBSITE_URL: str = "https://kmountainflower.org"
THEME_JSON_PATH: str = "/app/theme.json"
model_config = {"env_prefix": "KMTN_"}
+10 -1
View File
@@ -10,7 +10,7 @@ from app.config import settings
from app.database import async_session, engine
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
from app.user_models import User
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
def _cleanup_orphaned_sequences(sync_conn):
@@ -153,6 +153,14 @@ async def lifespan(app: FastAPI):
async with async_session() as session:
await _ensure_station_config(session)
# Write theme.json on startup
try:
from app.theme_export import write_theme_json
await write_theme_json()
print(f" ✓ Theme JSON written to {settings.THEME_JSON_PATH}")
except Exception as e:
print(f" WARNING: Failed to write theme.json on startup: {e}")
# Bootstrap admin user if configured
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
async with async_session() as session:
@@ -224,6 +232,7 @@ app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
# Dev-only admin router (disabled in production)
if settings.ENV != "production":
+8
View File
@@ -98,6 +98,10 @@ class StationConfigResponse(BaseModel):
color_success: str
color_danger: str
color_info: str
color_white: str
color_light: str
color_medium: str
color_black: str
model_config = {"from_attributes": True}
@@ -145,6 +149,10 @@ class StationConfigUpdate(BaseModel):
color_success: Optional[str] = None
color_danger: Optional[str] = None
color_info: Optional[str] = None
color_white: Optional[str] = None
color_light: Optional[str] = None
color_medium: Optional[str] = None
color_black: Optional[str] = None
# ── HistoryEntry ──────────────────────────────────────────
+134
View File
@@ -0,0 +1,134 @@
"""Theme export: read StationConfig from the DB and dump a theme.json file.
This module provides functions to:
- Build a clean, consumer-friendly JSON structure from StationConfig.
- Write it atomically to disk (for scp by build machines).
- Check whether the file exists and return its metadata.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict
from app.config import settings
from app.database import async_session
from app.models import StationConfig
from sqlalchemy import select
logger = logging.getLogger(__name__)
def resolve_image_url(base_url: str, relative_url: str) -> str | None:
"""Convert a relative image URL to an absolute one.
If the URL is already absolute (starts with http:// or https://), return as-is.
Returns None for empty strings.
"""
if not relative_url:
return None
if relative_url.startswith(("http://", "https://")):
return relative_url
path = relative_url.lstrip("/")
base = base_url.rstrip("/")
return f"{base}/{path}"
def build_theme_dict(config: StationConfig) -> Dict[str, Any]:
"""Build the theme.json dict from a StationConfig ORM object."""
base_url = settings.WEBSITE_URL
images: Dict[str, str] = {}
for key, field in [
("logo", config.logo_url),
("hero_background", config.hero_background_url),
("hero_icon", config.hero_icon_url),
("hero_divider", config.hero_divider_url),
]:
resolved = resolve_image_url(base_url, field)
if resolved:
images[key] = resolved
return {
"version": "1.0",
"generated_at": datetime.now(timezone.utc).isoformat(),
"station": {
"callsign": config.callsign,
"name_primary": config.name_primary,
"name_secondary": config.name_secondary,
"tagline": config.tagline,
"frequency": config.frequency,
},
"colors": {
"primary": config.color_primary,
"primary_light": config.color_primary_light,
"primary_dark": config.color_primary_dark,
"primary_muted": config.color_primary_muted,
"accent": config.color_accent,
"accent_light": config.color_accent_light,
"accent_dark": config.color_accent_dark,
"accent_muted": config.color_accent_muted,
"background": config.color_background,
"text": config.color_text,
"success": config.color_success,
"danger": config.color_danger,
"info": config.color_info,
"white": config.color_white,
"light": config.color_light,
"medium": config.color_medium,
"black": config.color_black,
},
"images": images,
}
async def write_theme_json() -> Dict[str, Any]:
"""Read the current station config and write theme.json to disk.
Uses atomic write (write to .tmp, then os.replace) to prevent
reading a partially-written file.
Returns the theme dict that was written.
"""
async with async_session() as session:
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = result.scalar_one_or_none()
if config is None:
raise RuntimeError("Station config not found in database")
theme = build_theme_dict(config)
# Atomic write
path = Path(settings.THEME_JSON_PATH)
tmp_path = path.with_suffix(".tmp")
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(json.dumps(theme, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
return theme
def get_theme_status() -> Dict[str, Any]:
"""Check if theme.json exists on disk and return its metadata."""
path = Path(settings.THEME_JSON_PATH)
if not path.exists():
return {"exists": False, "path": str(path)}
stat = path.stat()
try:
data = json.loads(path.read_text(encoding="utf-8"))
generated_at = data.get("generated_at")
except Exception:
generated_at = None
return {
"exists": True,
"path": str(path),
"generated_at": generated_at,
"size": stat.st_size,
}