printers: add format=text to install-list + pc-default; vendor via model

The Inno printer installers hand-parsed JSON in Pascal (brittle brace-counting).
Add ?format=text to install-list (one printer per line, pipe-delimited:
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy) and
to pc-default (printerid|windowsname), so the installer side is a split() with
no JSON parser. The web map keeps the default JSON.

Also resolve install-list's vendorname via the model (as the batch already does),
since the import sets the model, not the printer's direct vendorid - otherwise
the installers' HP/Xerox/Brother filter drops every prod printer.
This commit is contained in:
cproudlock
2026-07-29 13:51:01 -04:00
parent cb075a278f
commit ad84c9060a
2 changed files with 87 additions and 5 deletions

View File

@@ -343,7 +343,7 @@ def printer_install_list():
'sharename': printer.sharename,
'hostname': printer.hostname,
'ipaddress': ipaddress,
'vendorname': data.get('vendorname'),
'vendorname': _printer_vendor(printer),
'modelnumber': data.get('modelname'),
'installpath': printer.installpath,
'iscsf': printer.iscsf,
@@ -352,6 +352,15 @@ def printer_install_list():
'mapy': asset.mapy,
})
# A pipe-delimited text variant for the Inno installers: one printer per
# line, fixed field order, so the Pascal side is a split() instead of a
# hand-rolled JSON parser. The web map uses the default JSON.
if request.args.get('format') == 'text':
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
'hostname', 'ipaddress', 'mapx', 'mapy')
lines = [_text_line(row, fields) for row in rows]
return Response('\n'.join(lines), mimetype='text/plain')
return success_response(rows)
@@ -378,6 +387,17 @@ def _batch_base_url():
return 'https://%s%s' % (host, root)
def _text_line(row, fields):
"""One pipe-delimited line for the installer text format. None -> empty;
any pipe/newline in a value is neutralized so the field count stays fixed."""
parts = []
for key in fields:
value = row.get(key)
text = '' if value is None else str(value)
parts.append(text.replace('|', ' ').replace('\r', ' ').replace('\n', ' '))
return '|'.join(parts)
def _printer_vendor(printer):
"""Vendor name for install grouping. The legacy import sets the printer's
model but not its direct vendorid, so resolve via the model's vendor (as the
@@ -560,14 +580,21 @@ def pc_default_printer():
Returns {printerid, windowsname}, or {} when the machine is unknown or has
no active default printer set.
"""
as_text = request.args.get('format') == 'text'
def _empty():
# Text variant returns an empty body (no default) so the installer's
# split yields nothing; JSON keeps the {} contract.
return Response('', mimetype='text/plain') if as_text else success_response({})
machine = (request.args.get('machine') or '').strip()
if not machine:
return success_response({})
return _empty()
pc = Asset.query.filter_by(assetnumber=machine, isactive=True).first()
dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
if not pc or not dp_type:
return success_response({})
return _empty()
rel = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid,
@@ -575,14 +602,21 @@ def pc_default_printer():
isactive=True,
).first()
if not rel:
return success_response({})
return _empty()
printer = db.session.query(Printer).join(Asset).filter(
Printer.assetid == rel.targetassetid,
Asset.isactive == True,
).first()
if not printer:
return success_response({})
return _empty()
if as_text:
return Response(
_text_line({'printerid': printer.printerid,
'windowsname': printer.windowsname},
('printerid', 'windowsname')),
mimetype='text/plain')
return success_response({
'printerid': printer.printerid,

View File

@@ -38,6 +38,54 @@ def test_install_list_returns_network_printer_with_map(client, db, auth_headers,
assert row['mapx'] == 120 and row['mapy'] == 240
def test_install_list_vendorname_resolves_via_model(client, db, auth_headers,
printer_assettype):
"""vendorname falls back to the model's vendor when the printer has no
direct vendorid (the import sets the model, not the printer's vendor). The
Inno installer filters on this, so it must not be null on real data."""
from shopdb.core.models import Vendor, Model
hp = Vendor(vendor='HP')
db.session.add(hp)
db.session.commit()
model = Model(modelnumber='LaserJet M607', vendorid=hp.vendorid)
db.session.add(model)
db.session.commit()
client.post('/api/printers', json={
'assetnumber': 'CSF09-2022-HP', 'hostname': 'wjprn09',
'modelnumberid': model.modelnumberid,
}, headers=auth_headers)
resp = client.get('/api/printers/install-list', headers=auth_headers)
row = next((r for r in resp.get_json()['data']
if r['machinenumber'] == 'CSF09-2022-HP'), None)
assert row is not None
assert row['vendorname'] == 'HP'
def test_install_list_text_format(client, db, auth_headers, printer_assettype):
"""format=text returns one pipe-delimited line per printer (fixed field
order) so the Inno installers split() instead of parsing JSON."""
client.post('/api/printers', json={
'assetnumber': 'CSF01-HP', 'windowsname': 'CSF01-HP', 'hostname': 'wjprn01',
'mapx': 120, 'mapy': 240,
}, headers=auth_headers)
resp = client.get('/api/printers/install-list?format=text', headers=auth_headers)
assert resp.status_code == 200
assert resp.mimetype == 'text/plain'
line = next((l for l in resp.get_data(as_text=True).splitlines()
if 'CSF01-HP' in l), None)
assert line is not None
cols = line.split('|')
# printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
assert len(cols) == 8
assert cols[1] == 'CSF01-HP' # windowsname (falls back to assetnumber name)
assert cols[4] == 'wjprn01' # hostname
assert cols[6] == '120' and cols[7] == '240'
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,
printer_assettype):
"""A printer with no hostname and no network IP is excluded."""