Files
shopdb-flask/plugins/machines/api/routes.py
cproudlock 3324dbd91e
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
Buildings and levels for the floor map, and make every identifier searchable
The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
2026-08-17 12:55:51 -04:00

594 lines
20 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
machines_bp = Blueprint('machines', __name__)
# =============================================================================
# Machine Types
# =============================================================================
@machines_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
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'])
@jwt_required(optional=True)
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'])
@jwt_required()
@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'])
@jwt_required()
@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'])
@jwt_required()
@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'])
@jwt_required(optional=True)
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),
MachineType.machinetype.ilike(pattern),
Vendor.vendor.ilike(pattern)
)
)
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Machine.machinetypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Machine.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(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'])
@jwt_required(optional=True)
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'])
@jwt_required(optional=True)
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'])
@jwt_required()
@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()
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=data.get('lastmaintenancedate'),
nextmaintenancedate=data.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'])
@jwt_required()
@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()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = mach.asset
# 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',
'lastmaintenancedate', 'nextmaintenancedate', '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])
# 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'])
@jwt_required()
@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'])
@jwt_required(optional=True)
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
from shopdb.api import AssetStatus
by_status = 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
).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'])
@jwt_required()
@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])