Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads

This commit is contained in:
2026-08-12 11:51:38 -04:00
parent 568dfbb91c
commit d32513727b
38 changed files with 2667 additions and 779 deletions

View File

@@ -1482,3 +1482,102 @@ def dashboard_supplies():
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
return success_response(rows)
# =============================================================================
# Supply forecast
#
# The toner report answers "what is empty now". This answers "what will be, and
# what have we been getting through" - a purchasing question, on a different
# cadence, off data Zabbix has been keeping all along.
# =============================================================================
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
@jwt_required(optional=True)
def supplies_forecast():
"""Days-to-empty and replacement counts per printer.
?days=90 how far back to read (Zabbix retention is the real ceiling)
Printers sort by their soonest supply. Anything without an honest estimate
is returned separately with the reason, rather than sorted as though it
were fine or dropped as though it did not exist.
"""
from ..services.supply_history import analyse, soonest
try:
days = max(1, min(365, int(request.args.get('days', 90))))
except (TypeError, ValueError):
days = 90
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
return success_response({
'printers': [], 'unestimated': [], 'days': days,
'available': False,
'reason': 'Zabbix is not configured or not reachable',
})
rows = (
db.session.query(Printer, Asset, Communication, Vendor)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
.filter(Asset.isactive == True,
Communication.ipaddress.isnot(None),
Communication.ipaddress != '')
.all()
)
seen = set()
estimated, unestimated = [], []
for printer, asset, comm, vendor in rows:
if printer.printerid in seen:
continue
seen.add(printer.printerid)
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
if not supplies:
continue
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
history = service.gethistory(itemids, days=days)
analysed = []
for supply in supplies:
points = history.get(str(supply.get('itemid')), [])
detail = analyse(points)
detail['name'] = supply.get('name')
detail['color'] = supply.get('color')
# Trust the live read for the level; history can lag a poll behind.
detail['currentlevel'] = supply.get('level', detail['currentlevel'])
analysed.append(detail)
entry = {
'printerid': printer.printerid,
'printername': asset.name or printer.hostname or '',
'assetnumber': asset.assetnumber or '',
'ipaddress': comm.ipaddress,
'vendor': vendor.vendor if vendor else None,
'supplies': analysed,
'daysleft': soonest(analysed),
'replacements': sum(s['replacements'] for s in analysed),
}
(estimated if entry['daysleft'] is not None else unestimated).append(entry)
# Soonest first: the point of the report is what to order next.
estimated.sort(key=lambda p: p['daysleft'])
unestimated.sort(key=lambda p: p['printername'])
return success_response({
'printers': estimated,
'unestimated': unestimated,
'days': days,
'available': True,
'summary': {
'estimated': len(estimated),
'unestimated': len(unestimated),
'replacements': sum(p['replacements'] for p in estimated + unestimated),
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
},
})