Files
shopdb-flask/plugins/machines/api/routes.py
cproudlock f6e5f6ca47 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
2026-09-03 15:51:22 -04:00

634 lines
23 KiB
Python

"""Machines plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
from ..models import Machine, MachineType
from shopdb.api import (require_permission, apply_import_timestamps, intarg,
DateValueError, parse_date_fields)
machines_bp = Blueprint('machines', __name__)
# =============================================================================
# Machine Types
# =============================================================================
@machines_bp.route('/types', methods=['GET'])
@require_permission('machines.view')
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(MachineType.isactive == True)
if search := request.args.get('search'):
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
@require_permission('machines.view')
def get_machine_type(type_id: int):
"""Get a single machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@machines_bp.route('/types', methods=['POST'])
@require_permission('machines.create')
def create_machine_type():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
existing = MachineType.query.filter_by(machinetype=data['machinetype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
t = MachineType(
machinetype=data['machinetype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Machine type created', http_code=201)
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
@require_permission('machines.edit')
def update_machine_type(type_id: int):
"""Update a machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'machinetype' in data and data['machinetype'] != t.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['machinetype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Machine type updated')
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
@require_permission('machines.delete')
def delete_machine_type(type_id: int):
"""Delete a machine type. Refused if any asset still uses it."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404)
inuse = Machine.query.filter_by(machinetypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Machine type deleted')
# =============================================================================
# Machine CRUD
# =============================================================================
@machines_bp.route('', methods=['GET'])
@require_permission('machines.view')
def list_machines():
"""
List all machines with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number or name
- type_id: Filter by machine type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
"""
page, per_page = get_pagination_params(request)
# Join Machine with Asset
query = db.session.query(Machine).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter. Covers what the list actually SHOWS - the asset fields plus
# the type and vendor names, which are columns in the table. Searching a type
# like 'Part Washer' used to return nothing. Outer joins so a machine with no
# type or vendor still matches on its own fields.
if search := request.args.get('search'):
pattern = f'%{search}%'
query = query.outerjoin(
MachineType, Machine.machinetypeid == MachineType.machinetypeid
).outerjoin(
Vendor, Machine.vendorid == Vendor.vendorid
).filter(
db.or_(
Asset.assetnumber.ilike(pattern),
Asset.name.ilike(pattern),
Asset.serialnumber.ilike(pattern),
# The optional identifiers too (ADR-001). Global search matches
# these, and a tag read off the machine has to find it here as
# well - this box is where someone holding the label looks.
Asset.gaugelabreference.ilike(pattern),
Asset.maintenancereference.ilike(pattern),
MachineType.machinetype.ilike(pattern),
Vendor.vendor.ilike(pattern)
)
)
# Machine type filter
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Machine.machinetypeid == type_id)
# Vendor filter
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(Machine.vendorid == vendor_id)
# Location filter
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
else:
col = Asset.assetnumber
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
# Dualpath single-machine collapse: hide the secondary bay so a dual-bay
# pair lists (and paginates) as one machine. Excluding before pagination
# keeps totals honest. Gated on the site setting (default on).
collapse = None
if dualpath_single_machine_enabled():
collapse = resolve_dualpath_pairs()
if collapse.secondaryassetids:
query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids))
items, total = paginate_query(query, page, per_page)
# Resolve partner machineids for this page's primaries in one query.
machineidbyasset = {}
if collapse:
partnerassetids = [
collapse.partnerbyasset[m.assetid]['assetid']
for m in items if m.assetid in collapse.partnerbyasset
]
if partnerassetids:
for partner in Machine.query.filter(
Machine.assetid.in_(partnerassetids)).all():
machineidbyasset[partner.assetid] = partner.machineid
# Build response with both asset and machine data
data = []
for mach in items:
item = mach.asset.to_dict() if mach.asset else {}
item['machine'] = mach.to_dict()
# annotate the visible bay with its hidden partner (for '2007 / 2008')
partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None
item['dualpathpartner'] = {
'assetid': partner['assetid'],
'machineid': machineidbyasset.get(partner['assetid']),
'assetnumber': partner['assetnumber'],
} if partner else None
data.append(item)
return paginated_response(data, page, per_page, total)
def _dualpath_partner_for(assetid):
"""Resolve this machine's Dualpath sibling, or None. Always evaluated
(independent of the collapse toggle) so the detail-page banner shows even
when a site lists both bays. Returns {assetid, machineid, assetnumber}."""
partner = resolve_dualpath_pairs().partnerbyasset.get(assetid)
if not partner:
return None
partnermach = Machine.query.filter_by(assetid=partner['assetid']).first()
return {
'assetid': partner['assetid'],
'machineid': partnermach.machineid if partnermach else None,
'assetnumber': partner['assetnumber'],
}
@machines_bp.route('/<int:machine_id>', methods=['GET'])
@require_permission('machines.view')
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine with ID {machine_id} not found',
http_code=404
)
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
return success_response(result)
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@require_permission('machines.view')
def get_machine_by_asset(asset_id: int):
"""Get machine data by asset ID."""
mach = Machine.query.filter_by(assetid=asset_id).first()
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine for asset {asset_id} not found',
http_code=404
)
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
return success_response(result)
@machines_bp.route('', methods=['POST'])
@require_permission('machines.create')
def create_machine():
"""
Create new machine (creates both Asset and Machine records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- machinetypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly
- 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')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Get machine asset type
machine_type = AssetType.query.filter_by(assettype='machine').first()
if not machine_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Machine asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
serialnumber=data.get('serialnumber'),
assettypeid=machine_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the machine extension
mach = Machine(
assetid=asset.assetid,
machinetypeid=data.get('machinetypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
islocationonly=data.get('islocationonly', False),
lastmaintenancedate=maintenancedates.get('lastmaintenancedate'),
nextmaintenancedate=maintenancedates.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'),
controllervendorid=data.get('controllervendorid'),
controllermodelid=data.get('controllermodelid')
)
db.session.add(mach)
db.session.flush()
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
# Audit log
AuditLog.log('created', 'Machine', entityid=mach.machineid,
entityname=data['assetnumber'])
db.session.commit()
result = asset.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Machine created', http_code=201)
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
@require_permission('machines.edit')
def update_machine(machine_id: int):
"""Update machine (both Asset and Machine records)."""
mach = db.session.get(Machine, machine_id)
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine with ID {machine_id} not found',
http_code=404
)
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')
asset = mach.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Track changes for audit log
changes = {}
# Update asset fields
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
'maintenancereference', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
'notes', 'isactive']
for key in asset_fields:
if key in data:
old_val = getattr(asset, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update machine fields
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in machine_fields:
if key in data:
old_val = getattr(mach, key)
new_val = data[key]
if old_val != new_val:
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,
entityname=asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Machine updated')
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
@require_permission('machines.delete')
def delete_machine(machine_id: int):
"""Delete (soft delete) machine."""
mach = db.session.get(Machine, machine_id)
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine with ID {machine_id} not found',
http_code=404
)
# Soft delete the asset (machine extension will stay linked)
mach.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Machine', entityid=mach.machineid,
entityname=mach.asset.assetnumber)
db.session.commit()
return success_response(message='Machine deleted')
# =============================================================================
# Dashboard
# =============================================================================
@machines_bp.route('/dashboard/summary', methods=['GET'])
@require_permission('machines.view')
def dashboard_summary():
"""Get machine dashboard summary data."""
# Dualpath single-machine collapse: hide the secondary bay from counts so a
# dual-bay pair counts once. Gated on the site setting (default on).
secondaryassetids = set()
if dualpath_single_machine_enabled():
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
# Total active machine count
total_query = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
)
if secondaryassetids:
total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids))
total = total_query.count()
# Count by machine type
by_type_query = db.session.query(
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True)
if secondaryassetids:
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
by_type = by_type_query.group_by(MachineType.machinetype).all()
# Count by status. The dualpath exclusion applies here too: it was applied
# to total and bytype and NOT to this one, so the status bars summed to
# more than the total they sit under - by exactly the number of dualpath
# secondaries. A breakdown that does not add up to its own headline is the
# kind of thing somebody spends an afternoon reconciling.
from shopdb.api import AssetStatus
by_status_query = db.session.query(
AssetStatus.status,
db.func.count(Machine.machineid)
).join(Asset, Asset.assetid == Machine.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True)
if secondaryassetids:
by_status_query = by_status_query.filter(
Asset.assetid.notin_(secondaryassetids))
by_status = by_status_query.group_by(AssetStatus.status).all()
return success_response({
'total': total,
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'bystatus': [{'status': s, 'count': c} for s, c in by_status]
})
@machines_bp.route('/dashboard/outofservice', methods=['GET'])
@require_permission('machines.view')
def dashboard_outofservice():
"""Machines whose status says they are not running.
Anything that is not In Use: In Repair, Lost, Returned, and so on. Someone
is supposed to be chasing each of these, and today they are visible only to
whoever thinks to filter the machines list by status.
Inventory is excluded - a spare on a shelf is not a problem, it is stock.
"""
from shopdb.api import AssetStatus
ignored = ('In Use', 'Inventory')
rows = (db.session.query(Machine, Asset, AssetStatus)
.join(Asset, Asset.assetid == Machine.assetid)
.join(AssetStatus, AssetStatus.statusid == Asset.statusid)
.filter(Asset.isactive.is_(True),
AssetStatus.status.notin_(ignored))
.order_by(Asset.assetnumber)
.limit(50).all())
return success_response([{
'assetid': asset.assetid,
'assetnumber': asset.assetnumber or str(asset.assetid),
'name': asset.name,
'status': status.status,
} for _machine, asset, status in rows])