Add print badges, pagination, route splitting, JWT auth fixes, and list page alignment

- Fix equipment badge barcode not rendering (loading race condition)
- Fix printer QR code not rendering on initial load (same race condition)
- Add model image to equipment badge via imageurl from Model table
- Fix white-on-white machine number text on badge, tighten barcode spacing
- Add PaginationBar component used across all list pages
- Split monolithic router into per-plugin route modules
- Fix 25 GET API endpoints returning 401 (jwt_required -> optional=True)
- Align list page columns across Equipment, PCs, and Network pages
- Add print views: EquipmentBadge, PrinterQRSingle, PrinterQRBatch, USBLabelBatch
- Add PC Relationships report, migration docs, and CLAUDE.md project guide
- Various plugin model, API, and frontend refinements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-04 07:32:44 -05:00
parent c4bfdc2db2
commit 9efdb5f52d
89 changed files with 3951 additions and 1138 deletions

View File

@@ -621,8 +621,8 @@ def dashboard_summary():
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_os': [{'os': o, 'count': c} for o, c in by_os],
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byos': [{'os': o, 'count': c} for o, c in by_os],
'shopfloor': shopfloor_count,
'non_shopfloor': total - shopfloor_count
'nonshopfloor': total - shopfloor_count
})

View File

@@ -117,9 +117,9 @@ class Computer(BaseModel):
# Add related object names
if self.computertype:
result['computertype_name'] = self.computertype.computertype
result['computertypename'] = self.computertype.computertype
if self.operatingsystem:
result['os_name'] = self.operatingsystem.osname
result['osname'] = self.operatingsystem.osname
return result

View File

@@ -78,8 +78,8 @@ class ComputersPlugin(BasePlugin):
if not existing:
at = AssetType(
assettype='computer',
plugin_name='computers',
table_name='computers',
pluginname='computers',
tablename='computers',
description='PCs, servers, and workstations',
icon='desktop'
)
@@ -201,9 +201,9 @@ class ComputersPlugin(BasePlugin):
"""Return navigation menu items."""
return [
{
'name': 'Computers',
'name': 'PCs',
'icon': 'desktop',
'route': '/computers',
'route': '/pcs',
'position': 15,
},
]

View File

@@ -306,8 +306,8 @@ def create_equipment():
lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'),
controller_vendorid=data.get('controller_vendorid'),
controller_modelid=data.get('controller_modelid')
controllervendorid=data.get('controllervendorid'),
controllermodelid=data.get('controllermodelid')
)
db.session.add(equip)
@@ -358,7 +358,7 @@ def update_equipment(equipment_id: int):
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controller_vendorid', 'controller_modelid']
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
if key in data:
setattr(equip, key, data[key])
@@ -427,6 +427,6 @@ def dashboard_summary():
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_status': [{'status': s, 'count': c} for s, c in by_status]
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'bystatus': [{'status': s, 'count': c} for s, c in by_status]
})

View File

@@ -78,13 +78,13 @@ class Equipment(BaseModel):
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)
controller_vendorid = db.Column(
controllervendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True,
comment='Controller vendor (e.g., FANUC)'
)
controller_modelid = db.Column(
controllermodelid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True,
@@ -99,8 +99,8 @@ class Equipment(BaseModel):
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controller_vendor = db.relationship('Vendor', foreign_keys=[controller_vendorid], backref='equipment_controllers')
controller_model = db.relationship('Model', foreign_keys=[controller_modelid], backref='equipment_controller_models')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
__table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'),
@@ -116,16 +116,18 @@ class Equipment(BaseModel):
# Add related object names
if self.equipmenttype:
result['equipmenttype_name'] = self.equipmenttype.equipmenttype
result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
if self.model:
result['model_name'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber
if self.model.imageurl:
result['imageurl'] = self.model.imageurl
# Add controller info
if self.controller_vendor:
result['controller_vendor_name'] = self.controller_vendor.vendor
if self.controller_model:
result['controller_model_name'] = self.controller_model.modelnumber
if self.controllervendor:
result['controllervendorname'] = self.controllervendor.vendor
if self.controllermodel:
result['controllermodelname'] = self.controllermodel.modelnumber
return result

View File

@@ -79,8 +79,8 @@ class EquipmentPlugin(BasePlugin):
if not existing:
at = AssetType(
assettype='equipment',
plugin_name='equipment',
table_name='equipment',
pluginname='equipment',
tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
@@ -214,7 +214,7 @@ class EquipmentPlugin(BasePlugin):
{
'name': 'Equipment',
'icon': 'cog',
'route': '/equipment',
'route': '/machines',
'position': 10,
},
]

View File

@@ -227,7 +227,7 @@ def get_network_device(device_id: int):
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result)
@@ -246,7 +246,7 @@ def get_network_device_by_asset(asset_id: int):
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result)
@@ -265,7 +265,7 @@ def get_network_device_by_hostname(hostname: str):
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result)
@@ -353,7 +353,7 @@ def create_network_device():
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result, message='Network device created', http_code=201)
@@ -413,7 +413,7 @@ def update_network_device(device_id: int):
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
result['networkdevice'] = netdev.to_dict()
return success_response(result, message='Network device updated')
@@ -479,10 +479,10 @@ def dashboard_summary():
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'poe': poe_count,
'non_poe': total - poe_count
'nonpoe': total - poe_count
})

View File

