From 88e6d042cd29cb21a96bc7f08d3652837994fa3b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 01:13:29 +0200 Subject: [PATCH] Fix CI timezone and fresh settings initialization --- backend/app/main.py | 2 +- backend/app/migrations.py | 34 +++++++++++++++- backend/app/settings_store.py | 65 +++++++++++++++++++++++------- backend/tests/test_settings_api.py | 6 +++ 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index aa0c304..1e7c214 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -129,7 +129,7 @@ def discover(): def _validate_settings(payload: dict) -> None: try: ZoneInfo(payload.get("timezone") or "UTC") - except ZoneInfoNotFoundError as exc: + except (ValueError, ZoneInfoNotFoundError) as exc: raise HTTPException(400, "Timezone must be a valid IANA timezone, for example Europe/Berlin or UTC") from exc if payload.get("setup_complete"): missing = [ diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 26e815c..28cc94c 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -1,14 +1,32 @@ from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from .db import db, utc_now +def _normalize_timezone(value: str) -> str: + timezone_name = value.strip() + if not timezone_name: + return "UTC" + if timezone_name.startswith("/"): + marker = "/zoneinfo/" + if marker in timezone_name: + timezone_name = timezone_name.split(marker, 1)[1] + else: + timezone_name = timezone_name.lstrip("/") + try: + ZoneInfo(timezone_name) + except (ValueError, ZoneInfoNotFoundError): + return "UTC" + return timezone_name + + def _system_timezone() -> str: timezone_file = Path("/etc/timezone") if timezone_file.exists(): value = timezone_file.read_text().strip() if value: - return value + return _normalize_timezone(value) return "UTC" @@ -147,3 +165,17 @@ def run_migrations() -> None: "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", (3, now), ) + if 4 not in applied: + now = utc_now() + row = conn.execute("SELECT value FROM settings WHERE key = 'timezone'").fetchone() + if row: + normalized_timezone = _normalize_timezone(row["value"]) + if normalized_timezone != row["value"]: + conn.execute( + "UPDATE settings SET value = ?, updated_at = ? WHERE key = 'timezone'", + (normalized_timezone, now), + ) + conn.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (4, now), + ) diff --git a/backend/app/settings_store.py b/backend/app/settings_store.py index 626fed6..41b709c 100644 --- a/backend/app/settings_store.py +++ b/backend/app/settings_store.py @@ -1,7 +1,9 @@ from __future__ import annotations +import sqlite3 + from .db import db, utc_now -from .migrations import DEFAULT_SETTINGS +from .migrations import DEFAULT_SETTINGS, run_migrations INT_KEYS = {"default_retention_value", "max_concurrent_backups"} @@ -27,9 +29,20 @@ def serialize_value(key: str, value) -> str: return str(value) +def _is_missing_settings_table(exc: sqlite3.OperationalError) -> bool: + return "no such table: settings" in str(exc).lower() + + def get_settings() -> dict: - with db() as conn: - rows = conn.execute("SELECT key, value FROM settings").fetchall() + try: + with db() as conn: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + except sqlite3.OperationalError as exc: + if not _is_missing_settings_table(exc): + raise + run_migrations() + with db() as conn: + rows = conn.execute("SELECT key, value FROM settings").fetchall() raw = {row["key"]: row["value"] for row in rows} for key, value in DEFAULT_SETTINGS.items(): raw.setdefault(key, value) @@ -42,22 +55,44 @@ def set_settings(values: dict) -> dict: unknown = set(values) - valid_keys if unknown: raise ValueError(f"Unknown settings: {', '.join(sorted(unknown))}") - with db() as conn: - for key, value in values.items(): - conn.execute( - """ - INSERT INTO settings(key, value, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - """, - (key, serialize_value(key, value), now), - ) + try: + with db() as conn: + for key, value in values.items(): + conn.execute( + """ + INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + (key, serialize_value(key, value), now), + ) + except sqlite3.OperationalError as exc: + if not _is_missing_settings_table(exc): + raise + run_migrations() + with db() as conn: + for key, value in values.items(): + conn.execute( + """ + INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + (key, serialize_value(key, value), now), + ) return get_settings() def get_setting(key: str, default: str = "") -> str: - with db() as conn: - row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + try: + with db() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + except sqlite3.OperationalError as exc: + if not _is_missing_settings_table(exc): + raise + run_migrations() + with db() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() if row: return row["value"] return default diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index 0e69b53..0ad416c 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -1,5 +1,11 @@ from app import commands from app.main import _validate_settings +from app.migrations import _normalize_timezone + + +def test_system_timezone_absolute_utc_is_normalized(): + assert _normalize_timezone("/UTC") == "UTC" + assert _normalize_timezone("/usr/share/zoneinfo/Europe/Berlin") == "Europe/Berlin" def test_plain_onedrive_remote_is_accepted(monkeypatch):