diff --git a/migrations/versions/7d40_machine_maintenance_dates_are_dates.py b/migrations/versions/7d40_machine_maintenance_dates_are_dates.py new file mode 100644 index 00000000..74b48014 --- /dev/null +++ b/migrations/versions/7d40_machine_maintenance_dates_are_dates.py @@ -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) diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index 1d1dcf7e..72a5e031 100644 --- a/plugins/machines/api/routes.py +++ b/plugins/machines/api/routes.py @@ -7,7 +7,8 @@ from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response, from ..models import Machine, MachineType -from shopdb.api import require_permission, apply_import_timestamps, intarg +from shopdb.api import (require_permission, apply_import_timestamps, intarg, + DateValueError, parse_date_fields) machines_bp = Blueprint('machines', __name__) @@ -336,6 +337,14 @@ def create_machine(): - mapx, mapy, notes """ data = request.get_json() + # Refused rather than stored raw or nulled: these are db.Date columns + # (migration 7d40), so a string would be rejected by the engine and a + # mistyped one used to become NULL further down the stack. + try: + maintenancedates = parse_date_fields( + data, ('lastmaintenancedate', 'nextmaintenancedate')) + except DateValueError as bad: + return error_response(ErrorCodes.VALIDATION_ERROR, str(bad)) if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') @@ -388,8 +397,8 @@ def create_machine(): modelnumberid=data.get('modelnumberid'), requiresmanualconfig=data.get('requiresmanualconfig', False), islocationonly=data.get('islocationonly', False), - lastmaintenancedate=data.get('lastmaintenancedate'), - nextmaintenancedate=data.get('nextmaintenancedate'), + lastmaintenancedate=maintenancedates.get('lastmaintenancedate'), + nextmaintenancedate=maintenancedates.get('nextmaintenancedate'), maintenanceintervaldays=data.get('maintenanceintervaldays'), controllervendorid=data.get('controllervendorid'), controllermodelid=data.get('controllermodelid') @@ -427,6 +436,14 @@ def update_machine(machine_id: int): ) data = request.get_json() + # Refused rather than stored raw or nulled: these are db.Date columns + # (migration 7d40), so a string would be rejected by the engine and a + # mistyped one used to become NULL further down the stack. + try: + maintenancedates = parse_date_fields( + data, ('lastmaintenancedate', 'nextmaintenancedate')) + except DateValueError as bad: + return error_response(ErrorCodes.VALIDATION_ERROR, str(bad)) if not data: return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') @@ -471,7 +488,7 @@ def update_machine(machine_id: int): # Update machine fields machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid', 'requiresmanualconfig', 'islocationonly', - 'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays', + 'maintenanceintervaldays', 'controllervendorid', 'controllermodelid'] for key in machine_fields: if key in data: @@ -481,6 +498,14 @@ def update_machine(machine_id: int): changes[key] = {'old': old_val, 'new': new_val} setattr(mach, key, data[key]) + # The maintenance dates are real dates now (migration 7d40), so they are + # parsed rather than assigned as whatever string arrived - and a mistyped + # one is refused instead of stored or nulled, like every other date. + for key, parsed in maintenancedates.items(): + if getattr(mach, key) != parsed: + changes[key] = {'old': getattr(mach, key), 'new': parsed} + setattr(mach, key, parsed) + # Audit log if there were changes if changes: AuditLog.log('updated', 'Machine', entityid=mach.machineid, diff --git a/plugins/machines/models/machine.py b/plugins/machines/models/machine.py index 507c374d..320b242c 100644 --- a/plugins/machines/models/machine.py +++ b/plugins/machines/models/machine.py @@ -73,8 +73,12 @@ class Machine(BaseModel): ) # Maintenance tracking - lastmaintenancedate = db.Column(db.DateTime, nullable=True) - nextmaintenancedate = db.Column(db.DateTime, nullable=True) + # Dates, not datetimes: there is no time in a maintenance date, and a + # DATETIME went out of to_dict as "...T00:00:00Z", which a browser west of + # UTC rendered as the previous day. measuringtools and warranties already + # model theirs as db.Date. See migration 7d40. + lastmaintenancedate = db.Column(db.Date, nullable=True) + nextmaintenancedate = db.Column(db.Date, nullable=True) maintenanceintervaldays = db.Column(db.Integer, nullable=True) # Controller info (for CNC machines) diff --git a/shopdb/core/models/base.py b/shopdb/core/models/base.py index 18e577b3..2d3f4bd4 100644 --- a/shopdb/core/models/base.py +++ b/shopdb/core/models/base.py @@ -1,6 +1,6 @@ """Base model class with common fields.""" -from datetime import datetime, timezone +from datetime import date, datetime, timezone from shopdb.extensions import db @@ -63,6 +63,13 @@ class BaseModel(db.Model): value = getattr(self, c.name) if isinstance(value, datetime): value = value.isoformat() + 'Z' + elif isinstance(value, date): + # A date, not an instant: no time and no timezone marker. Left + # to Flask's encoder it became an HTTP-date + # ("Thu, 15 Jan 2026 00:00:00 GMT"), which a browser parses + # back into a moment and can render as the day before. + # datetime is a subclass of date, so this must stay second. + value = value.isoformat() result[c.name] = value return result diff --git a/tests/test_plugins/test_dualpath_single.py b/tests/test_plugins/test_dualpath_single.py index 3c95f2eb..f0c2914a 100644 --- a/tests/test_plugins/test_dualpath_single.py +++ b/tests/test_plugins/test_dualpath_single.py @@ -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'