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:
co-authored by
Claude Opus 5
parent
aaecfc435d
commit
8bc32faa7f
@@ -383,6 +383,28 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
|
||||||
|
|||||||
+8
-5
@@ -95,16 +95,19 @@ class UserSettings(Base):
|
|||||||
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||||
default_view = Column(String(20), default="month")
|
default_view = Column(String(20), default="month")
|
||||||
week_start_day = Column(String(10), default="monday")
|
week_start_day = Column(String(10), default="monday")
|
||||||
primary_color = Column(String(7), default="#4285f4")
|
# NULL = inherit the admin instance default (see admin_router). A baked-in
|
||||||
accent_color = Column(String(7), default="#ea4335")
|
# column default would mean every user always *has* an own colour, so the
|
||||||
today_color = Column(String(7), default="#4285f4")
|
# 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)
|
dim_past_events = Column(Boolean, default=False)
|
||||||
text_contrast = Column(Integer, default=3)
|
text_contrast = Column(Integer, default=3)
|
||||||
line_contrast = Column(Integer, default=3)
|
line_contrast = Column(Integer, default=3)
|
||||||
hour_height = Column(Integer, default=60)
|
hour_height = Column(Integer, default=60)
|
||||||
language = Column(String(5), default="de")
|
language = Column(String(5), default="de")
|
||||||
month_divider_color = Column(String(7), default="#7090c0")
|
month_divider_color = Column(String(7), nullable=True) # NULL = inherit
|
||||||
month_label_color = Column(String(7), default="#7090c0")
|
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)
|
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)
|
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)
|
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
||||||
|
|||||||
@@ -108,8 +108,10 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"line_contrast": s.line_contrast or 3,
|
"line_contrast": s.line_contrast or 3,
|
||||||
"hour_height": s.hour_height or 60,
|
"hour_height": s.hour_height or 60,
|
||||||
"language": s.language or "de",
|
"language": s.language or "de",
|
||||||
"month_divider_color": s.month_divider_color or "#7090c0",
|
# NULL stays NULL: the client resolves it against the admin instance
|
||||||
"month_label_color": s.month_label_color or "#7090c0",
|
# 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,
|
"text_color": s.text_color,
|
||||||
"line_color": s.line_color,
|
"line_color": s.line_color,
|
||||||
"bg_color": s.bg_color,
|
"bg_color": s.bg_color,
|
||||||
@@ -186,7 +188,7 @@ def update_settings(
|
|||||||
# For these three override colours, an explicit null is meaningful
|
# For these three override colours, an explicit null is meaningful
|
||||||
# ("reset to default") and must be persisted as NULL. All other fields
|
# ("reset to default") and must be persisted as NULL. All other fields
|
||||||
# keep the previous behaviour where a null/missing value is ignored.
|
# 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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||||
|
|||||||
@@ -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
|
||||||
+15
-5
@@ -3761,8 +3761,13 @@ function settingRowHtml(def) {
|
|||||||
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
||||||
} else if (def.type === 'color') {
|
} else if (def.type === 'color') {
|
||||||
const hex = normalizeHex(val, baseColor(def.key));
|
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 = `<div class="settings-color-ctl">
|
valueHtml = `<div class="settings-color-ctl">
|
||||||
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
|
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}"${inherits} />
|
||||||
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
|
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
|
||||||
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -3817,20 +3822,23 @@ function wireSettingRow(def) {
|
|||||||
state.settings[def.key] = color;
|
state.settings[def.key] = color;
|
||||||
applyTheme(state.settings);
|
applyTheme(state.settings);
|
||||||
};
|
};
|
||||||
|
const own = () => { delete hex.dataset.inherit; };
|
||||||
prev.addEventListener('click', async () => {
|
prev.addEventListener('click', async () => {
|
||||||
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
||||||
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
if (picked) { hex.value = picked.toUpperCase(); own(); apply(picked); }
|
||||||
});
|
});
|
||||||
hex.addEventListener('input', () => {
|
hex.addEventListener('input', () => {
|
||||||
const norm = normalizeHex(hex.value, null);
|
const norm = normalizeHex(hex.value, null);
|
||||||
if (norm) apply(norm);
|
if (norm) { own(); apply(norm); }
|
||||||
});
|
});
|
||||||
hex.addEventListener('change', () => {
|
hex.addEventListener('change', () => {
|
||||||
const norm = normalizeHex(hex.value, baseColor(def.key));
|
const norm = normalizeHex(hex.value, baseColor(def.key));
|
||||||
hex.value = norm; apply(norm);
|
hex.value = norm; own(); apply(norm);
|
||||||
});
|
});
|
||||||
reset.addEventListener('click', () => {
|
reset.addEventListener('click', () => {
|
||||||
|
// Back to inheriting, not "copy the current admin default in".
|
||||||
const d = baseColor(def.key);
|
const d = baseColor(def.key);
|
||||||
|
hex.dataset.inherit = '1';
|
||||||
hex.value = d; apply(d);
|
hex.value = d; apply(d);
|
||||||
});
|
});
|
||||||
} else if (def.type === 'icon') {
|
} else if (def.type === 'icon') {
|
||||||
@@ -3860,7 +3868,9 @@ function readAppearanceTable() {
|
|||||||
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
||||||
} else if (def.type === 'color') {
|
} else if (def.type === 'color') {
|
||||||
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
||||||
out[def.key] = normalizeHex(el && el.value, baseColor(def.key));
|
out[def.key] = (el && el.dataset.inherit === '1')
|
||||||
|
? null // inherit the admin default
|
||||||
|
: normalizeHex(el && el.value, baseColor(def.key));
|
||||||
} else if (def.type === 'icon') {
|
} else if (def.type === 'icon') {
|
||||||
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
||||||
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v96';
|
export const APP_VERSION = 'v97';
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
// the entry HTML / version files). New releases take effect on the next
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v42';
|
const CACHE_VERSION = 'calendarr-v43';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user