Store and serve a maintenance date as a date

The machine maintenance panel showed every date one calendar day early on this
site, and would show it a day late east of UTC.

lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
midnight, and BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and the browser
converted it into local time. There is no time in a maintenance date: the form
offers a date picker, the CSV importer takes '2026-06-01', and the value is only
ever compared against other dates. Every sibling already models it correctly -
measuringtools.lastcalibrationdate, warranties.startdate and enddate are all
db.Date - so machines were the odd one out.

Migration 7d40 converts both columns; MySQL truncates a midnight time so no
data moves, and SQLite neither enforces nor needs it.

That exposed two more things. The route was assigning the raw request string to
the column, which a DATE column rejects outright - it now goes through the
strict parser from wave 2, so a mistyped maintenance date is refused with a 400
rather than stored or nulled. And BaseModel.to_dict had no branch for a plain
date, so one reached Flask's encoder and came out as an HTTP-date
("Thu, 15 Jan 2026 00:00:00 GMT") - which a browser parses back into a moment
and can still render as the day before. Dates now serialise as "2026-01-15",
with datetime checked first because it is a subclass of date.

Verified against MySQL on a live instance: the column is DATE, the API serves
2026-01-15, a mistyped date is refused with 400, and a rename on a machine that
has maintenance dates saves - the 500 from the audit-log fix in 5d81f50c, now
confirmed end to end rather than only in tests.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 15:51:22 -04:00
parent fb63ef9d30
commit f6e5f6ca47
5 changed files with 152 additions and 7 deletions

View File

@@ -0,0 +1,73 @@
"""Store a maintenance date as a DATE, because that is what it is.
lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
midnight. BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and a browser west of
UTC rendered it as 14 December. The maintenance panel showed every date one
calendar day early on this site, and would show it a day LATE east of UTC.
There is no time in a maintenance date. Nobody records that a machine was
serviced at midnight; the form offers a date picker, the CSV importer takes
'2026-06-01', and the value is compared against other dates. Every sibling
already models it correctly - measuringtools.lastcalibrationdate,
warranties.startdate and enddate are all db.Date - so this is machines being
the odd one out rather than a new decision.
As a Date, to_dict emits "2026-12-15" with no timezone marker at all, and
nothing can shift it.
MySQL truncates the (midnight) time component on the ALTER, so no data moves.
SQLite stores what it is given and does not enforce column types, so the
conversion is a no-op there and is skipped.
Revision ID: 7d40_machine_maintenance_dates
Revises: 7d39_audit_retention_key_merge
"""
from alembic import op
import sqlalchemy as sa
revision = '7d40_machine_maintenance_dates'
down_revision = '7d39_audit_retention_key_merge'
branch_labels = None
depends_on = None
COLUMNS = ('lastmaintenancedate', 'nextmaintenancedate')
def _machines_table(bind):
inspector = sa.inspect(bind)
if 'machines' not in inspector.get_table_names():
return None
return {column['name'] for column in inspector.get_columns('machines')}
def upgrade():
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return
present = _machines_table(bind)
if not present:
return
for column in COLUMNS:
if column in present:
op.alter_column('machines', column,
existing_type=sa.DateTime(),
type_=sa.Date(),
existing_nullable=True)
def downgrade():
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return
present = _machines_table(bind)
if not present:
return
for column in COLUMNS:
if column in present:
op.alter_column('machines', column,
existing_type=sa.Date(),
type_=sa.DateTime(),
existing_nullable=True)