Aug 4 - Update code to follow up - MT11
This commit is contained in:
@@ -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 -- 1–31, clamped to the month's last day
|
||||
nth_week SMALLINT -- 1–5, or -1 for "last"
|
||||
nth_weekday SMALLINT -- 0–6, 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"
|
||||
))
|
||||
Reference in New Issue
Block a user