Files
Calendarr/backend/main.py
T
ScarriffleandClaude Opus 5 8bc32faa7f fix(theme): Farben erben wieder den Standard statt ihn einzufrieren
Fuenf Farbspalten trugen einen fest eingebauten Vorgabewert — primary_color
#4285f4, accent_color #ea4335, today_color #4285f4, month_divider_color und
month_label_color #7090c0. Damit hatte *jeder* Nutzer immer eine eigene
Farbe, und der Fallback settings.X || baseColor(X) kam nie zum Zug. Das
Admin-Versprechen "Gilt fuer alle Nutzer ohne eigene Farbe" war fuer diese
fuenf unerfuellbar, und die Werte widersprachen DEFAULT_COLORS im Frontend,
das laut Kommentar die einzige Quelle sein soll.

Das betraf nicht nur Altbestand: UserSettings wird ohne Farbwerte angelegt,
also bekam auch jeder heute neu angelegte Nutzer das Blau.

- models.py: die fuenf Spalten sind nullable, NULL heisst "erbt".
- settings_router: NULL wird ausgeliefert statt durch einen Standard ersetzt,
  und ein explizites null wird gespeichert (NULLABLE_OVERRIDES).
- main.py: einmalige Migration setzt genau die alten Vorgabewerte auf NULL.
  Bewusst gewaehlte Farben bleiben unangetastet.
- calendar.js: eine erbende Zeile merkt sich das und sendet beim Speichern
  null. "Zurueck" heisst wieder erben, statt den aktuellen Standard in die
  Nutzerzeile zu kopieren — sonst erreichen spaetere Aenderungen des
  Standards den Nutzer nie mehr.

