Files
Calendarr/backend/main.py
Scarriffle 8f9eafe561 feat(settings): Schriftfarbe, Linienfarbe und Hintergrundfarbe per Color-Picker
Die bisherigen Stufen-Wähler ("Dunkel/Mittel/Hell/Maximum" und
"Kaum/Subtil/Normal/Stark") für Schrift- bzw. Linienkontrast sind durch
echte Hex-Color-Picker ersetzt. Zusätzlich kann jetzt auch die
Hintergrundfarbe der Seite frei gewählt werden.

Wenn ein Override gesetzt ist:
- text_color → setzt --text-1 direkt, --text-2/--text-3 werden
  daraus per shadeHex(-0.25 / -0.55) abgeleitet, damit der Hue passt
- line_color → setzt --border, --border-light wird leicht abgedunkelt
- bg_color → setzt --bg-app, daraus werden Topbar/Sidebar/Surface/
  Hover/Active per shadeHex(+0.10…+0.40) konsistent hochskaliert

Per "Reset"-Knopf wird der Override geleert und die alte Stufen-Logik
(falls noch vorhanden) bzw. der Default-Theme greift wieder.

Backend:
- 3 neue nullable VARCHAR(7)-Spalten in user_settings (text_color,
  line_color, bg_color) inkl. Migrationen in main.py
- settings_router nutzt model_dump(exclude_unset=True) und respektiert
  explizite null-Werte nur für diese 3 Override-Felder, damit Reset
  funktioniert

Auch enthalten: Auflösen der Merge-Konflikte in sw.js, index.html,
version.js (HEAD-Stand v17 behalten) und Bump auf v18.
2026-05-19 09:49:45 +02:00

216 lines
7.8 KiB
Python

import logging
import os
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
# How long the browser may keep static assets before revalidating.
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
NO_CACHE = "no-cache, no-store, must-revalidate"
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
# Create DB tables on startup
Base.metadata.create_all(bind=engine)
# Run column migrations for new fields (safe: only adds if not exists)
def _migrate():
with engine.connect() as conn:
# Add week_start_day to user_settings if not present
try:
conn.execute(text(
"ALTER TABLE user_settings ADD COLUMN week_start_day VARCHAR(10) DEFAULT 'monday'"
))
conn.commit()
logging.info("Migration: added week_start_day column")
except Exception:
pass # Column already exists
try:
conn.execute(text("ALTER TABLE calendars ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to calendars")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE google_calendars ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to google_calendars")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_contrast INTEGER DEFAULT 3"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_contrast INTEGER DEFAULT 3"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN hour_height INTEGER DEFAULT 60"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN language VARCHAR(5) DEFAULT 'de'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
conn.commit()
logging.info("Migration: added rrule to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT"))
conn.commit()
logging.info("Migration: added exdate to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_label_color VARCHAR(7) DEFAULT '#7090c0'"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Force ≤ 2h browser cache for static assets and disable cache for the
entry HTML / SW / version file. API responses are left alone (handlers
decide their own caching)."""
response = await call_next(request)
path = request.url.path
# Never cache: entry HTML, manifest, service worker, version marker
if (
path in ("/", "/index.html", "/manifest.json", "/sw.js")
or path == "/static/js/version.js"
):
response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# 2h cache for the rest of the frontend (JS/CSS/icons/etc.)
elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML;
# don't let the browser cache that either.
elif not path.startswith("/api/"):
response.headers["Cache-Control"] = NO_CACHE
return response
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
# ── PWA assets that must live at root scope ──────────────
@app.get("/manifest.json")
async def pwa_manifest():
return FileResponse(str(FRONTEND_DIR / "manifest.json"), media_type="application/manifest+json")
@app.get("/sw.js")
async def pwa_service_worker():
return FileResponse(
str(FRONTEND_DIR / "sw.js"),
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@app.get("/icons/{icon_name}")
async def pwa_icon(icon_name: str):
icon_path = FRONTEND_DIR / "icons" / icon_name
if not icon_path.exists() or not icon_path.is_file():
raise HTTPException(status_code=404, detail="Icon not found")
return FileResponse(str(icon_path))
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found")
index = FRONTEND_DIR / "index.html"
return FileResponse(str(index))
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
host = os.environ.get("HOST", "0.0.0.0")
uvicorn.run(app, host=host, port=port)