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

@@ -238,3 +238,39 @@ def test_the_status_breakdown_counts_both_bays_when_collapse_is_off(
headers=auth_headers).get_json()['data']
assert summary['total'] == 2
assert sum(row['count'] for row in summary['bystatus']) == 2
def test_a_maintenance_date_is_served_as_a_plain_date(client, db, auth_headers):
"""It was a DATETIME holding midnight, serialised as '...T00:00:00Z', so a
browser west of UTC rendered every maintenance date one day early. A date
has no time and no timezone."""
from plugins.machines.models import Machine
# The create route resolves the 'machine' AssetType, which this module's
# fixture only seeds as part of building a pair.
if not AssetType.query.filter_by(assettype='machine').first():
_db.session.add(AssetType(assettype='machine'))
_db.session.commit()
created = client.post('/api/machines', headers=auth_headers, json={
'assetnumber': 'MAINT-DATE-1',
'name': 'Maintenance date probe',
'lastmaintenancedate': '2026-01-15',
'nextmaintenancedate': '2026-12-15',
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
machineid = payload.get('machineid') or payload['machine']['machineid']
fetched = client.get('/api/machines/%d' % machineid,
headers=auth_headers).get_json()['data']
extension = fetched['machine']
assert extension['lastmaintenancedate'] == '2026-01-15'
assert extension['nextmaintenancedate'] == '2026-12-15'
for value in (extension['lastmaintenancedate'],
extension['nextmaintenancedate']):
assert 'T' not in value and 'Z' not in value, \
'a date must not carry a time or a timezone marker'
stored = db.session.get(Machine, machineid)
assert str(stored.lastmaintenancedate) == '2026-01-15'