Verifiziert: frische Installation liefert --primary #58B900 statt #4285f4;
zurueckgeschriebenes Alt-Blau wird beim Neustart geleert; eine eigene Farbe
(#FF00AA) ueberlebt die Migration. Suite 129 gruen, davon 5 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:44:25 +02:00

506 lines
21 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))
import scheduler
from database import Base, engine
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_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 local_events ADD COLUMN reminders TEXT"))
conn.commit()
logging.info("Migration: added reminders to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_reminder_minutes INTEGER"))
conn.commit()
logging.info("Migration: added default_reminder_minutes to user_settings")
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
# ── Collaboration features (sharing, groups, creator, private) ──
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'"))
conn.commit()
logging.info("Migration: added private_event_visibility to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER"))
conn.commit()
logging.info("Migration: added creator_id to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT"))
conn.commit()
logging.info("Migration: added creator_name_external to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added is_private to local_events")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN group_visible_calendar_id INTEGER"))
conn.commit()
logging.info("Migration: added group_visible_calendar_id to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
conn.commit()
logging.info("Migration: added display_name to users")
except Exception:
pass
# Backfill display_name from username for existing rows (only where empty).
try:
conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
conn.commit()
logging.info("Migration: added icon to groups")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)"))
conn.commit()
logging.info("Migration: added color to group_members")
except Exception:
pass
# Per-calendar reminder toggle (default ON = unchanged behaviour for existing data).
for tbl in ("calendars", "local_calendars", "google_calendars",
"homeassistant_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN reminders_enabled BOOLEAN DEFAULT 1"))
conn.commit()
logging.info("Migration: added reminders_enabled to %s", tbl)
except Exception:
pass
# Synced default duration (minutes) for newly created events.
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60"))
conn.commit()
logging.info("Migration: added default_event_duration_minutes to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN share_calendar_icon VARCHAR(16)"))
conn.commit()
logging.info("Migration: added share_calendar_icon to user_settings")
except Exception:
pass
# Per-setting cross-device sync: value columns for the two newly syncable
# device-local prefs, plus the JSON map of which settings sync.
for col, ddl in (
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Fine-grained web element theme colours (all optional overrides).
for col in (
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
"day_hover_color", "day_selected_color",
"day_bg_color", "today_bg_color",
):
try:
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
conn.commit()
logging.info("Migration: added %s to user_settings", col)
except Exception:
pass
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
# from the sidebar, matching caldav/google/ha.
for tbl in ("local_calendars", "ical_subscriptions"):
try:
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added sidebar_hidden to %s", tbl)
except Exception:
pass
# CalDAV publishing of local calendars (opt-in, secret token URL).
for col, ddl in (
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
conn.commit()
logging.info("Migration: added etag to local_events")
except Exception:
pass
# Per-recipient colour override for a shared calendar (NULL = owner's colour).
try:
conn.execute(text("ALTER TABLE calendar_shares ADD COLUMN color VARCHAR(16)"))
conn.commit()
logging.info("Migration: added color to calendar_shares")
except Exception:
pass
# Hide a user from sharing/group pickers (admin management still shows them).
try:
conn.execute(text("ALTER TABLE users ADD COLUMN directory_hidden BOOLEAN DEFAULT 0"))
conn.commit()
logging.info("Migration: added directory_hidden to users")
except Exception:
pass
# Birthday calendars: flag + per-calendar "notify N days before".
for col, ddl in (
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_calendars", col)
except Exception:
pass
# Birthday events: stable external id (Contacts dedup) + birth year.
for col, ddl in (
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
):
try:
conn.execute(text(ddl))
conn.commit()
logging.info("Migration: added %s to local_events", col)
except Exception:
pass
# One-time cleanup of duplicate imported events sharing the same
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
# older build without idempotent upsert. Keep the earliest row.
# Idempotent: after cleanup there is nothing left to delete.
try:
conn.execute(text(
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
"GROUP BY calendar_id, external_uid)"
))
conn.commit()
logging.info("Migration: de-duplicated local_events by external_uid")
except Exception:
pass
# Hard guarantee: the DB itself forbids two events with the same
# external_uid in one calendar (imported entries only; NULLs unconstrained).
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
))
conn.commit()
logging.info("Migration: unique index on (calendar_id, external_uid)")
except Exception:
pass
# ── OpenID Connect single sign-on ────────────────────────────────
# The oidc_identities table itself comes from create_all above; only
# the new users column and the indexes need retrofitting here.
try:
conn.execute(text("ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local'"))
conn.commit()
logging.info("Migration: added auth_source to users")
except Exception:
pass
try:
conn.execute(text("UPDATE users SET auth_source = 'local' "
"WHERE auth_source IS NULL OR auth_source = ''"))
conn.commit()
except Exception:
pass
try:
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_oidc_identity_provider_subject "
"ON oidc_identities(provider_key, subject)"
))
conn.commit()
except Exception:
pass
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_oidc_identities_user_id "
"ON oidc_identities(user_id)"
))
conn.commit()
except Exception:
pass
# ── Theme inheritance ────────────────────────────────────────────
# These five colours used to carry a hardcoded column default, so every
# user row held a concrete value and the admin instance default could
# never apply. Clear the ones that still hold exactly that old default
# so they inherit again. A user who deliberately picked the same colour
# also inherits now — which is the same colour, until an admin changes
# the instance default.
for col, old_default in (("primary_color", "#4285f4"),
("accent_color", "#ea4335"),
("today_color", "#4285f4"),
("month_divider_color", "#7090c0"),
("month_label_color", "#7090c0")):
try:
conn.execute(text(
f"UPDATE user_settings SET {col} = NULL "
f"WHERE lower({col}) = lower(:old)"
), {"old": old_default})
conn.commit()
except Exception:
pass
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
lifespan=scheduler.lifespan)
@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"
# JS/CSS must revalidate on every load so a deploy takes effect on the next
# reload (returns a cheap 304 when unchanged). Without this, a fresh
# no-cache index.html could pair with stale 2h-cached scripts.
elif path.startswith("/static/js/") or path.startswith("/static/css/"):
response.headers["Cache-Control"] = NO_CACHE
# 2h cache for the rest of the frontend (icons, fonts, images, …)
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(oidc_router.router, prefix="/api/auth/oidc", tags=["oidc"])
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(attachments_router.router, prefix="/api/local", tags=["attachments"])
# Root scope: the capability URL carries its own /api/attach prefix so the
# SPA catch-all below does not swallow it.
app.include_router(attachments_router.public_router, tags=["attachments"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
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"])
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
app.include_router(dav_router.router, tags=["dav"])
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)