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>
This commit is contained in:
Scarriffle
2026-10-01 11:44:25 +02:00
co-authored by Claude Opus 5
parent aaecfc435d
commit 8bc32faa7f
7 changed files with 127 additions and 15 deletions
+22
View File
@@ -383,6 +383,28 @@ def _migrate():
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,
+8 -5
View File
@@ -95,16 +95,19 @@ class UserSettings(Base):
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
default_view = Column(String(20), default="month")
week_start_day = Column(String(10), default="monday")
primary_color = Column(String(7), default="#4285f4")
accent_color = Column(String(7), default="#ea4335")
today_color = Column(String(7), default="#4285f4")
# NULL = inherit the admin instance default (see admin_router). A baked-in
# column default would mean every user always *has* an own colour, so the
# instance default could never apply — which is exactly what happened.
primary_color = Column(String(7), nullable=True)
accent_color = Column(String(7), nullable=True)
today_color = Column(String(7), nullable=True)
dim_past_events = Column(Boolean, default=False)
text_contrast = Column(Integer, default=3)
line_contrast = Column(Integer, default=3)
hour_height = Column(Integer, default=60)
language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0")
month_divider_color = Column(String(7), nullable=True) # NULL = inherit
month_label_color = Column(String(7), nullable=True) # NULL = inherit
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
+5 -3
View File
@@ -108,8 +108,10 @@ def _settings_dict(s: models.UserSettings) -> dict:
"line_contrast": s.line_contrast or 3,
"hour_height": s.hour_height or 60,
"language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0",
# 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,
@@ -186,7 +188,7 @@ def update_settings(
# 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"}
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
+75
View File
@@ -0,0 +1,75 @@
"""Theme colours: a user without an own colour must inherit the admin default.
These five colours used to carry a hardcoded column default, which meant every
user row always held a concrete value — so the admin's instance default could
never apply to them, however it was configured.
"""
import models
from conftest import auth, create_user, register_admin
from database import SessionLocal
INHERITABLE = ["primary_color", "accent_color", "today_color",
"month_divider_color", "month_label_color"]
def db_session():
return SessionLocal()
def test_new_user_has_no_own_colours(client):
"""A fresh account inherits — nothing is baked into its row."""
admin_token = register_admin(client, "admin", "pw")
uid, token = create_user(client, admin_token, "hugo")
with db_session() as db:
s = db.query(models.UserSettings).filter(
models.UserSettings.user_id == uid).one()
for col in INHERITABLE:
assert getattr(s, col) is None, f"{col} should inherit, got {getattr(s, col)!r}"
body = client.get("/api/settings/", headers=auth(token)).json()
for col in INHERITABLE:
assert body[col] is None
def test_setup_admin_also_inherits(client):
"""The first-run admin is created on a different code path."""
token = register_admin(client, "admin", "pw")
body = client.get("/api/settings/", headers=auth(token)).json()
for col in INHERITABLE:
assert body[col] is None
def test_choosing_a_colour_is_stored(client):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
r = client.put("/api/settings/", headers=auth(token),
json={"primary_color": "#58B900"})
assert r.status_code == 200, r.text
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] == "#58B900"
def test_reset_clears_back_to_inheriting(client):
"""The reset button sends null, and null has to persist — otherwise
"reset" would just freeze today's admin default into the user's row."""
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
client.put("/api/settings/", headers=auth(token), json={"primary_color": "#58B900"})
r = client.put("/api/settings/", headers=auth(token), json={"primary_color": None})
assert r.status_code == 200, r.text
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] is None
def test_unrelated_save_does_not_invent_a_colour(client):
"""Saving some other setting must not write a colour into the row."""
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "hugo")
client.put("/api/settings/", headers=auth(token), json={"week_start_day": "sunday"})
body = client.get("/api/settings/", headers=auth(token)).json()
assert body["week_start_day"] == "sunday"
for col in INHERITABLE:
assert body[col] is None