@@ -114,8 +114,8 @@ class NetworkDevice(BaseModel):
# Add related object names
if self.networkdevicetype:
result['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
return result

View File

@@ -78,8 +78,8 @@ class NetworkPlugin(BasePlugin):
if not existing:
at = AssetType(
assettype='network_device',
plugin_name='network',
table_name='networkdevices',
pluginname='network',
tablename='networkdevices',
description='Network infrastructure devices (switches, APs, cameras, etc.)',
icon='network-wired'
)

View File

@@ -374,7 +374,7 @@ def dashboard_summary():
return success_response({
'active': total_active,
'by_type': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
})

View File

@@ -24,7 +24,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
# =============================================================================
@printers_asset_bp.route('/types', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def list_printer_types():
"""List all printer types."""
page, per_page = get_pagination_params(request)
@@ -46,7 +46,7 @@ def list_printer_types():
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
@jwt_required(optional=True)
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = PrinterType.query.get(type_id)
@@ -471,6 +471,6 @@ def dashboard_summary():
'online': total, # Placeholder - would need monitoring integration
'lowsupplies': 0, # Placeholder - would need Zabbix integration
'criticalsupplies': 0, # Placeholder - would need Zabbix integration
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
})

View File

@@ -113,10 +113,10 @@ class Printer(BaseModel):
# Add related object names
if self.printertype:
result['printertype_name'] = self.printertype.printertype
result['printertypename'] = self.printertype.printertype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
if self.model:
result['model_name'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber
return result

View File

@@ -116,8 +116,8 @@ class PrintersPlugin(BasePlugin):
if not existing:
at = AssetType(
assettype='printer',
plugin_name='printers',
table_name='printers',
pluginname='printers',
tablename='printers',
description='Printers (laser, inkjet, label, MFP, plotter)',
icon='printer'
)

View File

@@ -161,10 +161,10 @@ def get_usb_device(device_id: int):
# Get recent checkout history
checkouts = USBCheckout.query.filter_by(
usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc()).limit(20).all()
).order_by(USBCheckout.checkouttime.desc()).limit(20).all()
result = device.to_dict()
result['checkout_history'] = [c.to_dict() for c in checkouts]
result['checkouthistory'] = [c.to_dict() for c in checkouts]
return success_response(result)
@@ -258,16 +258,16 @@ def checkout_device(device_id: int):
usbdeviceid=device_id,
machineid=0, # Legacy field, set to 0 for new checkouts
sso=data['sso'],
checkout_name=data.get('checkout_name'),
checkout_time=datetime.utcnow(),
checkout_reason=data.get('checkout_reason'),
was_wiped=False
checkoutname=data.get('checkoutname'),
checkouttime=datetime.utcnow(),
checkoutreason=data.get('checkoutreason'),
waswiped=False
)
# Update device status
device.ischeckedout = True
device.currentuserid = data['sso']
device.currentusername = data.get('checkout_name')
device.currentusername = data.get('checkoutname')
device.currentcheckoutdate = datetime.utcnow()
device.modifieddate = datetime.utcnow()
@@ -300,15 +300,15 @@ def checkin_device(device_id: int):
# Find active checkout
active_checkout = USBCheckout.query.filter_by(
usbdeviceid=device_id,
checkin_time=None
checkintime=None
).first()
data = request.get_json() or {}
if active_checkout:
active_checkout.checkin_time = datetime.utcnow()
active_checkout.checkin_notes = data.get('checkin_notes', active_checkout.checkin_notes)
active_checkout.was_wiped = data.get('was_wiped', False)
active_checkout.checkintime = datetime.utcnow()
active_checkout.checkinnotes = data.get('checkinnotes', active_checkout.checkinnotes)
active_checkout.waswiped = data.get('waswiped', False)
# Update device status
device.ischeckedout = False
@@ -337,7 +337,7 @@ def get_device_history(device_id: int):
query = USBCheckout.query.filter_by(
usbdeviceid=device_id
).order_by(USBCheckout.checkout_time.desc())
).order_by(USBCheckout.checkouttime.desc())
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
@@ -361,13 +361,13 @@ def list_all_checkouts():
# Filter by active only
if request.args.get('active', '').lower() == 'true':
query = query.filter(USBCheckout.checkin_time == None)
query = query.filter(USBCheckout.checkintime == None)
# Filter by user
if sso := request.args.get('sso'):
query = query.filter(USBCheckout.sso == sso)
query = query.order_by(USBCheckout.checkout_time.desc())
query = query.order_by(USBCheckout.checkouttime.desc())
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
@@ -380,7 +380,7 @@ def list_all_checkouts():
def list_active_checkouts():
"""List all currently active checkouts."""
checkouts = USBCheckout.query.filter(
USBCheckout.checkin_time == None
).order_by(USBCheckout.checkout_time.desc()).all()
USBCheckout.checkintime == None
).order_by(USBCheckout.checkouttime.desc()).all()
return success_response([c.to_dict() for c in checkouts])

View File

@@ -123,16 +123,16 @@ class USBCheckout(BaseModel):
# User info
sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
checkout_name = db.Column(db.String(100), nullable=True, comment='Name of user')
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
checkout_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkin_time = db.Column(db.DateTime, nullable=True)
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkintime = db.Column(db.DateTime, nullable=True)
# Metadata
checkout_reason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkin_notes = db.Column(db.Text, nullable=True)
was_wiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkinnotes = db.Column(db.Text, nullable=True)
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
# Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
@@ -143,13 +143,13 @@ class USBCheckout(BaseModel):
@property
def is_active(self):
"""Check if this checkout is currently active (not returned)."""
return self.checkin_time is None
return self.checkintime is None
@property
def duration_days(self):
"""Get duration of checkout in days."""
end = self.checkin_time or datetime.utcnow()
delta = end - self.checkout_time
end = self.checkintime or datetime.utcnow()
delta = end - self.checkouttime
return delta.days
def to_dict(self):