Merge pull request 'Release: timezone persistence fix (0.9.4) (2026-09-26)' (#270) from release/2026-09-26-timezone-persistence into main
Reviewed-on: #270 Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
This commit was merged in pull request #270.
This commit is contained in:
@@ -1,5 +1,37 @@
|
|||||||
# NestQuest Release Notes
|
# NestQuest Release Notes
|
||||||
|
|
||||||
|
## Version 0.9.4 — 2026-09-26
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
Patch release fixing the admin timezone control that shipped in 0.9.3.
|
||||||
|
|
||||||
|
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
|
||||||
|
re-detected the browser zone whenever the timezone field was empty, so an admin
|
||||||
|
could not leave it empty ("use the API host's local time") — closing and
|
||||||
|
reopening the screen set it again. A new persisted, API-only
|
||||||
|
`timezone_configured` marker (default `false`) now distinguishes "never set"
|
||||||
|
from "admin chose empty": the one-time auto-set runs only when the timezone is
|
||||||
|
empty AND the marker is false, and any saved timezone change — including
|
||||||
|
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
|
||||||
|
#268.)
|
||||||
|
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
|
||||||
|
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
|
||||||
|
as in 0.9.3.
|
||||||
|
|
||||||
|
### Upgrade note
|
||||||
|
|
||||||
|
The database schema is unchanged at version **9**; no migration or backup step is
|
||||||
|
needed. Settings stored before this release have no `timezone_configured` value
|
||||||
|
and read it as `false`, so an install whose timezone is empty will auto-set the
|
||||||
|
browser zone once on the next open — after which the choice persists.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||||
|
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||||
|
mirror).
|
||||||
|
|
||||||
## Version 0.9.3 — 2026-09-26
|
## Version 0.9.3 — 2026-09-26
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface HouseholdSettings {
|
|||||||
celebration_enabled: boolean;
|
celebration_enabled: boolean;
|
||||||
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
|
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
/**
|
||||||
|
* True once the admin explicitly chose `timezone` (even ""); false on a
|
||||||
|
* fresh install, where the screen auto-sets the browser's zone. API-only:
|
||||||
|
* never shown in the form.
|
||||||
|
*/
|
||||||
|
timezone_configured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the supplied fields are updated; the API answers with all of them. */
|
/** Only the supplied fields are updated; the API answers with all of them. */
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
|
|||||||
end_of_day_report_enabled: true,
|
end_of_day_report_enabled: true,
|
||||||
celebration_enabled: false,
|
celebration_enabled: false,
|
||||||
timezone: "America/Chicago",
|
timezone: "America/Chicago",
|
||||||
|
timezone_configured: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function child(
|
function child(
|
||||||
@@ -506,7 +507,9 @@ describe("SettingsScreen — preferences", () => {
|
|||||||
fireEvent.change(input, { target: { value: " Europe/London " } });
|
fireEvent.change(input, { target: { value: " Europe/London " } });
|
||||||
save(form);
|
save(form);
|
||||||
await screen.findByText("Preferences saved.");
|
await screen.findByText("Preferences saved.");
|
||||||
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } }]);
|
expect(writes()).toEqual([
|
||||||
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } },
|
||||||
|
]);
|
||||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||||
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
|
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
|
||||||
});
|
});
|
||||||
@@ -572,7 +575,8 @@ describe("SettingsScreen — timezone auto-set", () => {
|
|||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
children = [child(1, "Declan", 1)];
|
children = [child(1, "Declan", 1)];
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
// A fresh install: no zone, and the admin never chose one.
|
||||||
|
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
|
||||||
writeResponse = null;
|
writeResponse = null;
|
||||||
zoneSpy = null;
|
zoneSpy = null;
|
||||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
@@ -596,9 +600,10 @@ describe("SettingsScreen — timezone auto-set", () => {
|
|||||||
const form = await renderForm();
|
const form = await renderForm();
|
||||||
expect(field(form, "Timezone").value).toBe("America/New_York");
|
expect(field(form, "Timezone").value).toBe("America/New_York");
|
||||||
expect(writes()).toEqual([
|
expect(writes()).toEqual([
|
||||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
|
||||||
]);
|
]);
|
||||||
expect(settings.timezone).toBe("America/New_York");
|
expect(settings.timezone).toBe("America/New_York");
|
||||||
|
expect(settings.timezone_configured).toBe(true);
|
||||||
|
|
||||||
// A later save of another field does not re-send the timezone.
|
// A later save of another field does not re-send the timezone.
|
||||||
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
||||||
@@ -618,24 +623,41 @@ describe("SettingsScreen — timezone auto-set", () => {
|
|||||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
const form = await screen.findByRole("form", { name: "Preferences" });
|
||||||
expect(field(form, "Timezone").value).toBe("America/New_York");
|
expect(field(form, "Timezone").value).toBe("America/New_York");
|
||||||
expect(writes()).toEqual([
|
expect(writes()).toEqual([
|
||||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an admin clearing the timezone is not re-set by the browser zone", async () => {
|
it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", async () => {
|
||||||
mockBrowserZone("America/New_York");
|
mockBrowserZone("America/New_York");
|
||||||
const form = await renderForm();
|
const form = await renderForm();
|
||||||
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
|
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
|
||||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||||
await screen.findByText("Preferences saved.");
|
await screen.findByText("Preferences saved.");
|
||||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "America/New_York" }, { timezone: "" }]);
|
expect(writes().map((w) => w.body)).toEqual([
|
||||||
|
{ timezone: "America/New_York", timezone_configured: true },
|
||||||
|
{ timezone: "", timezone_configured: true },
|
||||||
|
]);
|
||||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||||
expect(field(reloaded, "Timezone").value).toBe("");
|
expect(field(reloaded, "Timezone").value).toBe("");
|
||||||
|
|
||||||
|
// Close and reopen: the screen re-fetches "" but the choice persisted.
|
||||||
|
cleanup();
|
||||||
|
const reopened = await renderForm();
|
||||||
|
expect(field(reopened, "Timezone").value).toBe("");
|
||||||
|
expect(writes()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty timezone the admin chose is never auto-set", async () => {
|
||||||
|
mockBrowserZone("America/New_York");
|
||||||
|
settings = { ...settings, timezone_configured: true };
|
||||||
|
const form = await renderForm();
|
||||||
|
expect(field(form, "Timezone").value).toBe("");
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a set timezone is shown and never overwritten by the browser zone", async () => {
|
it("a set timezone is shown and never overwritten by the browser zone", async () => {
|
||||||
mockBrowserZone("America/New_York");
|
mockBrowserZone("America/New_York");
|
||||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
|
||||||
const form = await renderForm();
|
const form = await renderForm();
|
||||||
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
|
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
|
||||||
expect(writes()).toEqual([]);
|
expect(writes()).toEqual([]);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
* household's child profiles. Every write refetches the list.
|
* household's child profiles. Every write refetches the list.
|
||||||
*
|
*
|
||||||
* Preferences: the household settings (planning horizon, day rollover,
|
* Preferences: the household settings (planning horizon, day rollover,
|
||||||
* timezone, notifications). Save patches only the changed fields. An unset
|
* timezone, notifications). Save patches only the changed fields. A timezone
|
||||||
* timezone is set once, on load, to the browser's own zone.
|
* the admin never chose is set once, on load, to the browser's own zone;
|
||||||
|
* saving the timezone (even clearing it) marks it chosen, so it sticks.
|
||||||
*
|
*
|
||||||
* One write at a time across both sections.
|
* One write at a time across both sections.
|
||||||
*/
|
*/
|
||||||
@@ -341,6 +342,9 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
|
|||||||
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
|
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
|
||||||
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
|
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
|
||||||
}
|
}
|
||||||
|
// An explicit choice — including "" (the API host's local time) — must
|
||||||
|
// persist, or the next load would auto-set the browser's zone over it.
|
||||||
|
if ("timezone" in changes) changes.timezone_configured = true;
|
||||||
if (Object.keys(changes).length === 0) return "No changes to save.";
|
if (Object.keys(changes).length === 0) return "No changes to save.";
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
@@ -570,7 +574,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
// A ref, not state: two taps in one frame must not both see `busy === null`.
|
// A ref, not state: two taps in one frame must not both see `busy === null`.
|
||||||
const inFlight = useRef(false);
|
const inFlight = useRef(false);
|
||||||
// The unset-timezone auto-set fires at most once per screen.
|
// The never-chosen-timezone auto-set fires at most once per screen.
|
||||||
const timezoneAutoSet = useRef(false);
|
const timezoneAutoSet = useRef(false);
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
@@ -618,16 +622,18 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
|||||||
fetchSettings()
|
fetchSettings()
|
||||||
.then(async (settings) => {
|
.then(async (settings) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Unset: adopt the browser's zone before the form shows, so nothing
|
// Never chosen: adopt the browser's zone before the form shows, so
|
||||||
// the admin enters can be overwritten by it.
|
// nothing the admin enters can be overwritten by it. An empty zone the
|
||||||
const detected = settings.timezone === "" ? detectTimezone() : "";
|
// admin chose (timezone_configured) is left alone.
|
||||||
|
const unchosen = settings.timezone === "" && !settings.timezone_configured;
|
||||||
|
const detected = unchosen ? detectTimezone() : "";
|
||||||
if (!detected || timezoneAutoSet.current) {
|
if (!detected || timezoneAutoSet.current) {
|
||||||
setPreferences({ kind: "ready", settings });
|
setPreferences({ kind: "ready", settings });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timezoneAutoSet.current = true;
|
timezoneAutoSet.current = true;
|
||||||
try {
|
try {
|
||||||
const saved = await updateSettings({ timezone: detected });
|
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
|
||||||
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
|
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
+12
-7
@@ -165,12 +165,13 @@ integration's Home-Assistant config-entry options remain a temporary,
|
|||||||
SEPARATE source until Feature 18 wires the client to the API — the two
|
SEPARATE source until Feature 18 wires the client to the API — the two
|
||||||
are deliberately not unified here:
|
are deliberately not unified here:
|
||||||
|
|
||||||
- ``GET /settings`` returns the current effective settings: all eleven
|
- ``GET /settings`` returns the current effective settings: all twelve
|
||||||
documented fields (the rolling horizon, the day rollover time, the
|
documented fields (the rolling horizon, the day rollover time, the
|
||||||
notify target, the three notification times, the four enable
|
notify target, the three notification times, the four enable
|
||||||
toggles and the ``timezone`` — the IANA household zone the
|
toggles, the ``timezone`` — the IANA household zone the
|
||||||
household-local dates derive from, empty meaning host local) — the
|
household-local dates derive from, empty meaning host local — and
|
||||||
defaults on a fresh database.
|
``timezone_configured``, whether the admin explicitly chose that
|
||||||
|
zone) — the defaults on a fresh database.
|
||||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||||
JSON object of settings field names to new values, passed STRAIGHT
|
JSON object of settings field names to new values, passed STRAIGHT
|
||||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||||
@@ -905,7 +906,7 @@ class AdminSettingsResponse(BaseModel):
|
|||||||
"""The effective settings as the admin plane serializes them.
|
"""The effective settings as the admin plane serializes them.
|
||||||
|
|
||||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||||
— all ELEVEN documented fields, resolved (every absent stored field
|
— all TWELVE documented fields, resolved (every absent stored field
|
||||||
already fell back to its default in the core). The same shape both
|
already fell back to its default in the core). The same shape both
|
||||||
the GET and the PATCH route answer with.
|
the GET and the PATCH route answer with.
|
||||||
"""
|
"""
|
||||||
@@ -923,6 +924,9 @@ class AdminSettingsResponse(BaseModel):
|
|||||||
#: The household IANA time zone the API reads its clock in; ``""``
|
#: The household IANA time zone the API reads its clock in; ``""``
|
||||||
#: means the API host's local time.
|
#: means the API host's local time.
|
||||||
timezone: str
|
timezone: str
|
||||||
|
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
|
||||||
|
#: ``False`` lets the admin UI auto-set the browser's zone once.
|
||||||
|
timezone_configured: bool
|
||||||
|
|
||||||
|
|
||||||
class AdminMissedSweepResponse(BaseModel):
|
class AdminMissedSweepResponse(BaseModel):
|
||||||
@@ -2179,7 +2183,7 @@ def _settings_response(
|
|||||||
"""Serialize one core NestQuestSettings into the documented payload.
|
"""Serialize one core NestQuestSettings into the documented payload.
|
||||||
|
|
||||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||||
stays pinned to the eleven documented fields even if the core
|
stays pinned to the twelve documented fields even if the core
|
||||||
dataclass later grows another one.
|
dataclass later grows another one.
|
||||||
"""
|
"""
|
||||||
return AdminSettingsResponse(
|
return AdminSettingsResponse(
|
||||||
@@ -2194,6 +2198,7 @@ def _settings_response(
|
|||||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||||
celebration_enabled=settings.celebration_enabled,
|
celebration_enabled=settings.celebration_enabled,
|
||||||
timezone=settings.timezone,
|
timezone=settings.timezone,
|
||||||
|
timezone_configured=settings.timezone_configured,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2291,7 +2296,7 @@ async def admin_history_csv(
|
|||||||
response_model=AdminSettingsResponse,
|
response_model=AdminSettingsResponse,
|
||||||
)
|
)
|
||||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||||
"""Return the current effective settings (all eleven documented fields).
|
"""Return the current effective settings (all twelve documented fields).
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
Requires a valid admin JWT (the router's shared
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ DEFAULT_AUTOMATION_ENABLED = True
|
|||||||
# Home Assistant's configured ``time_zone`` and ignores this field.
|
# Home Assistant's configured ``time_zone`` and ignores this field.
|
||||||
CONF_TIMEZONE = "timezone"
|
CONF_TIMEZONE = "timezone"
|
||||||
DEFAULT_TIMEZONE = ""
|
DEFAULT_TIMEZONE = ""
|
||||||
|
# Whether the admin has made an explicit ``timezone`` choice. ``False``
|
||||||
|
# (a fresh install) lets the admin UI auto-set the browser's zone once;
|
||||||
|
# ``True`` pins the stored value — including an explicitly-chosen empty
|
||||||
|
# ``timezone`` (the API host's local time) — so it is never auto-set again.
|
||||||
|
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
|
||||||
|
DEFAULT_TIMEZONE_CONFIGURED = False
|
||||||
|
|
||||||
# Panel-plane API client (Feature 18): the integration reaches the
|
# Panel-plane API client (Feature 18): the integration reaches the
|
||||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from .const import (
|
|||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
CONF_NOTIFY_TARGET,
|
CONF_NOTIFY_TARGET,
|
||||||
CONF_TIMEZONE,
|
CONF_TIMEZONE,
|
||||||
|
CONF_TIMEZONE_CONFIGURED,
|
||||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||||
DEFAULT_AUTOMATION_ENABLED,
|
DEFAULT_AUTOMATION_ENABLED,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
@@ -56,6 +57,7 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_MORNING_SUMMARY_TIME,
|
DEFAULT_MORNING_SUMMARY_TIME,
|
||||||
DEFAULT_TIMEZONE,
|
DEFAULT_TIMEZONE,
|
||||||
|
DEFAULT_TIMEZONE_CONFIGURED,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
TIME_PATTERN,
|
TIME_PATTERN,
|
||||||
)
|
)
|
||||||
@@ -241,6 +243,12 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
|||||||
_resolve_bool,
|
_resolve_bool,
|
||||||
),
|
),
|
||||||
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||||
|
(
|
||||||
|
"timezone_configured",
|
||||||
|
CONF_TIMEZONE_CONFIGURED,
|
||||||
|
DEFAULT_TIMEZONE_CONFIGURED,
|
||||||
|
_resolve_bool,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +279,10 @@ class NestQuestSettings:
|
|||||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||||
timezone: str = DEFAULT_TIMEZONE
|
timezone: str = DEFAULT_TIMEZONE
|
||||||
|
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
|
||||||
|
#: the empty host-local value); ``False`` on a fresh install, where
|
||||||
|
#: the admin UI may auto-set the browser's zone.
|
||||||
|
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
# Validate STRICTLY at construction so a hand-built instance
|
# Validate STRICTLY at construction so a hand-built instance
|
||||||
@@ -303,6 +315,7 @@ class NestQuestSettings:
|
|||||||
"afternoon_reminder_enabled",
|
"afternoon_reminder_enabled",
|
||||||
"end_of_day_report_enabled",
|
"end_of_day_report_enabled",
|
||||||
"celebration_enabled",
|
"celebration_enabled",
|
||||||
|
"timezone_configured",
|
||||||
):
|
):
|
||||||
value = getattr(self, name)
|
value = getattr(self, name)
|
||||||
if not isinstance(value, bool):
|
if not isinstance(value, bool):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
|
|||||||
side reads the other's values.
|
side reads the other's values.
|
||||||
|
|
||||||
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
||||||
object holding exactly the ELEVEN settings fields, keyed by field name
|
object holding exactly the TWELVE settings fields, keyed by field name
|
||||||
(which equals the ``CONF_*`` option key for every field), complete
|
(which equals the ``CONF_*`` option key for every field), complete
|
||||||
after the first update:
|
after the first update:
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ after the first update:
|
|||||||
booleans,
|
booleans,
|
||||||
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
|
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
|
||||||
zone name such as ``America/New_York``. A document stored before
|
zone name such as ``America/New_York``. A document stored before
|
||||||
this field existed simply lacks it and resolves to ``""``.
|
this field existed simply lacks it and resolves to ``""``,
|
||||||
|
- ``timezone_configured`` — a real boolean: ``True`` once the admin
|
||||||
|
explicitly chose ``timezone`` (including the empty host-local value),
|
||||||
|
so the admin UI never auto-sets it again. A document stored before
|
||||||
|
this field existed lacks it and resolves to ``False``.
|
||||||
|
|
||||||
Only values whose core validators ACCEPTED are ever written, so a
|
Only values whose core validators ACCEPTED are ever written, so a
|
||||||
stored document always round-trips through the core's option builders.
|
stored document always round-trips through the core's option builders.
|
||||||
@@ -175,7 +179,7 @@ async def update_settings(
|
|||||||
update can never leave a half-applied document behind.
|
update can never leave a half-applied document behind.
|
||||||
|
|
||||||
The validated changes are merged onto the current effective
|
The validated changes are merged onto the current effective
|
||||||
settings and the merged (complete, eleven-field) document is persisted
|
settings and the merged (complete, twelve-field) document is persisted
|
||||||
in one atomic upsert; the updated settings are returned. The whole
|
in one atomic upsert; the updated settings are returned. The whole
|
||||||
read → merge → write span runs inside the per-database settings
|
read → merge → write span runs inside the per-database settings
|
||||||
lock, so concurrent updates queue instead of interleaving.
|
lock, so concurrent updates queue instead of interleaving.
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.9.3"
|
"version": "0.9.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nestquest"
|
name = "nestquest"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
description = "NestQuest Home Assistant Integration"
|
description = "NestQuest Home Assistant Integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
|
|||||||
Proven per the done-condition, everything read back through the API:
|
Proven per the done-condition, everything read back through the API:
|
||||||
|
|
||||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||||
— the eleven documented fields at their defaults on a fresh database.
|
— the twelve documented fields at their defaults on a fresh database.
|
||||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||||
NEXT read reflects them; a later partial update merges onto the
|
NEXT read reflects them; a later partial update merges onto the
|
||||||
current effective settings (omitted fields keep their stored value),
|
current effective settings (omitted fields keep their stored value),
|
||||||
@@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
|||||||
SETTINGS_META_KEY = "settings"
|
SETTINGS_META_KEY = "settings"
|
||||||
|
|
||||||
#: The all-defaults effective settings a fresh database reports — the
|
#: The all-defaults effective settings a fresh database reports — the
|
||||||
#: documented eleven fields, asserted literally so the payload contract is
|
#: documented twelve fields, asserted literally so the payload contract is
|
||||||
#: pinned here, independent of the core constants.
|
#: pinned here, independent of the core constants.
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
"horizon_days": 14,
|
"horizon_days": 14,
|
||||||
@@ -74,6 +74,7 @@ DEFAULT_SETTINGS = {
|
|||||||
"end_of_day_report_enabled": True,
|
"end_of_day_report_enabled": True,
|
||||||
"celebration_enabled": True,
|
"celebration_enabled": True,
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
|
"timezone_configured": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
|||||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||||
settings_client: SimpleNamespace,
|
settings_client: SimpleNamespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""GET returns the eleven documented fields at their defaults."""
|
"""GET returns the twelve documented fields at their defaults."""
|
||||||
response = await settings_client.client.get(
|
response = await settings_client.client.get(
|
||||||
"/api/v1/admin/settings", headers=_admin_headers()
|
"/api/v1/admin/settings", headers=_admin_headers()
|
||||||
)
|
)
|
||||||
@@ -140,7 +141,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
|
|||||||
assert reread.status_code == 200
|
assert reread.status_code == 200
|
||||||
assert reread.json() == expected
|
assert reread.json() == expected
|
||||||
|
|
||||||
# ...and the DATABASE holds the merged document: the complete eleven
|
# ...and the DATABASE holds the merged document: the complete twelve
|
||||||
# fields, keyed by field name, under the well-known meta_state key.
|
# fields, keyed by field name, under the well-known meta_state key.
|
||||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||||
SETTINGS_META_KEY
|
SETTINGS_META_KEY
|
||||||
@@ -222,6 +223,40 @@ async def test_null_resets_a_field_to_its_default(
|
|||||||
assert reread.json()["horizon_days"] == 14
|
assert reread.json()["horizon_days"] == 14
|
||||||
|
|
||||||
|
|
||||||
|
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||||
|
settings_client: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
"""An explicit empty (host-local) zone persists as configured.
|
||||||
|
|
||||||
|
The admin clearing the zone PATCHes ``timezone: ""`` together with
|
||||||
|
``timezone_configured: true``; the NEXT read — a reopened settings
|
||||||
|
screen — sees the marker, so the empty value is a persisted choice
|
||||||
|
rather than a fresh install to auto-set.
|
||||||
|
"""
|
||||||
|
client = settings_client.client
|
||||||
|
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
|
||||||
|
assert fresh.json()["timezone_configured"] is False
|
||||||
|
|
||||||
|
patched = await client.patch(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"timezone": "", "timezone_configured": True},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
|
||||||
|
assert patched.json() == expected
|
||||||
|
|
||||||
|
reread = await client.get(
|
||||||
|
"/api/v1/admin/settings", headers=_admin_headers()
|
||||||
|
)
|
||||||
|
assert reread.json() == expected
|
||||||
|
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||||
|
SETTINGS_META_KEY
|
||||||
|
)
|
||||||
|
assert raw is not None
|
||||||
|
assert json.loads(raw)["timezone_configured"] is True
|
||||||
|
|
||||||
|
|
||||||
# --- validation: rejected values never change the stored settings -------
|
# --- validation: rejected values never change the stored settings -------
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +273,8 @@ async def test_null_resets_a_field_to_its_default(
|
|||||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||||
({"notify_target": 5}, "notify_target"),
|
({"notify_target": 5}, "notify_target"),
|
||||||
|
({"timezone_configured": "true"}, "timezone_configured"),
|
||||||
|
({"timezone_configured": 1}, "timezone_configured"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||||
|
|||||||
@@ -236,6 +236,25 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
|
||||||
|
assert _settings.NestQuestSettings().timezone_configured is False
|
||||||
|
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
|
||||||
|
assert (
|
||||||
|
_settings.NestQuestSettings.from_options(
|
||||||
|
{"timezone_configured": True}
|
||||||
|
).timezone_configured
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
for value in ("true", 1, None):
|
||||||
|
with pytest.raises(ValueError, match="timezone_configured"):
|
||||||
|
_settings.NestQuestSettings(timezone_configured=value)
|
||||||
|
if value is not None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_settings.NestQuestSettings.from_options(
|
||||||
|
{"timezone_configured": value}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- the missed-sweep scheduler ----------------------------------------------
|
# --- the missed-sweep scheduler ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user