782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
326 lines
10 KiB
Python
326 lines
10 KiB
Python
"""Migration 010's downgrade: reconstruct `is_pinned`/`planned_day_exclusion`
|
|
from `day_assertion` instead of losing them.
|
|
|
|
The naive downgrade — `ADD COLUMN is_pinned ... DEFAULT 0` plus a bare
|
|
`CREATE TABLE planned_day_exclusion` — silently unpins every previously
|
|
pinned day and drops every exclusion, then a further `downgrade` to 008
|
|
deletes `day_assertion` itself, at which point the facts are gone for good.
|
|
This proves the fixed downgrade instead reconstructs both shapes from
|
|
`day_assertion` (still present at the moment 010's `downgrade()` runs) for a
|
|
full `upgrade head` -> `downgrade 008` round trip, and pins down exactly the
|
|
two cases that are honestly NOT round-trippable (see 010's docstring):
|
|
|
|
- a concrete assertion whose `wish_id IS NULL`-driven mirrored row was never
|
|
pinned still comes back with `is_pinned = 1` (over-restored, not
|
|
under-restored — `day_assertion` never recorded *why* a row was asserted).
|
|
- a pinned row that collided with an exclusion at the same key loses its
|
|
`is_pinned` fact at 009's *upgrade* already (`INSERT OR REPLACE`, exclusion
|
|
wins), long before 010's downgrade runs, so there is nothing left to
|
|
recover it from.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
|
|
def _alembic_config(db_path: Path) -> Config:
|
|
cfg = Config("alembic.ini")
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
|
return cfg
|
|
|
|
|
|
def _seed_workspace_and_child(conn: sqlite3.Connection, now: str) -> tuple[str, str]:
|
|
ws_id = str(uuid.uuid4())
|
|
child_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (ws_id, "WS", now)
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO child (id, workspace_id, name, birth_date, "
|
|
"preschool_start_date, preschool_planning_days_per_year, created_at) "
|
|
"VALUES (?, ?, ?, ?, NULL, 0, ?)",
|
|
(child_id, ws_id, "Barn", date(2026, 5, 1).isoformat(), now),
|
|
)
|
|
return ws_id, child_id
|
|
|
|
|
|
def _insert_planned_leave_day(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
workspace_id: str,
|
|
child_id: str,
|
|
parent_id: str,
|
|
day: date,
|
|
day_type: str,
|
|
is_pinned: bool,
|
|
wish_id: str | None,
|
|
now: str,
|
|
) -> str:
|
|
row_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO planned_leave_day "
|
|
"(id, workspace_id, child_id, parent_personal_data_uuid, date, day_type, "
|
|
" is_dubbeldag, wish_id, is_pinned, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)",
|
|
(
|
|
row_id,
|
|
workspace_id,
|
|
child_id,
|
|
parent_id,
|
|
day.isoformat(),
|
|
day_type,
|
|
wish_id,
|
|
int(is_pinned),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
return row_id
|
|
|
|
|
|
def _insert_exclusion(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
workspace_id: str,
|
|
child_id: str,
|
|
parent_id: str,
|
|
day: date,
|
|
now: str,
|
|
) -> None:
|
|
conn.execute(
|
|
"INSERT INTO planned_day_exclusion "
|
|
"(id, workspace_id, child_id, parent_personal_data_uuid, date, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(str(uuid.uuid4()), workspace_id, child_id, parent_id, day.isoformat(), now, now),
|
|
)
|
|
|
|
|
|
def _planned_day(db_path: Path, row_id: str) -> sqlite3.Row:
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
row = conn.execute("SELECT * FROM planned_leave_day WHERE id = ?", (row_id,)).fetchone()
|
|
assert row is not None
|
|
return row
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _exclusion_exists(
|
|
db_path: Path, *, workspace_id: str, child_id: str, parent_id: str, day: date
|
|
) -> bool:
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM planned_day_exclusion "
|
|
"WHERE workspace_id = ? AND child_id = ? AND parent_personal_data_uuid = ? "
|
|
"AND date = ?",
|
|
(workspace_id, child_id, parent_id, day.isoformat()),
|
|
).fetchone()
|
|
return row is not None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_full_round_trip_reconstructs_pins_and_exclusions(tmp_path: Path) -> None:
|
|
db = tmp_path / "roundtrip.db"
|
|
command.upgrade(_alembic_config(db), "008")
|
|
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
|
|
|
conn = sqlite3.connect(db)
|
|
ws_id, child_id = _seed_workspace_and_child(conn, now)
|
|
parent_id = str(uuid.uuid4())
|
|
wish_id = str(uuid.uuid4())
|
|
|
|
pinned_day = date(2027, 1, 10)
|
|
pinned_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=pinned_day,
|
|
day_type="lagsta",
|
|
is_pinned=True,
|
|
wish_id=wish_id,
|
|
now=now,
|
|
)
|
|
|
|
adhoc_day = date(2027, 1, 11)
|
|
adhoc_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=adhoc_day,
|
|
day_type="vacation",
|
|
is_pinned=False,
|
|
wish_id=None,
|
|
now=now,
|
|
)
|
|
|
|
pinned_and_adhoc_day = date(2027, 1, 12)
|
|
pinned_and_adhoc_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=pinned_and_adhoc_day,
|
|
day_type="sjukpenning",
|
|
is_pinned=True,
|
|
wish_id=None,
|
|
now=now,
|
|
)
|
|
|
|
derived_day = date(2027, 1, 13)
|
|
derived_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=derived_day,
|
|
day_type="sjukpenning",
|
|
is_pinned=False,
|
|
wish_id=wish_id,
|
|
now=now,
|
|
)
|
|
|
|
plain_exclusion_day = date(2027, 1, 14)
|
|
_insert_exclusion(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=plain_exclusion_day,
|
|
now=now,
|
|
)
|
|
|
|
# A pinned row and a colliding exclusion at the same key (a historical
|
|
# anomaly the runtime never creates going forward, but old data might
|
|
# still hold) — 009's upgrade resolves this "exclusion wins".
|
|
collision_day = date(2027, 1, 15)
|
|
collision_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=collision_day,
|
|
day_type="sjukpenning",
|
|
is_pinned=True,
|
|
wish_id=None,
|
|
now=now,
|
|
)
|
|
_insert_exclusion(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=collision_day,
|
|
now=now,
|
|
)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
command.downgrade(_alembic_config(db), "008")
|
|
|
|
# -- pinned row: round-trips exactly ------------------------------------
|
|
pinned_row = _planned_day(db, pinned_id)
|
|
assert bool(pinned_row["is_pinned"]) is True
|
|
assert pinned_row["day_type"] == "lagsta"
|
|
assert pinned_row["wish_id"] == wish_id
|
|
|
|
# -- ad-hoc, never-pinned row: over-restored, documented and expected --
|
|
# `day_assertion` never recorded whether a concrete assertion came from
|
|
# `is_pinned=1` or from `wish_id IS NULL` (009's own upgrade backfill
|
|
# already conflates them: `WHERE is_pinned = 1 OR wish_id IS NULL`), so
|
|
# this row comes back pinned even though it started unpinned. It is
|
|
# still exempt from the allocator via `wish_id IS NULL`, unaffected by
|
|
# this bit either way.
|
|
adhoc_row = _planned_day(db, adhoc_id)
|
|
assert bool(adhoc_row["is_pinned"]) is True
|
|
assert adhoc_row["wish_id"] is None
|
|
|
|
# -- pinned AND ad-hoc: round-trips exactly (was already pinned) -------
|
|
both_row = _planned_day(db, pinned_and_adhoc_id)
|
|
assert bool(both_row["is_pinned"]) is True
|
|
|
|
# -- ordinary planner-derived row: never asserted, stays unpinned -------
|
|
derived_row = _planned_day(db, derived_id)
|
|
assert bool(derived_row["is_pinned"]) is False
|
|
assert derived_row["wish_id"] == wish_id
|
|
|
|
# -- plain exclusion: round-trips exactly --------------------------------
|
|
assert _exclusion_exists(
|
|
db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=plain_exclusion_day
|
|
)
|
|
|
|
# -- collision: exclusion wins, is_pinned is a KNOWN, documented loss ---
|
|
# 009's upgrade already discarded the pinned assertion at this key via
|
|
# `INSERT OR REPLACE` (exclusion wins) before 010 ever runs, so there is
|
|
# no assertion left for 010's downgrade to recover `is_pinned` from.
|
|
# This is not a bug in the downgrade under test — it is a fact already
|
|
# lost one migration earlier, and the exclusion itself is what survives.
|
|
collision_row = _planned_day(db, collision_id)
|
|
assert bool(collision_row["is_pinned"]) is False
|
|
assert _exclusion_exists(
|
|
db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=collision_day
|
|
)
|
|
|
|
|
|
def test_concrete_assertion_with_no_mirrored_row_does_not_error(tmp_path: Path) -> None:
|
|
"""An assertion can outlive its `planned_leave_day` mirror (e.g. a later
|
|
|
|
plan sync re-derives the day away). Reconstruction must not crash on it
|
|
— there is simply nothing to set `is_pinned` on.
|
|
"""
|
|
db = tmp_path / "orphan.db"
|
|
command.upgrade(_alembic_config(db), "008")
|
|
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
|
|
|
conn = sqlite3.connect(db)
|
|
ws_id, child_id = _seed_workspace_and_child(conn, now)
|
|
parent_id = str(uuid.uuid4())
|
|
day = date(2027, 2, 1)
|
|
row_id = _insert_planned_leave_day(
|
|
conn,
|
|
workspace_id=ws_id,
|
|
child_id=child_id,
|
|
parent_id=parent_id,
|
|
day=day,
|
|
day_type="vacation",
|
|
is_pinned=True,
|
|
wish_id=None,
|
|
now=now,
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
command.upgrade(_alembic_config(db), "head")
|
|
|
|
# Delete the mirrored row after the assertion was backfilled, simulating
|
|
# a later re-derivation that removed it — the assertion survives.
|
|
conn = sqlite3.connect(db)
|
|
conn.execute("DELETE FROM planned_leave_day WHERE id = ?", (row_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Must downgrade cleanly, not raise.
|
|
command.downgrade(_alembic_config(db), "008")
|
|
|
|
conn = sqlite3.connect(db)
|
|
try:
|
|
remaining = conn.execute(
|
|
"SELECT COUNT(*) FROM planned_leave_day WHERE workspace_id = ?", (ws_id,)
|
|
).fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
assert remaining == 0
|