"""Materialise repeating leave wishes into one row per year, grouped by series_id. A repeating wish used to be ONE row (`recurrence = 'yearly'` plus `first_year`/`last_year`), and every year's window was re-derived at read time by `LeaveWish.window_for_year()`. That made a single year impossible to edit (there was no row for it) and a single year impossible to remove without narrowing the range or splitting the wish in two. It also left nowhere to put a per-year offset — which the planner genuinely needs, because Easter moves. This migration therefore: 1. drops the `recurrence` column and adds a nullable, indexed `series_id` (SQLite has no `DROP CONSTRAINT`, and the column carries a CHECK, so the table is rebuilt and its indexes recreated — the standard SQLite dance, see https://sqlite.org/lang_altertable.html#otheralter and migration 002); 2. backfills: every `recurrence = 'yearly'` row is expanded into one row per year, sharing a freshly minted `series_id`. Each new row stores the exact dates the OLD `window_for_year(year)` would have produced for that year, *including* the fixed-mode weekday snap added in 224fe7a — the projection is copied verbatim below rather than imported from `app.entities`, so this migration keeps describing the 2026-09 behaviour even after that module moves on. The original row is kept (same id, so its `planned_leave_day.wish_id` references survive) and becomes the series' `first_year` occurrence, whose window is by definition unshifted. `last_year` survives as the SERIES-level intent, mirrored onto every row of a series (NULL = "until the child turns 4"); standalone rows get NULL. Must stay byte-for-byte equivalent to the `leave_wish` table in `app/db/connection.py`'s `_FULL_SCHEMA` — `tests/integration/test_schema_parity.py` asserts exactly that. Revision ID: 004 Revises: 003 Create Date: 2026-09-19 """ from __future__ import annotations import calendar import datetime as dt import uuid from typing import Any from alembic import op revision: str = "004" down_revision: str | None = "003" branch_labels: str | None = None depends_on: str | None = None # How far a `last_year IS NULL` ("until the child turns 4") series is expanded # when nothing else bounds it. Mirrors the allocator's own childless fallback # horizon (`_compute_horizon_end`: today.year + 4) and the cached Swedish # calendar's upper bound (`swedish_calendar.json` covers 2026-2055; planning # past it raises). Hardcoded rather than imported: a migration is a snapshot. _CHILDLESS_YEARS_AHEAD = 4 _MAX_MATERIALISED_YEAR = 2055 _MAX_SERIES_LENGTH_YEARS = 30 _UPGRADE_DDL = """ CREATE TABLE leave_wish_new ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, child_id TEXT, name TEXT NOT NULL, name_message_key TEXT, window_start_month INTEGER NOT NULL DEFAULT 1 CHECK (window_start_month >= 1 AND window_start_month <= 12), window_start_day INTEGER NOT NULL DEFAULT 1 CHECK (window_start_day >= 1 AND window_start_day <= 31), window_end_month INTEGER NOT NULL DEFAULT 1 CHECK (window_end_month >= 1 AND window_end_month <= 12), window_end_day INTEGER NOT NULL DEFAULT 1 CHECK (window_end_day >= 1 AND window_end_day <= 31), target_weeks INTEGER NOT NULL, target_days INTEGER, first_year INTEGER NOT NULL DEFAULT 2026, last_year INTEGER, priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1), parent_mode TEXT NOT NULL DEFAULT 'either' CHECK (parent_mode IN ('either','both','specific')), parent_personal_data_uuid TEXT, window_mode TEXT NOT NULL DEFAULT 'fixed' CHECK (window_mode IN ('fixed','flexible')), series_id TEXT, wants_dubbeldag INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); INSERT INTO leave_wish_new ( id, workspace_id, child_id, name, name_message_key, window_start_month, window_start_day, window_end_month, window_end_day, target_weeks, target_days, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, window_mode, series_id, wants_dubbeldag, created_at, updated_at ) SELECT id, workspace_id, child_id, name, name_message_key, window_start_month, window_start_day, window_end_month, window_end_day, target_weeks, target_days, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, window_mode, NULL, wants_dubbeldag, created_at, updated_at FROM leave_wish; DROP TABLE leave_wish; ALTER TABLE leave_wish_new RENAME TO leave_wish; CREATE INDEX IF NOT EXISTS idx_leave_wish_workspace ON leave_wish (workspace_id); CREATE INDEX IF NOT EXISTS idx_leave_wish_child ON leave_wish (child_id); CREATE INDEX IF NOT EXISTS idx_leave_wish_parent ON leave_wish (parent_personal_data_uuid); CREATE INDEX IF NOT EXISTS idx_leave_wish_series ON leave_wish (series_id); """ _DOWNGRADE_DDL = """ CREATE TABLE leave_wish_old ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, child_id TEXT, name TEXT NOT NULL, name_message_key TEXT, window_start_month INTEGER NOT NULL DEFAULT 1 CHECK (window_start_month >= 1 AND window_start_month <= 12), window_start_day INTEGER NOT NULL DEFAULT 1 CHECK (window_start_day >= 1 AND window_start_day <= 31), window_end_month INTEGER NOT NULL DEFAULT 1 CHECK (window_end_month >= 1 AND window_end_month <= 12), window_end_day INTEGER NOT NULL DEFAULT 1 CHECK (window_end_day >= 1 AND window_end_day <= 31), target_weeks INTEGER NOT NULL, target_days INTEGER, recurrence TEXT NOT NULL DEFAULT 'once' CHECK (recurrence IN ('yearly','once')), first_year INTEGER NOT NULL DEFAULT 2026, last_year INTEGER, priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1), parent_mode TEXT NOT NULL DEFAULT 'either' CHECK (parent_mode IN ('either','both','specific')), parent_personal_data_uuid TEXT, window_mode TEXT NOT NULL DEFAULT 'fixed' CHECK (window_mode IN ('fixed','flexible')), wants_dubbeldag INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); INSERT INTO leave_wish_old ( id, workspace_id, child_id, name, name_message_key, window_start_month, window_start_day, window_end_month, window_end_day, target_weeks, target_days, recurrence, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, window_mode, wants_dubbeldag, created_at, updated_at ) SELECT id, workspace_id, child_id, name, name_message_key, window_start_month, window_start_day, window_end_month, window_end_day, target_weeks, target_days, CASE WHEN series_id IS NULL THEN 'once' ELSE 'yearly' END, first_year, last_year, priority, parent_mode, parent_personal_data_uuid, window_mode, wants_dubbeldag, created_at, updated_at FROM leave_wish; DROP TABLE leave_wish; ALTER TABLE leave_wish_old RENAME TO leave_wish; CREATE INDEX IF NOT EXISTS idx_leave_wish_workspace ON leave_wish (workspace_id); CREATE INDEX IF NOT EXISTS idx_leave_wish_child ON leave_wish (child_id); CREATE INDEX IF NOT EXISTS idx_leave_wish_parent ON leave_wish (parent_personal_data_uuid); """ def _execute_script(sql: str) -> None: from sqlalchemy import text for stmt in [s.strip() for s in sql.split(";") if s.strip()]: op.execute(text(stmt)) # --- verbatim copy of the pre-004 window projection ------------------------- # Kept local on purpose: the backfill must reproduce what `window_for_year()` # produced on 2026-09-19, not whatever `app.entities.leave_wish` does later. def _clamped(year: int, month: int, day: int) -> dt.date: return dt.date(year, month, min(day, calendar.monthrange(year, month)[1])) def _shift_to_weekday(naive: dt.date, target_weekday: int) -> dt.date: forward = (target_weekday - naive.weekday()) % 7 shift = min((forward, forward - 7), key=lambda d: (abs(d), d)) return naive + dt.timedelta(days=shift) def _project(row: Any, year: int) -> tuple[dt.date, dt.date]: sm, sd = row.window_start_month, row.window_start_day em, ed = row.window_end_month, row.window_end_day wraps = (em, ed) < (sm, sd) naive_start = _clamped(year, sm, sd) naive_end = _clamped(year + 1 if wraps else year, em, ed) if row.window_mode != "fixed" or year == row.first_year: return naive_start, naive_end anchor_start = _clamped(row.first_year, sm, sd) anchor_end = _clamped(row.first_year + 1 if wraps else row.first_year, em, ed) start = _shift_to_weekday(naive_start, anchor_start.weekday()) return start, start + (anchor_end - anchor_start) def _series_end_year(row: Any, birth_years: dict[str, int], today_year: int) -> int: if row.last_year is not None: end = int(row.last_year) elif row.child_id is not None and row.child_id in birth_years: end = birth_years[row.child_id] + 4 else: end = today_year + _CHILDLESS_YEARS_AHEAD end = min(end, _MAX_MATERIALISED_YEAR, row.first_year + _MAX_SERIES_LENGTH_YEARS) return max(end, int(row.first_year)) def upgrade() -> None: from sqlalchemy import text conn = op.get_bind() yearly = conn.execute( text( "SELECT id, workspace_id, child_id, name, name_message_key, " "window_start_month, window_start_day, window_end_month, window_end_day, " "target_weeks, target_days, first_year, last_year, priority, parent_mode, " "parent_personal_data_uuid, window_mode, wants_dubbeldag, created_at, updated_at " "FROM leave_wish WHERE recurrence = 'yearly'" ) ).fetchall() birth_years: dict[str, int] = {} for child_id, birth_date in conn.execute(text("SELECT id, birth_date FROM child")).fetchall(): if birth_date is None: continue birth_years[str(child_id)] = int(str(birth_date)[:4]) today_year = dt.date.today().year plan: list[dict[str, Any]] = [] for row in yearly: series_id = str(uuid.uuid4()) end_year = _series_end_year(row, birth_years, today_year) for year in range(int(row.first_year), end_year + 1): start, end = _project(row, year) plan.append( { "id": str(row.id) if year == int(row.first_year) else str(uuid.uuid4()), "is_anchor": year == int(row.first_year), "source_id": str(row.id), "series_id": series_id, "first_year": year, "window_start_month": start.month, "window_start_day": start.day, "window_end_month": end.month, "window_end_day": end.day, } ) _execute_script(_UPGRADE_DDL) for item in plan: if item["is_anchor"]: conn.execute( text( "UPDATE leave_wish SET series_id = :series_id, " "window_start_month = :wsm, window_start_day = :wsd, " "window_end_month = :wem, window_end_day = :wed " "WHERE id = :id" ), { "series_id": item["series_id"], "wsm": item["window_start_month"], "wsd": item["window_start_day"], "wem": item["window_end_month"], "wed": item["window_end_day"], "id": item["id"], }, ) continue conn.execute( text( "INSERT INTO leave_wish (" " id, workspace_id, child_id, name, name_message_key," " window_start_month, window_start_day, window_end_month, window_end_day," " target_weeks, target_days, first_year, last_year," " priority, parent_mode, parent_personal_data_uuid," " window_mode, series_id, wants_dubbeldag, created_at, updated_at)" " SELECT :id, workspace_id, child_id, name, name_message_key," " :wsm, :wsd, :wem, :wed," " target_weeks, target_days, :first_year, last_year," " priority, parent_mode, parent_personal_data_uuid," " window_mode, :series_id, wants_dubbeldag, created_at, updated_at" " FROM leave_wish WHERE id = :source_id" ), { "id": item["id"], "wsm": item["window_start_month"], "wsd": item["window_start_day"], "wem": item["window_end_month"], "wed": item["window_end_day"], "first_year": item["first_year"], "series_id": item["series_id"], "source_id": item["source_id"], }, ) def downgrade() -> None: from sqlalchemy import text conn = op.get_bind() # Collapse each series back to its earliest occurrence, which is the row # the pre-004 shape would have stored (`window_for_year(first_year)` is # unshifted). The later occurrences' individual edits cannot survive a # shape that has nowhere to keep them. conn.execute( text( "DELETE FROM leave_wish WHERE series_id IS NOT NULL AND first_year > (" " SELECT MIN(inner_lw.first_year) FROM leave_wish AS inner_lw" " WHERE inner_lw.series_id = leave_wish.series_id)" ) ) _execute_script(_DOWNGRADE_DDL)