"""Realign already-materialised flexible series onto their anchor's weekday. `project_window` (`app/entities/leave_wish.py`) used to skip the weekday snap for `window_mode == 'flexible'` rows — every year of a flexible series stored the identical naive month/day bound, so a week-shaped bound (e.g. "the week of Oct 5") silently drifted onto a different weekday every year it wasn't the anchor. That bug is fixed at the source (`project_window` now snaps a flexible bound's start to the anchor year's start weekday too, holding the bound's day-span fixed, exactly like `window_mode == 'fixed'` already did), but `project_window` only ever runs ONCE, at materialisation time (`LeaveWishService`) — nothing re-derives it at read time. Every flexible series row materialised before this fix therefore stays unsnapped forever unless something rewrites it once, here. This migration: 1. finds every `leave_wish` row with `window_mode = 'flexible'` and a non-null `series_id`; 2. groups them by `series_id`, and for each series treats the row with the lowest `first_year` as the anchor occurrence (unshifted by definition — `project_window` never shifts `year == anchor_year`), the same convention 004's `downgrade()` uses to collapse a series back to one row; 3. for every OTHER row in that series, re-projects that year's bound from the anchor's own stored month/day fields (which, being the anchor, are already the naive/unshifted pattern) using the anchor's start weekday, and rewrites that row's four month/day columns in place. Fixed-mode series are left untouched — `project_window` already snapped them correctly the day they were materialised, and re-running the (identical) projection on them would be a costly no-op at best. No schema change: this is a pure data rewrite. The projection helpers below are a **self-contained copy**, not an import of `app.entities.leave_wish`, for the same reason 004 gives at its line 196 — a migration must keep describing the behaviour it was written against, not whatever the entity module does later. `downgrade()` restores the naive (unsnapped) anchor month/day for every non-anchor flexible row — i.e. exactly what this migration found — so the round trip is lossless for anchor rows (never touched) and reverses cleanly for every row it did touch. Revision ID: 008 Revises: 007 Create Date: 2026-09-20 """ from __future__ import annotations import calendar import datetime as dt from typing import Any from alembic import op revision: str = "008" down_revision: str | None = "007" branch_labels: str | None = None depends_on: str | None = None 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( sm: int, sd: int, em: int, ed: int, anchor_year: int, year: int, *, snap: bool ) -> tuple[dt.date, dt.date]: """Verbatim copy of `project_window`'s post-fix (`snap=True`) and pre-fix (`snap=False`) behaviour, parametrised on the anchor row's own (start_month, start_day, end_month, end_day) pattern rather than a `LeaveWish` instance. """ wraps = (em, ed) < (sm, sd) naive_start = _clamped(year, sm, sd) naive_end = _clamped(year + 1 if wraps else year, em, ed) if not snap or year == anchor_year: return naive_start, naive_end anchor_start = _clamped(anchor_year, sm, sd) anchor_end = _clamped(anchor_year + 1 if wraps else anchor_year, em, ed) span = anchor_end - anchor_start start = _shift_to_weekday(naive_start, anchor_start.weekday()) return start, start + span def _flexible_series_rows(conn: Any) -> dict[str, list[Any]]: from sqlalchemy import text rows = conn.execute( text( "SELECT id, series_id, first_year, " "window_start_month, window_start_day, window_end_month, window_end_day " "FROM leave_wish " "WHERE window_mode = 'flexible' AND series_id IS NOT NULL " "ORDER BY series_id, first_year" ) ).fetchall() by_series: dict[str, list[Any]] = {} for row in rows: by_series.setdefault(str(row.series_id), []).append(row) return by_series def _rewrite(conn: Any, *, snap: bool) -> None: from sqlalchemy import text for series_rows in _flexible_series_rows(conn).values(): anchor = min(series_rows, key=lambda r: int(r.first_year)) sm, sd = anchor.window_start_month, anchor.window_start_day em, ed = anchor.window_end_month, anchor.window_end_day anchor_year = int(anchor.first_year) for row in series_rows: year = int(row.first_year) if year == anchor_year: continue start, end = _project(sm, sd, em, ed, anchor_year, year, snap=snap) conn.execute( text( "UPDATE leave_wish SET " "window_start_month = :wsm, window_start_day = :wsd, " "window_end_month = :wem, window_end_day = :wed " "WHERE id = :id" ), { "wsm": start.month, "wsd": start.day, "wem": end.month, "wed": end.day, "id": row.id, }, ) def upgrade() -> None: _rewrite(op.get_bind(), snap=True) def downgrade() -> None: _rewrite(op.get_bind(), snap=False)