Files
Calendarr/backend/routers/settings_router.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

217 lines
8.2 KiB
Python

import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
# Which settings can sync across a user's devices, and the default flag applied to
# an existing/new account until the user overrides it. This map is the single
# authority: GET returns the fully-resolved flags so no client duplicates default
# logic. Rollout rule: settings that already lived on the server default to True;
# the four newly-syncable device-local prefs default to False.
DEFAULT_SYNC = {
"default_view": True,
"week_start_day": True,
"dim_past_events": True,
"hour_height": True,
"primary_color": True,
"accent_color": True,
"today_color": True,
"text_color": True,
"line_color": True,
"bg_color": True,
"month_divider_color": True,
"month_label_color": True,
"default_event_duration_minutes": True,
"default_reminder_minutes": True,
"language": False,
"share_calendar_icon": False,
"cache_months": False,
"month_view_paged": False,
"surface_color": False,
"hover_highlight_color": True,
"icon_inactive_color": True,
"icon_active_color": True,
"day_hover_color": True,
"day_selected_color": True,
"day_bg_color": True,
"today_bg_color": True,
}
def _resolve_sync_flags(s: models.UserSettings) -> dict:
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
top of DEFAULT_SYNC, junk keys dropped."""
stored = {}
if s.sync_flags:
try:
stored = json.loads(s.sync_flags) or {}
except (ValueError, TypeError):
stored = {}
return {
key: bool(stored[key]) if key in stored else default
for key, default in DEFAULT_SYNC.items()
}
class SettingsUpdate(BaseModel):
default_view: Optional[str] = None
week_start_day: Optional[str] = None
primary_color: Optional[str] = None
accent_color: Optional[str] = None
today_color: Optional[str] = None
dim_past_events: Optional[bool] = None
text_contrast: Optional[int] = None
line_contrast: Optional[int] = None
hour_height: Optional[int] = None
language: Optional[str] = None
month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None
text_color: Optional[str] = None
line_color: Optional[str] = None
bg_color: Optional[str] = None
surface_color: Optional[str] = None
hover_highlight_color: Optional[str] = None
icon_inactive_color: Optional[str] = None
icon_active_color: Optional[str] = None
day_hover_color: Optional[str] = None
day_selected_color: Optional[str] = None
day_bg_color: Optional[str] = None
today_bg_color: Optional[str] = None
private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off
default_event_duration_minutes: Optional[int] = None
share_calendar_icon: Optional[str] = None
cache_months: Optional[int] = None
month_view_paged: Optional[bool] = None
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
def _settings_dict(s: models.UserSettings) -> dict:
return {
"default_view": s.default_view,
"week_start_day": s.week_start_day or "monday",
"primary_color": s.primary_color,
"accent_color": s.accent_color,
"today_color": s.today_color,
"dim_past_events": s.dim_past_events,
"text_contrast": s.text_contrast or 3,
"line_contrast": s.line_contrast or 3,
"hour_height": s.hour_height or 60,
"language": s.language or "de",
# NULL stays NULL: the client resolves it against the admin instance
# default. Substituting a colour here would hide the inheritance.
"month_divider_color": s.month_divider_color,
"month_label_color": s.month_label_color,
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
"surface_color": s.surface_color,
"hover_highlight_color": s.hover_highlight_color,
"icon_inactive_color": s.icon_inactive_color,
"icon_active_color": s.icon_active_color,
"day_hover_color": s.day_hover_color,
"day_selected_color": s.day_selected_color,
"day_bg_color": s.day_bg_color,
"today_bg_color": s.today_bg_color,
"private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes,
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
"share_calendar_icon": s.share_calendar_icon,
"cache_months": s.cache_months or 3,
"month_view_paged": bool(s.month_view_paged),
"sync_flags": _resolve_sync_flags(s),
}
@router.get("/")
def get_settings(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == current_user.id)
.first()
)
if not settings:
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
db.commit()
db.refresh(settings)
return _settings_dict(settings)
@router.put("/")
def update_settings(
data: SettingsUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == current_user.id)
.first()
)
if not settings:
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
# A birthday calendar must never become the group-visible ("personal")
# calendar — it may be shared directly, but not stand in as your calendar in
# group views. Clients filter it out of the picker; this is the safety net.
if data.group_visible_calendar_id:
bcal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == data.group_visible_calendar_id,
models.LocalCalendar.user_id == current_user.id,
)
.first()
)
if bcal is not None and bcal.is_birthday:
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon", "primary_color", "accent_color", "today_color", "month_divider_color", "month_label_color"}
update_data = data.model_dump(exclude_unset=True)
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
# syncable keys are kept; a partial map leaves untouched flags as they were.
if "sync_flags" in update_data:
incoming = update_data.pop("sync_flags") or {}
current = {}
if settings.sync_flags:
try:
current = json.loads(settings.sync_flags) or {}
except (ValueError, TypeError):
current = {}
for key, val in incoming.items():
if key in DEFAULT_SYNC:
current[key] = bool(val)
settings.sync_flags = json.dumps(current)
for field, value in update_data.items():
if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None)
elif value is not None:
setattr(settings, field, value)
db.commit()
return {"ok": True}