diff --git a/backend/main.py b/backend/main.py index e1bf01d..262216e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/models.py b/backend/models.py index 0a42d9a..cdc77ce 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 093ad43..d6472ca 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -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 diff --git a/backend/tests/test_theme_inheritance.py b/backend/tests/test_theme_inheritance.py new file mode 100644 index 0000000..ed823e8 --- /dev/null +++ b/backend/tests/test_theme_inheritance.py @@ -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 diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index ecfb2d9..7f21fc4 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -3761,8 +3761,13 @@ function settingRowHtml(def) { valueHtml = ``; } else if (def.type === 'color') { const hex = normalizeHex(val, baseColor(def.key)); + // No own value means the row inherits the admin instance default. The + // field still shows that colour, but saving must send null — writing the + // hex back would freeze today's admin default into the user's row and + // later admin changes would stop reaching them. + const inherits = val == null ? ' data-inherit="1"' : ''; valueHtml = `