Aug 4 - Update code to follow up - MT11

This commit is contained in:
2026-08-04 13:00:21 -04:00
parent ceb0b806af
commit 3c2489e289
10 changed files with 1468 additions and 49 deletions
@@ -0,0 +1,115 @@
"""phase45 — widen inspection_schedules.frequency ENUM
Converges MT's cadence set onto the agreed union:
once | daily | weekly | monthly | quarterly | bi-annually | annually
'bi-annually' means every 6 months (not twice a year).
Why now
-------
phase46 (recurrence) and phase47 (end date) both branch on 'once':
* a one-time schedule deactivates on completion instead of rolling forward;
* a one-time schedule may not carry an end date — it ends by being completed.
Adding the value later would mean a second ENUM rewrite of the same table.
'bi-annually' and 'annually' are added in the same pass for the same reason.
The original four values are retained in their original order, so NO existing
row changes and no data migration is needed. This is purely a widening.
MySQL ENUM changes
------------------
`MODIFY COLUMN` on an ENUM cannot be done in place — MySQL rebuilds the table.
`ALGORITHM=COPY, LOCK=SHARED` is stated explicitly rather than left to the
server's default so the behaviour is predictable across versions: the table is
readable throughout and blocks writes for the duration of the copy. It also
takes a metadata lock, which will WAIT behind any long-running transaction
touching inspection_schedules — see the deploy notes. The table is small (one
row per schedule), so the copy itself is fast.
No batch_alter_table (MySQL).
RE-RUNNABLE. An existence check is not enough here — the column already exists;
what changes is its type. This inspects COLUMN_TYPE and skips when the target
values are already present, so a re-run against a fully-migrated schema is a
genuine no-op rather than a needless table rebuild.
"""
revision = 'phase45_schedule_frequency_enum'
down_revision = 'phase44_internal_handler'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMN = 'frequency'
_NEW_ENUM = ("ENUM('once','daily','weekly','monthly','quarterly',"
"'bi-annually','annually')")
_OLD_ENUM = "ENUM('daily','weekly','monthly','quarterly')"
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_type(conn, table, column):
"""Lowercased COLUMN_TYPE, or None when the column does not exist."""
row = conn.execute(sa.text(
"SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).first()
if not row:
return None
value = row[0]
if isinstance(value, bytes):
value = value.decode('utf-8', 'replace')
return value.lower()
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
col_type = _column_type(bind, _TABLE, _COLUMN)
if col_type is None:
return
# Already widened? Every new value present means there is nothing to do.
if all(v in col_type for v in ("'once'", "'bi-annually'", "'annually'")):
return
op.execute(sa.text(
f"ALTER TABLE {_TABLE} MODIFY COLUMN {_COLUMN} {_NEW_ENUM} "
f"NOT NULL DEFAULT 'weekly', ALGORITHM=COPY, LOCK=SHARED"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
col_type = _column_type(bind, _TABLE, _COLUMN)
if col_type is None or "'once'" not in col_type:
return
# Rows holding a value the narrow ENUM cannot represent would be silently
# truncated to '' by the MODIFY. Map them to the nearest surviving cadence
# first so the downgrade is lossy in a defined, inspectable way rather than
# producing invalid empty-string rows.
op.execute(sa.text(
f"UPDATE {_TABLE} SET {_COLUMN} = 'monthly' "
f"WHERE {_COLUMN} IN ('once','bi-annually','annually')"
))
op.execute(sa.text(
f"ALTER TABLE {_TABLE} MODIFY COLUMN {_COLUMN} {_OLD_ENUM} "
f"NOT NULL DEFAULT 'weekly', ALGORITHM=COPY, LOCK=SHARED"
))
@@ -0,0 +1,84 @@
"""phase46 — inspection schedule day-of-week / day-of-month recurrence
Ports single-tenant phase43 onto MT's `inspection_schedules` table (MT's name
for the same thing — no rename, rule 7). Adds the recurrence-detail columns so a
weekly schedule can name the weekdays it runs on (Mon/Wed/Fri) and a monthly (or
quarterly / bi-annual / annual) schedule can name either a day of the month
("the 15th") or an nth weekday ("the 2nd Tuesday"):
weekdays VARCHAR(20) -- CSV of Python weekday ints, Mon=0, e.g. '0,2,4'
month_mode VARCHAR(20) -- 'day_of_month' | 'nth_weekday'
day_of_month SMALLINT -- 131, clamped to the month's last day
nth_week SMALLINT -- 15, or -1 for "last"
nth_weekday SMALLINT -- 06, Mon=0
All nullable with no backfill: existing phase34/phase43 rows keep NULLs and fall
back to the plain-interval behaviour in InspectionSchedule._add_interval(), so
no schedule changes cadence on deploy.
`next_run_at` is NOT touched — it keeps its name, its DATETIME type and its
index. It remains the due datetime (ST's `next_due_date` by another name); the
recurrence maths runs on its DATE part and preserves its TIME part.
`month_mode` is VARCHAR rather than ENUM so adding a recurrence style later
needs no 3-step MySQL ENUM migration.
Uses INFORMATION_SCHEMA column-existence checks — safe to re-run on every
tenant DB. Additive only: nothing is renamed, retyped or dropped.
"""
revision = 'phase46_schedule_recurrence'
down_revision = 'phase45_schedule_frequency_enum'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
_COLUMNS = (
('weekdays', 'VARCHAR(20) NULL'),
('month_mode', 'VARCHAR(20) NULL'),
('day_of_month', 'SMALLINT NULL'),
('nth_week', 'SMALLINT NULL'),
('nth_weekday', 'SMALLINT NULL'),
)
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
for name, ddl in _COLUMNS:
if not _column_exists(bind, _TABLE, name):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN {name} {ddl}"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
for name, _ddl in reversed(_COLUMNS):
if _column_exists(bind, _TABLE, name):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} DROP COLUMN {name}"
))
@@ -0,0 +1,73 @@
"""phase47 — inspection schedule end date
Ports single-tenant phase44 onto MT's `inspection_schedules` table. Adds:
end_date DATE NULL -- last date this schedule may produce an occurrence
Separates the two ideas that `next_run_at` was carrying at once. `next_run_at`
is *mutable state* — InspectionSchedule.advance_due_date() rewrites it after
every occurrence, whether the inspector completed a plan-mode schedule or the
cron materialised an auto-mode one — whereas `end_date` is a *fixed boundary*
set by the manager and never touched by the app. NULL means "repeat
indefinitely", which is the behaviour every existing row has today, so there is
no backfill and no schedule changes cadence on deploy.
Applies to BOTH modes. Scoping it to plan mode would leave an auto schedule
materialising inspections past its boundary forever, which is the exact failure
the column exists to prevent.
Only meaningful for recurring schedules; the create/edit routes force it to NULL
when frequency == 'once' (a one-time schedule already ends by deactivating on
completion) and reject an end date submitted against one.
Uses an INFORMATION_SCHEMA column-existence check — safe to re-run on every
tenant DB. Additive only: no existing column is renamed, retyped or dropped. In
particular `next_run_at` keeps its name and its index — it is the API payload
key (`next_due_date`) the iPad decodes and drives the cron.
"""
revision = 'phase47_schedule_end_date'
down_revision = 'phase46_schedule_recurrence'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
_TABLE = 'inspection_schedules'
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def _column_exists(conn, table, column):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column}).scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if not _column_exists(bind, _TABLE, 'end_date'):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} ADD COLUMN end_date DATE NULL AFTER next_run_at"
))
def downgrade():
bind = op.get_bind()
if not _table_exists(bind, _TABLE):
return
if _column_exists(bind, _TABLE, 'end_date'):
op.execute(sa.text(
f"ALTER TABLE {_TABLE} DROP COLUMN end_date"
))