ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the observed half makes a rollout a review instead of a typing exercise: the floor reports itself in, you look, and you adopt. The collection uses the mechanism that already exists rather than a new one. POST /api/collector/printers dispatches to the printers plugin's apply_collector_payload, the same ADR-006 hook the computers and backups plugins implement. New client script, new plugin-owned table, no new transport and no new credential. OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A collector report can never write an assignment row: _reconcile_edges is the only function that writes usesprinter/defaultprinter, it has two call sites, and both are authenticated routes a human calls. If a drifted bay's own state were allowed to become what it is told to install, every configuration error would become permanent the next time that PC checked in. Seeding an assignment from observed state is explicit - POST /assignments/seed-from-observed - because a rollout adopts many machines at once. It routes through the same _reconcile_edges as the editor, so there is one write path with two doors, and a queue matching no known printer is REFUSED rather than guessed into an assignment. That last rule is the lesson from the measuring tools: adopting on a weak key produced 43 duplicate instruments. Two fixes on top of what the agents built. The replace deleted a host's previous rows by exact case-folded name while the read path treats a short name and its FQDN as one machine, so a PC that changed spelling appeared to hold every queue twice - which reads as drift that is not there. And the client sent 'reportedat' where the declared schema said 'observedat'. Also here: the legacy loader now imports machines.printerid, the classic system's record of each machine's default printer, which it silently dropped - the production import would have lost every one. And Set-ShopdbPrinters.ps1 finally registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to C:\ProgramData first because the share it lives on is mounted only during the enforcement cycle and the task runs at logon when it is gone. VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these scripts happily and executes none of the spooler branches. The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl and CollectorKey from HKLM when given no arguments; suppresses the virtual queues by port; resolves port addresses; and reads the CONSOLE USER's default out of HKU rather than SYSTEM's own, which is a different and usually wrong answer. Two results matter more than the rest. With the spooler stopped, both the cmdlet and the CIM path fail and the script posts NOTHING - verified against a capture server that recorded zero requests, where an empty list would instead have erased that host's observed rows and read as a bay that lost its printers. A genuinely empty host still posts [], because that is a real and different fact. The logon task registers as the Users group at Limited, and falls back to the well-known SID S-1-5-32-545 when the group name will not resolve, as it will not on localised Windows. It was then run with the source directory RENAMED AWAY, to stand in for the share being unmounted, and it still moved the user's default - which is the whole reason the script is staged to C:\ProgramData rather than run from where it lives. The guarantees against damage were re-checked rather than assumed: an empty assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly leaves no queue, no task, no staged file and no registry value behind, and a drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their default keeps it. Not covered by any of this: the driver-staging path, which needs a real vendor package rather than the class drivers a VM ships with.
2773 lines
108 KiB
Python
2773 lines
108 KiB
Python
"""Printers API routes - new Asset-based architecture."""
|
|
|
|
import logging
|
|
import re
|
|
|
|
from flask import Blueprint, request, Response
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
|
|
|
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
|
|
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
|
|
from ..services import (
|
|
ZabbixService,
|
|
classifysupply,
|
|
derivesupplytype,
|
|
derivecolor,
|
|
lookupsupplies,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from shopdb.api import require_permission, apply_import_timestamps
|
|
|
|
printers_asset_bp = Blueprint('printers_asset', __name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Printer Types
|
|
# =============================================================================
|
|
|
|
@printers_asset_bp.route('/types', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_printer_types():
|
|
"""List all printer types."""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = PrinterType.query
|
|
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(PrinterType.isactive == True)
|
|
|
|
if search := request.args.get('search'):
|
|
query = query.filter(PrinterType.printertype.ilike(f'%{search}%'))
|
|
|
|
query = query.order_by(PrinterType.printertype)
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
data = [t.to_dict() for t in items]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_printer_type(type_id: int):
|
|
"""Get a single printer type."""
|
|
t = db.session.get(PrinterType, type_id)
|
|
|
|
if not t:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer type with ID {type_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
return success_response(t.to_dict())
|
|
|
|
|
|
@printers_asset_bp.route('/types', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.create')
|
|
def create_printer_type():
|
|
"""Create a new printer type."""
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('printertype'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'printertype is required')
|
|
|
|
existing = PrinterType.query.filter_by(printertype=data['printertype']).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"Printer type '{data['printertype']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
t = PrinterType(
|
|
printertype=data['printertype'],
|
|
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='Printer type created', http_code=201)
|
|
|
|
|
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def update_printer_type(type_id: int):
|
|
"""Update a printer type."""
|
|
t = db.session.get(PrinterType, type_id)
|
|
if not t:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printer type with ID {type_id} not found', http_code=404)
|
|
|
|
data = request.get_json() or {}
|
|
if 'printertype' in data and data['printertype'] != t.printertype:
|
|
if PrinterType.query.filter_by(printertype=data['printertype']).first():
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Printer type '{data['printertype']}' already exists", http_code=409)
|
|
|
|
for key in ['printertype', 'description', 'icon', 'color', 'isactive']:
|
|
if key in data:
|
|
setattr(t, key, data[key])
|
|
|
|
db.session.commit()
|
|
return success_response(t.to_dict(), message='Printer type updated')
|
|
|
|
|
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printers.delete')
|
|
def delete_printer_type(type_id: int):
|
|
"""Delete a printer type. Refused if any printer still uses it."""
|
|
t = db.session.get(PrinterType, type_id)
|
|
if not t:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Printer type not found', http_code=404)
|
|
inuse = Printer.query.filter_by(printertypeid=type_id).count()
|
|
if inuse:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Cannot delete: {inuse} printer(s) still use this type", http_code=409)
|
|
db.session.delete(t)
|
|
db.session.commit()
|
|
return success_response(message='Printer type deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Printer Drivers (named SMB / HTTP links to driver packages)
|
|
# =============================================================================
|
|
|
|
@printers_asset_bp.route('/drivers', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_drivers():
|
|
"""List printer drivers. ?active=false includes inactive ones."""
|
|
query = PrinterDriver.query
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter_by(isactive=True)
|
|
drivers = query.order_by(PrinterDriver.name).all()
|
|
return success_response([d.to_dict() for d in drivers])
|
|
|
|
|
|
@printers_asset_bp.route('/drivers', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.create')
|
|
def create_driver():
|
|
data = request.get_json() or {}
|
|
if not (data.get('name') and data.get('location')):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
|
|
d = PrinterDriver(
|
|
name=data['name'],
|
|
location=data['location'],
|
|
description=data.get('description'),
|
|
modelnumberid=data.get('modelnumberid') or None,
|
|
isactive=data.get('isactive', True),
|
|
)
|
|
db.session.add(d)
|
|
db.session.commit()
|
|
return success_response(d.to_dict(), message='Driver created', http_code=201)
|
|
|
|
|
|
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def update_driver(driver_id):
|
|
d = db.session.get(PrinterDriver, driver_id)
|
|
if not d:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
for key in ('name', 'location', 'description', 'isactive'):
|
|
if key in data:
|
|
setattr(d, key, data[key])
|
|
if 'modelnumberid' in data:
|
|
d.modelnumberid = data['modelnumberid'] or None
|
|
db.session.commit()
|
|
return success_response(d.to_dict(), message='Driver updated')
|
|
|
|
|
|
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printers.delete')
|
|
def delete_driver(driver_id):
|
|
d = db.session.get(PrinterDriver, driver_id)
|
|
if not d:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Driver not found', http_code=404)
|
|
db.session.delete(d)
|
|
db.session.commit()
|
|
return success_response(message='Driver deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Printers CRUD
|
|
# =============================================================================
|
|
|
|
@printers_asset_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_printers():
|
|
"""
|
|
List all printers with filtering and pagination.
|
|
|
|
Query parameters:
|
|
- page, per_page: Pagination
|
|
- active: Filter by active status
|
|
- search: Search by asset number, name, or hostname
|
|
- type_id: Filter by printer 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 Printer with Asset
|
|
query = db.session.query(Printer).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. Type and model are columns in the list, so searching
|
|
# 'Thermal' must find the thermal printers. Outer joins so a printer missing
|
|
# either still matches on its own fields.
|
|
if search := request.args.get('search'):
|
|
pattern = f'%{search}%'
|
|
query = query.outerjoin(
|
|
PrinterType, Printer.printertypeid == PrinterType.printertypeid
|
|
).outerjoin(
|
|
Model, Printer.modelnumberid == Model.modelnumberid
|
|
).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),
|
|
Printer.hostname.ilike(pattern),
|
|
Printer.windowsname.ilike(pattern),
|
|
PrinterType.printertype.ilike(pattern),
|
|
Model.modelnumber.ilike(pattern)
|
|
)
|
|
)
|
|
|
|
# Type filter
|
|
if typeid := request.args.get('typeid', request.args.get('type_id')):
|
|
query = query.filter(Printer.printertypeid == int(typeid))
|
|
|
|
# Vendor filter
|
|
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
|
query = query.filter(Printer.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', 'hostname')
|
|
sort_dir = request.args.get('dir', 'asc')
|
|
|
|
if sort_by == 'hostname':
|
|
col = Printer.hostname
|
|
elif sort_by == 'assetnumber':
|
|
col = Asset.assetnumber
|
|
elif sort_by == 'name':
|
|
col = Asset.name
|
|
else:
|
|
col = Printer.hostname
|
|
|
|
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
|
|
# Build response with both asset and printer data
|
|
data = []
|
|
for printer in items:
|
|
item = printer.asset.to_dict() if printer.asset else {}
|
|
item['printer'] = printer.to_dict()
|
|
|
|
# Add primary IP address
|
|
if printer.asset:
|
|
primary_comm = Communication.query.filter_by(
|
|
assetid=printer.asset.assetid,
|
|
isprimary=True
|
|
).first()
|
|
if not primary_comm:
|
|
primary_comm = Communication.query.filter_by(
|
|
assetid=printer.asset.assetid
|
|
).first()
|
|
item['ipaddress'] = primary_comm.ipaddress if primary_comm else None
|
|
|
|
data.append(item)
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@printers_asset_bp.route('/install-list', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def printer_install_list():
|
|
"""Flat, unpaginated list of network printers for the printer installer.
|
|
|
|
Shopfloor 2.0 PCs cannot run unsigned .bat maps, so the signed installer EXE
|
|
pulls printer data + floor-map positions from here and renders the picker.
|
|
Replaces the classic apiprinters.asp contract, adding mapx/mapy. Network
|
|
printers only (USB-only printers are excluded).
|
|
"""
|
|
rows = []
|
|
query = db.session.query(Printer).join(Asset).filter(Asset.isactive == True)
|
|
for printer in query.all():
|
|
asset = printer.asset
|
|
if not asset:
|
|
continue
|
|
primary = Communication.query.filter_by(
|
|
assetid=asset.assetid, isprimary=True).first() \
|
|
or Communication.query.filter_by(assetid=asset.assetid).first()
|
|
ipaddress = primary.ipaddress if primary else None
|
|
|
|
# Network printers only: must have a hostname or a non-USB IP.
|
|
is_network = bool(printer.hostname) or (ipaddress and ipaddress != 'USB')
|
|
if not is_network:
|
|
continue
|
|
|
|
data = printer.to_dict()
|
|
rows.append({
|
|
'printerid': printer.printerid,
|
|
'name': asset.name or asset.assetnumber,
|
|
'machinenumber': asset.assetnumber,
|
|
'windowsname': printer.windowsname,
|
|
'sharename': printer.sharename,
|
|
'hostname': printer.hostname,
|
|
'ipaddress': ipaddress,
|
|
'vendorname': _printer_vendor(printer),
|
|
'modelnumber': data.get('modelname'),
|
|
'installpath': printer.installpath,
|
|
'iscsf': printer.iscsf,
|
|
'locationname': asset.location.locationname if asset.location else None,
|
|
'mapx': asset.mapx,
|
|
'levelid': asset.levelid,
|
|
'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', 'levelid')
|
|
lines = [_text_line(row, fields) for row in rows]
|
|
return Response('\n'.join(lines), mimetype='text/plain')
|
|
|
|
return success_response(rows)
|
|
|
|
|
|
# Vendors whose printers install through the universal PrinterInstaller.exe
|
|
# (single call with a comma-separated /PRINTER list). Everything else installs
|
|
# from its own installpath .exe, or is flagged for manual install. Mirrors the
|
|
# classic installprinter.asp grouping rule.
|
|
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
|
|
|
|
|
|
def _batch_base_url():
|
|
"""Base URL the generated .bat downloads installers from. Prefer the
|
|
configured site_base_url (it already includes scheme + the /shopdb mount).
|
|
|
|
Fallback matters: behind IIS the app sees http on a loopback port and its
|
|
url_root drops the mount, so a naive request.url_root yields a broken
|
|
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
|
|
mount (script_root) and force https instead."""
|
|
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
|
|
if base:
|
|
return base
|
|
host = request.headers.get('X-Forwarded-Host') or request.host
|
|
root = (request.script_root or '').rstrip('/')
|
|
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
|
|
classic installprinter.asp did) when the printer has no direct vendor."""
|
|
if printer.vendor:
|
|
return (printer.vendor.vendor or '').strip()
|
|
if printer.model and printer.model.vendor:
|
|
return (printer.model.vendor.vendor or '').strip()
|
|
return ''
|
|
|
|
|
|
def _installer_url(installpath, base):
|
|
"""Absolute URL for a specific installer. Full URLs and UNC paths pass
|
|
through; a stored relative path ('./installers/printers/X.exe') mounts under
|
|
the site base -> base + '/installers/printers/X.exe'."""
|
|
path = (installpath or '').strip()
|
|
if not path:
|
|
return None
|
|
if re.match(r'^[a-z][a-z0-9+.-]*:', path, re.I) or path.startswith('\\\\'):
|
|
return path
|
|
return base + '/' + re.sub(r'^(\.?/)+', '', path)
|
|
|
|
|
|
def _install_name(printer, asset):
|
|
"""Name to install the printer as: the standardized Windows Name, else the
|
|
share/CSF name, else the asset name/number."""
|
|
return ((printer.windowsname or '').strip()
|
|
or (printer.sharename or '').strip()
|
|
or (asset.name or asset.assetnumber or '').strip())
|
|
|
|
|
|
@printers_asset_bp.route('/install-batch', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def printer_install_batch():
|
|
"""Generate a Windows .bat that installs the selected printers.
|
|
|
|
?printerids=1,2,3 (the printers the user clicked on the installer map). The
|
|
batch groups them the same way the classic installprinter.asp did:
|
|
- HP / Xerox -> one universal PrinterInstaller.exe /PRINTER="a,b,c" call
|
|
- has .exe installpath -> download + run that installer /SILENT
|
|
- anything else (no installpath, or a .zip) -> listed as manual install
|
|
Downloads use PowerShell Invoke-WebRequest with the caller's Windows creds,
|
|
against the site base URL + the IIS-served /installers folder.
|
|
"""
|
|
raw = (request.args.get('printerids') or '').strip()
|
|
ids = []
|
|
for token in raw.split(','):
|
|
token = token.strip()
|
|
if token.isdigit():
|
|
ids.append(int(token))
|
|
if not ids:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'printerids is required (comma-separated)')
|
|
|
|
base = _batch_base_url()
|
|
universal = [] # (name,) install via PrinterInstaller.exe
|
|
specific = [] # (name, url) install via own .exe /SILENT
|
|
manual = [] # (name, vendor) cannot auto-install
|
|
|
|
for printer in (db.session.query(Printer).join(Asset)
|
|
.filter(Printer.printerid.in_(ids))
|
|
.filter(Asset.isactive == True).all()):
|
|
asset = printer.asset
|
|
if not asset:
|
|
continue
|
|
name = _install_name(printer, asset)
|
|
if not name:
|
|
continue
|
|
vendor = _printer_vendor(printer)
|
|
installpath = (printer.installpath or '').strip()
|
|
if vendor in UNIVERSAL_INSTALL_VENDORS:
|
|
universal.append(name)
|
|
elif installpath.lower().endswith('.exe'):
|
|
specific.append((name, _installer_url(installpath, base)))
|
|
else:
|
|
# No installer, or a non-.exe payload (e.g. .zip) we will not run
|
|
# blindly with /SILENT - surface it for a human instead.
|
|
manual.append((name, vendor or 'unknown'))
|
|
|
|
facility = (Setting.get('facility_name') or 'GE Aerospace').strip() or 'GE Aerospace'
|
|
total = len(universal) + len(specific) + len(manual)
|
|
bat = _render_install_bat(facility, base, universal, specific, manual, total)
|
|
|
|
count = total if total else 0
|
|
filename = ('Install_%d_Printers.bat' % count) if count != 1 else 'Install_Printer.bat'
|
|
return Response(bat, mimetype='application/octet-stream',
|
|
headers={'Content-Disposition': 'attachment; filename=%s' % filename})
|
|
|
|
|
|
# PowerShell one-liner that downloads a URL to a temp file using the caller's
|
|
# Windows credentials (the installers share/site is integrated-auth on the LAN).
|
|
_PS_DOWNLOAD = ("powershell -NoProfile -Command \""
|
|
"$ProgressPreference='SilentlyContinue'; "
|
|
"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; "
|
|
"Invoke-WebRequest -Uri '%s' -OutFile '%s' "
|
|
"-UseBasicParsing -UseDefaultCredentials\"")
|
|
|
|
|
|
def _render_install_bat(facility, base, universal, specific, manual, total):
|
|
"""Build the .bat text (CRLF line endings for cmd.exe)."""
|
|
out = []
|
|
add = out.append
|
|
add('@echo off')
|
|
add('setlocal enabledelayedexpansion')
|
|
add('')
|
|
add('echo ========================================')
|
|
add('echo %s Printer Installer' % facility)
|
|
add('echo ========================================')
|
|
add('echo.')
|
|
|
|
if total == 0:
|
|
add('echo No installable printers were selected.')
|
|
add('pause')
|
|
add('exit /b 1')
|
|
return '\r\n'.join(out) + '\r\n'
|
|
|
|
add('echo Installing %d printer(s)...' % total)
|
|
add('echo.')
|
|
|
|
if manual:
|
|
add('echo *** The following require MANUAL installation (no silent installer): ***')
|
|
for name, vendor in manual:
|
|
add('echo - %s (%s)' % (name, vendor))
|
|
add('echo.')
|
|
|
|
for name, url in specific:
|
|
add('echo ----------------------------------------')
|
|
add('echo Installing: %s' % name)
|
|
add('echo Downloading installer...')
|
|
add(_PS_DOWNLOAD % (url, '%TEMP%\\printer_installer.exe'))
|
|
add('if exist "%TEMP%\\printer_installer.exe" (')
|
|
add(' echo Running installer...')
|
|
add(' "%TEMP%\\printer_installer.exe" /SILENT')
|
|
add(' del "%TEMP%\\printer_installer.exe" 2>nul')
|
|
add(') else (')
|
|
add(' echo ERROR: Could not download installer for %s' % name)
|
|
add(')')
|
|
add('echo.')
|
|
|
|
if universal:
|
|
add('echo ----------------------------------------')
|
|
add('echo Installing %d printer(s) via the universal installer:' % len(universal))
|
|
for name in universal:
|
|
add('echo - %s' % name)
|
|
add('echo ----------------------------------------')
|
|
add('echo Downloading PrinterInstaller.exe...')
|
|
add(_PS_DOWNLOAD % (base + '/installers/PrinterInstaller.exe',
|
|
'%TEMP%\\PrinterInstaller.exe'))
|
|
add('if exist "%TEMP%\\PrinterInstaller.exe" (')
|
|
add(' echo Running installer...')
|
|
add(' "%TEMP%\\PrinterInstaller.exe" /PRINTER="' + ','.join(universal) + '"')
|
|
add(' del "%TEMP%\\PrinterInstaller.exe" 2>nul')
|
|
add(') else (')
|
|
add(' echo ERROR: Could not download PrinterInstaller.exe')
|
|
add(')')
|
|
add('echo.')
|
|
|
|
add('echo ========================================')
|
|
add('echo Installation Complete!')
|
|
add('echo ========================================')
|
|
add('echo.')
|
|
add('')
|
|
add(':: Self-delete this batch file')
|
|
add('(goto) 2>nul & del "%~f0"')
|
|
return '\r\n'.join(out) + '\r\n'
|
|
|
|
|
|
@printers_asset_bp.route('/pc-default', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def pc_default_printer():
|
|
"""Default printer for a PC, by machine (asset) number.
|
|
|
|
Parity with classic apipcdefaultprinter.asp: the signed installer EXE
|
|
preselects a PC's default-printer hotspot on the site-map wizard using the
|
|
machine number persisted at PXE enrollment. The link is a `defaultprinter`
|
|
asset relationship (PC asset -> printer asset), so this stays inside the
|
|
contract surface (no cross-plugin model import).
|
|
|
|
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 _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 _empty()
|
|
|
|
rel = AssetRelationship.query.filter_by(
|
|
sourceassetid=pc.assetid,
|
|
relationshiptypeid=dp_type.relationshiptypeid,
|
|
isactive=True,
|
|
).first()
|
|
if not rel:
|
|
return _empty()
|
|
|
|
printer = db.session.query(Printer).join(Asset).filter(
|
|
Printer.assetid == rel.targetassetid,
|
|
Asset.isactive == True,
|
|
).first()
|
|
if not printer:
|
|
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,
|
|
'windowsname': printer.windowsname,
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Printer assignment resolution (which printers belong on a PC)
|
|
# =============================================================================
|
|
|
|
# The assignment edges. usesprinter says a printer is installed here;
|
|
# defaultprinter says which of them Windows should default to.
|
|
_USES_PRINTER = 'usesprinter'
|
|
_DEFAULT_PRINTER = 'defaultprinter'
|
|
_CONTROLS = 'controls'
|
|
|
|
|
|
def _relationship_typeids(*names):
|
|
"""{name: [relationshiptypeid, ...]} for the named relationship types.
|
|
|
|
A list per name, not an id: MySQL's default collation is case-insensitive,
|
|
so a legacy 'Controls' row lives happily beside 'controls' and a walk that
|
|
picked one of them would silently miss half the data. Names absent from the
|
|
table map to an empty list, which resolves to no printers rather than an
|
|
error - an un-seeded database is a deployment step missed, not a bad request.
|
|
"""
|
|
wanted = {name.lower(): [] for name in names}
|
|
rows = RelationshipType.query.filter(
|
|
RelationshipType.relationshiptype.in_(names)).all()
|
|
for row in rows:
|
|
key = (row.relationshiptype or '').lower()
|
|
if key in wanted:
|
|
wanted[key].append(row.relationshiptypeid)
|
|
return wanted
|
|
|
|
|
|
def _outgoing_rows(assetid, typeids):
|
|
"""Active outgoing relationships of the given types, oldest first."""
|
|
if not typeids:
|
|
return []
|
|
return (AssetRelationship.query
|
|
.filter(AssetRelationship.sourceassetid == assetid,
|
|
AssetRelationship.relationshiptypeid.in_(typeids),
|
|
AssetRelationship.isactive == True)
|
|
.order_by(AssetRelationship.relationshipid)
|
|
.all())
|
|
|
|
|
|
def _own_assignment(assetid, typeids):
|
|
"""One asset's OWN assignment: (ordered printer assetids, default assetid).
|
|
|
|
On an asset with NO usesprinter rows, a defaultprinter row is the whole
|
|
assignment. Those rows predate this feature - the installer preselect and
|
|
the collector both write them - and ignoring them would take printers away
|
|
from every PC recorded before assignment existed. Once an asset has
|
|
usesprinter rows it is managed, and a default outside that set is stale
|
|
rather than legacy, so it is dropped by _assignment_result.
|
|
|
|
Two active defaults cannot be prevented by the schema - the unique
|
|
constraint is (source, target, type) - so the oldest row wins and the rest
|
|
are ignored, which at least makes the answer the same on every read.
|
|
"""
|
|
printerassetids = []
|
|
for rel in _outgoing_rows(assetid, typeids[_USES_PRINTER]):
|
|
if rel.targetassetid not in printerassetids:
|
|
printerassetids.append(rel.targetassetid)
|
|
ismanaged = bool(printerassetids)
|
|
|
|
defaultassetid = None
|
|
for rel in _outgoing_rows(assetid, typeids[_DEFAULT_PRINTER]):
|
|
if not ismanaged and rel.targetassetid not in printerassetids:
|
|
printerassetids.append(rel.targetassetid)
|
|
if defaultassetid is None:
|
|
defaultassetid = rel.targetassetid
|
|
|
|
return printerassetids, defaultassetid
|
|
|
|
|
|
def resolve_asset_printers(asset):
|
|
"""Which printers an asset gets, and which one is default.
|
|
|
|
Own rows first; only when the asset has none does the walk follow its
|
|
outgoing controls edges one hop and take the assignment of whatever it
|
|
controls.
|
|
|
|
THE INHERITANCE IS THE FEATURE. Printers are a property of the bay, not of
|
|
the box sat next to it: the machine holds the assignment, and whichever PC
|
|
controls that machine picks it up. So a PC that is reimaged, or swapped for
|
|
a different chassis entirely, resolves the same printers on its next cycle
|
|
with nothing backed up and nothing restored. A PC that controls no machine -
|
|
an office PC - has only its own rows, which is the same code path with an
|
|
empty walk.
|
|
|
|
A PC's own rows SHADOW what it would inherit rather than adding to it, so a
|
|
one-off printer on a bay PC is expressed by assigning that PC everything it
|
|
should have, not by hoping two sets merge.
|
|
|
|
Returns {'assignments': [{'assetid', 'isdefault', 'inheritedfromassetid'}],
|
|
'source': 'self' | 'inherited' | 'none'}.
|
|
"""
|
|
assetid = getattr(asset, 'assetid', None)
|
|
if assetid is None:
|
|
return {'assignments': [], 'source': 'none'}
|
|
|
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
|
|
|
printerassetids, defaultassetid = _own_assignment(assetid, typeids)
|
|
if printerassetids:
|
|
return _assignment_result(printerassetids, defaultassetid, None)
|
|
|
|
# Nothing of its own: take the bay's. Outgoing controls only (PC -> machine,
|
|
# the direction `flask relationships fix-controls-direction` enforces).
|
|
inherited = []
|
|
defaults = []
|
|
suppliers = {}
|
|
for rel in _outgoing_rows(assetid, typeids[_CONTROLS]):
|
|
machine = rel.targetasset
|
|
if machine is None or not getattr(machine, 'isactive', True):
|
|
continue
|
|
machineprinters, machinedefault = _own_assignment(machine.assetid, typeids)
|
|
for printerassetid in machineprinters:
|
|
if printerassetid not in inherited:
|
|
inherited.append(printerassetid)
|
|
suppliers[printerassetid] = machine.assetid
|
|
if machinedefault is not None and machinedefault not in defaults:
|
|
defaults.append(machinedefault)
|
|
|
|
if not inherited:
|
|
return {'assignments': [], 'source': 'none'}
|
|
|
|
# A PC controlling several machines (or both bays of a dualpath pair) can
|
|
# inherit two different defaults. Union the printers, but refuse to guess a
|
|
# default: no default is a state the client already handles, a coin toss is
|
|
# not.
|
|
if len(defaults) > 1:
|
|
logger.warning(
|
|
'Asset %s inherits %d conflicting default printers; leaving default unset',
|
|
assetid, len(defaults))
|
|
inheriteddefault = None
|
|
else:
|
|
inheriteddefault = defaults[0] if defaults else None
|
|
|
|
return _assignment_result(inherited, inheriteddefault, suppliers)
|
|
|
|
|
|
def _assignment_result(printerassetids, defaultassetid, suppliers):
|
|
"""Shape the resolver's answer. suppliers is None for an asset's own rows."""
|
|
# Settled rule: the default must be one of the assigned printers. A dangling
|
|
# default happens when a printer is unassigned through the generic
|
|
# relationships card, which knows nothing about this pairing.
|
|
if defaultassetid not in printerassetids:
|
|
defaultassetid = None
|
|
return {
|
|
'assignments': [{
|
|
'assetid': printerassetid,
|
|
'isdefault': printerassetid == defaultassetid,
|
|
'inheritedfromassetid': (suppliers or {}).get(printerassetid),
|
|
} for printerassetid in printerassetids],
|
|
'source': 'inherited' if suppliers is not None else 'self',
|
|
}
|
|
|
|
|
|
def _printer_driver(printer, universaldrivers):
|
|
"""Driver record to install this printer with, or None.
|
|
|
|
Three steps, most specific first:
|
|
|
|
1. A driver bound to the printer's MODEL. A plotter, a card printer and a
|
|
label printer each need their own, and a per-model row must beat the
|
|
universal one.
|
|
2. A driver bound to the printer's VENDOR with no model. HP's and Xerox's
|
|
universal drivers cover 41 of the reference site's 44 printers between
|
|
them; binding those to one model each would mean a near-duplicate row per
|
|
model, which is a table nobody keeps true.
|
|
3. Failing both, a model-less driver whose NAME carries the vendor word.
|
|
This is the pre-vendorid convention, kept so a site that populated its
|
|
table before the column existed does not lose its drivers on upgrade.
|
|
"""
|
|
if printer.modelnumberid:
|
|
driver = (PrinterDriver.query
|
|
.filter_by(modelnumberid=printer.modelnumberid, isactive=True)
|
|
.order_by(PrinterDriver.name).first())
|
|
if driver:
|
|
return driver
|
|
|
|
if printer.vendorid:
|
|
for driver in universaldrivers:
|
|
if driver.vendorid == printer.vendorid:
|
|
return driver
|
|
|
|
vendor = _printer_vendor(printer).lower()
|
|
if not vendor:
|
|
return None
|
|
for driver in universaldrivers:
|
|
# Only the legacy convention here: a row WITH a vendorid that did not
|
|
# match above must not be matched by its name instead, or a mis-set
|
|
# vendor silently resolves to the wrong package.
|
|
if driver.vendorid:
|
|
continue
|
|
if vendor in (driver.name or '').lower():
|
|
return driver
|
|
return None
|
|
|
|
|
|
def _computer_by_hostname(hostname):
|
|
"""Active computer asset matching a reported hostname, or None.
|
|
|
|
Case-folded on both sides: COMPUTERNAME arrives uppercase, MySQL forgives
|
|
that and SQLite does not, so an uncompared case would work in production and
|
|
fail in the tests (or the other way round on a binary collation).
|
|
|
|
A short name also matches a stored FQDN, and an FQDN matches a stored short
|
|
name, because which of the two a site records is a matter of how its PCs
|
|
were enrolled and the client only ever knows its own COMPUTERNAME.
|
|
"""
|
|
from plugins.computers.models import Computer
|
|
|
|
name = (hostname or '').strip().lower()
|
|
if not name:
|
|
return None
|
|
|
|
query = db.session.query(Computer, Asset).join(
|
|
Asset, Asset.assetid == Computer.assetid).filter(Asset.isactive == True)
|
|
|
|
row = query.filter(db.func.lower(Computer.hostname) == name).first()
|
|
if row:
|
|
return row
|
|
|
|
shortname = name.split('.')[0]
|
|
if shortname != name:
|
|
row = query.filter(db.func.lower(Computer.hostname) == shortname).first()
|
|
if row:
|
|
return row
|
|
# Prefix match only for a plain hostname: LIKE wildcards in a path segment
|
|
# would otherwise let '%' pull back somebody else's printers.
|
|
if not re.match(r'^[a-z0-9-]+$', shortname):
|
|
return None
|
|
return query.filter(
|
|
db.func.lower(Computer.hostname).like(shortname + '.%')).first()
|
|
|
|
|
|
@printers_asset_bp.route('/for-host/<hostname>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def printers_for_host(hostname: str):
|
|
"""Printers assigned to a PC, by hostname, with what it takes to install one.
|
|
|
|
The endpoint the convergence client asks on every cycle: give me the state
|
|
this host should be in. Resolution is own rows, else the assignment of the
|
|
machine this PC controls (see resolve_asset_printers) - which is why a
|
|
reimaged bay reinstalls its own printers.
|
|
|
|
Resolved by hostname rather than machine number because the collector
|
|
upserts PCs by hostname and an office PC has no machine number at all.
|
|
|
|
404 when the host is unknown. A known host with nothing assigned is an
|
|
empty list and a null default, not an error: that is the client's no-op.
|
|
|
|
Each printer carries queuename (what to call the queue), hostname/ipaddress
|
|
(where to point the port), port (null means the client's own default raw
|
|
port), drivername (verbatim from the INF, what Add-PrinterDriver matches on)
|
|
and driverlocation (where the package lives).
|
|
"""
|
|
try:
|
|
row = _computer_by_hostname(hostname)
|
|
except ImportError:
|
|
# No computers plugin, no way to resolve a hostname to an asset.
|
|
row = None
|
|
|
|
if not row:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No computer found with hostname {hostname}',
|
|
http_code=404)
|
|
|
|
computer, asset = row
|
|
resolved = resolve_asset_printers(asset)
|
|
assignments = resolved['assignments']
|
|
|
|
printers = []
|
|
if assignments:
|
|
assetids = [item['assetid'] for item in assignments]
|
|
rows = (db.session.query(Printer)
|
|
.join(Asset, Asset.assetid == Printer.assetid)
|
|
.filter(Printer.assetid.in_(assetids))
|
|
.filter(Asset.isactive == True)
|
|
.all())
|
|
byassetid = {printer.assetid: printer for printer in rows}
|
|
|
|
# Fetched once: the universal-driver fallback would otherwise re-read
|
|
# the same handful of rows per printer.
|
|
universaldrivers = (PrinterDriver.query
|
|
.filter(PrinterDriver.modelnumberid.is_(None),
|
|
PrinterDriver.isactive == True)
|
|
.order_by(PrinterDriver.name).all())
|
|
|
|
for item in assignments:
|
|
printer = byassetid.get(item['assetid'])
|
|
if not printer:
|
|
# Assigned asset is retired, or is not a printer at all.
|
|
continue
|
|
printerasset = printer.asset
|
|
primary = Communication.query.filter_by(
|
|
assetid=printer.assetid, isprimary=True).first() \
|
|
or Communication.query.filter_by(assetid=printer.assetid).first()
|
|
driver = _printer_driver(printer, universaldrivers)
|
|
printers.append({
|
|
'printerid': printer.printerid,
|
|
'assetid': printer.assetid,
|
|
'queuename': _install_name(printer, printerasset),
|
|
'windowsname': printer.windowsname,
|
|
'sharename': printer.sharename,
|
|
'hostname': printer.hostname,
|
|
'ipaddress': primary.ipaddress if primary else None,
|
|
'port': primary.port if primary else None,
|
|
'driverid': driver.driverid if driver else None,
|
|
'drivername': driver.drivername if driver else None,
|
|
'driverlocation': driver.location if driver else None,
|
|
'installpath': printer.installpath,
|
|
'isdefault': item['isdefault'],
|
|
'inheritedfromassetid': item['inheritedfromassetid'],
|
|
})
|
|
|
|
default = next((p for p in printers if p['isdefault']), None)
|
|
|
|
return success_response({
|
|
'hostname': computer.hostname,
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
# Where the assignment came from, so a technician reading a client log
|
|
# can tell a bay's printers from the PC's own overrides.
|
|
'source': resolved['source'],
|
|
'defaultprinterid': default['printerid'] if default else None,
|
|
'printers': printers,
|
|
})
|
|
|
|
|
|
|
|
|
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset_printer_assignment(asset_id: int):
|
|
"""What THIS asset is assigned, without inheritance.
|
|
|
|
Deliberately not resolved: an editor has to show what this asset's own rows
|
|
say, or a machine's printers would appear ticked on the PC that inherits
|
|
them and unticking one would silently create an override. for-host is the
|
|
resolved view; this is the editable one.
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
if not asset or not asset.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
|
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
|
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
|
return success_response({
|
|
'assetid': asset_id,
|
|
'printerassetids': printerassetids,
|
|
'defaultprinterassetid': defaultassetid,
|
|
})
|
|
|
|
|
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def set_asset_printer_assignment(asset_id: int):
|
|
"""Reconcile one asset's whole printer assignment in a single call.
|
|
|
|
Body: {"printerassetids": [...], "defaultprinterassetid": N or null}.
|
|
|
|
The WHOLE set, not a delta, because the caller knows the intended end state
|
|
and a row-at-a-time edit is a non-atomic reconcile: an HTTP failure part way
|
|
leaves an asset half-assigned, with nothing recording what was meant.
|
|
|
|
Written against the MACHINE for a bay - that is the point of the feature, so
|
|
a reimaged PC inherits it - but an asset is an asset here, and writing to a
|
|
PC deliberately shadows its machine (see resolve_asset_printers).
|
|
|
|
Rows that go away are SOFT-deleted and rows that come back are REACTIVATED
|
|
rather than inserted: the unique constraint (source, target, type) spans
|
|
inactive rows, so a blind insert after an unassign raises IntegrityError on
|
|
MySQL while passing on SQLite.
|
|
|
|
Removal here uninstalls nothing. It changes what the bay is told to have;
|
|
the client never deletes a queue.
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
if not asset or not asset.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
raw = data.get('printerassetids')
|
|
if raw is None or not isinstance(raw, list):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'printerassetids must be a list of asset ids')
|
|
|
|
# Ordered, de-duplicated: the same printer twice is one assignment, and the
|
|
# order is the order the client is told to install them in.
|
|
wanted = []
|
|
for value in raw:
|
|
try:
|
|
assetid = int(value)
|
|
except (TypeError, ValueError):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'printerassetids must be integers')
|
|
if assetid not in wanted:
|
|
wanted.append(assetid)
|
|
|
|
defaultid = data.get('defaultprinterassetid')
|
|
if defaultid is not None:
|
|
try:
|
|
defaultid = int(defaultid)
|
|
except (TypeError, ValueError):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'defaultprinterassetid must be an asset id or null')
|
|
# Checked BEFORE any write, so a rejected request changes nothing. A
|
|
# default outside the set tells the client to default to a queue it was
|
|
# never told to install: it fails, and nothing in ShopDB says why.
|
|
if defaultid not in wanted:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'defaultprinterassetid must be one of printerassetids')
|
|
|
|
# Every target must exist and be a printer. Assigning a machine to a machine
|
|
# is a typo that would otherwise sit in the data until a bay tried it.
|
|
if wanted:
|
|
found = {row.assetid: row for row in
|
|
Asset.query.filter(Asset.assetid.in_(wanted)).all()}
|
|
missing = [assetid for assetid in wanted if assetid not in found]
|
|
if missing:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
'Unknown printer asset(s): {0}'.format(
|
|
', '.join(str(assetid) for assetid in missing)),
|
|
http_code=404)
|
|
notprinters = [assetid for assetid, row in found.items()
|
|
if not (row.assettype and row.assettype.assettype == 'printer')]
|
|
if notprinters:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Not printer assets: {0}'.format(
|
|
', '.join(str(assetid) for assetid in sorted(notprinters))))
|
|
|
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
|
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
|
# Seed data, not a migration. An un-seeded database cannot hold an
|
|
# assignment, and saying so beats writing rows nothing can read.
|
|
return error_response(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
'Relationship types are not seeded - run: flask seed reference-data',
|
|
http_code=500)
|
|
|
|
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
|
typeids[_USES_PRINTER], wanted)
|
|
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
|
typeids[_DEFAULT_PRINTER],
|
|
[defaultid] if defaultid is not None else [])
|
|
db.session.commit()
|
|
|
|
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
|
return success_response({
|
|
'assetid': asset_id,
|
|
'printerassetids': printerassetids,
|
|
'defaultprinterassetid': defaultassetid,
|
|
}, message='Printer assignment updated')
|
|
|
|
|
|
def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
|
"""Make the active edges of one type be exactly `wantedtargets`.
|
|
|
|
Reads across every case-variant type id (a legacy 'DefaultPrinter' row is
|
|
the same edge) but writes new rows with one, so the table converges on a
|
|
single spelling instead of accumulating both.
|
|
"""
|
|
existing = {}
|
|
rows = (AssetRelationship.query
|
|
.filter(AssetRelationship.sourceassetid == sourceassetid,
|
|
AssetRelationship.relationshiptypeid.in_(readtypeids))
|
|
.order_by(AssetRelationship.relationshipid)
|
|
.all())
|
|
for row in rows:
|
|
existing.setdefault(row.targetassetid, []).append(row)
|
|
|
|
for targetassetid, rowlist in existing.items():
|
|
if targetassetid in wantedtargets:
|
|
# Keep the oldest, retire any duplicate: two active rows for one
|
|
# edge is how an asset ends up with two defaults.
|
|
keep = rowlist[0]
|
|
keep.isactive = True
|
|
for extra in rowlist[1:]:
|
|
extra.isactive = False
|
|
else:
|
|
for row in rowlist:
|
|
row.isactive = False
|
|
|
|
for targetassetid in wantedtargets:
|
|
if targetassetid not in existing:
|
|
db.session.add(AssetRelationship(
|
|
sourceassetid=sourceassetid,
|
|
targetassetid=targetassetid,
|
|
relationshiptypeid=writetypeid,
|
|
isactive=True))
|
|
|
|
|
|
# =============================================================================
|
|
# Observed state (what a bay actually has)
|
|
# =============================================================================
|
|
|
|
# The other half of the loop. The assignment says what a host SHOULD have; the
|
|
# observed rows say what it reported having on its last cycle, and they are kept
|
|
# apart on purpose - the moment a drifted bay's observed state is treated as
|
|
# correct, enforcement stops meaning anything. Nothing here writes an assignment
|
|
# except the seed endpoint at the bottom, which a person has to ask for.
|
|
|
|
_OBSERVED_MATCHING = 'matching'
|
|
_OBSERVED_MISSING = 'missing'
|
|
_OBSERVED_EXTRA = 'extra'
|
|
_OBSERVED_DRIFTED = 'drifted'
|
|
_OBSERVED_UNKNOWN = 'unknown'
|
|
|
|
_OBSERVED_CLASSIFICATIONS = (_OBSERVED_MATCHING, _OBSERVED_MISSING,
|
|
_OBSERVED_EXTRA, _OBSERVED_DRIFTED,
|
|
_OBSERVED_UNKNOWN)
|
|
|
|
|
|
def _observed_rows(hostname):
|
|
"""The rows of a host's last report, or [].
|
|
|
|
Case-folded with the same short-name and FQDN fallbacks as
|
|
_computer_by_hostname: the reporter sends its own COMPUTERNAME, and whether
|
|
that is short or fully qualified is a matter of how the site enrolls PCs,
|
|
not of which host it is.
|
|
|
|
Ordered by queue name, not by insertion: a report replaces the previous one
|
|
in one pass, so row order carries no meaning, and a stable sort keeps two
|
|
reads of the same report - and the seed built from it - identical.
|
|
"""
|
|
name = (hostname or '').strip().lower()
|
|
if not name:
|
|
return []
|
|
|
|
query = PrinterObservedQueue.query.order_by(PrinterObservedQueue.queuename)
|
|
|
|
rows = query.filter(db.func.lower(PrinterObservedQueue.hostname) == name).all()
|
|
if rows:
|
|
return rows
|
|
|
|
shortname = name.split('.')[0]
|
|
if shortname != name:
|
|
rows = query.filter(
|
|
db.func.lower(PrinterObservedQueue.hostname) == shortname).all()
|
|
if rows:
|
|
return rows
|
|
# Prefix match only for a plain hostname, as in _computer_by_hostname: a
|
|
# LIKE wildcard arriving in the path segment would pull back another PC.
|
|
if not re.match(r'^[a-z0-9-]+$', shortname):
|
|
return []
|
|
return query.filter(
|
|
db.func.lower(PrinterObservedQueue.hostname).like(shortname + '.%')).all()
|
|
|
|
|
|
def _printer_match_index():
|
|
"""Lookup tables for turning an observed queue into a printer asset.
|
|
|
|
Built in two queries instead of one lookup per queue, and built in
|
|
printerid order so first-match is deterministic: two printers can share an
|
|
address (a multi-queue device, or a Communication row left behind by a
|
|
swap), and without a fixed order the same report would classify one way on
|
|
one read and another way on the next.
|
|
|
|
'addressof' is what an install would point the port at - hostname first, IP
|
|
second, the order Set-ShopdbPrinters uses - and 'addressesof' is every
|
|
address that IS this printer. Drift is judged against the set, not the
|
|
preferred one: a queue pointed at the printer's IP where the register names
|
|
its hostname reaches the same device, and calling that drift would light up
|
|
every correctly installed bay.
|
|
"""
|
|
rows = (db.session.query(Printer, Asset)
|
|
.join(Asset, Asset.assetid == Printer.assetid)
|
|
.filter(Asset.isactive == True)
|
|
.order_by(Printer.printerid)
|
|
.all())
|
|
|
|
communications = {}
|
|
assetids = [printer.assetid for printer, _asset in rows]
|
|
if assetids:
|
|
for communication in (Communication.query
|
|
.filter(Communication.assetid.in_(assetids))
|
|
.order_by(Communication.communicationid).all()):
|
|
communications.setdefault(communication.assetid, []).append(communication)
|
|
|
|
index = {'byaddress': {}, 'byqueuename': {}, 'printers': {},
|
|
'addressof': {}, 'addressesof': {}}
|
|
for printer, asset in rows:
|
|
index['printers'][printer.assetid] = (printer, asset)
|
|
|
|
own = communications.get(printer.assetid, [])
|
|
primary = next((item for item in own if item.isprimary), None)
|
|
if primary is None and own:
|
|
primary = own[0]
|
|
|
|
address = (printer.hostname or '').strip()
|
|
if not address and primary is not None:
|
|
address = (primary.ipaddress or '').strip()
|
|
index['addressof'][printer.assetid] = address or None
|
|
|
|
# Every address the printer answers on, not just the primary one: a
|
|
# second NIC still identifies the same device to a port that uses it.
|
|
addresses = set()
|
|
for value in [printer.hostname] + [item.ipaddress for item in own]:
|
|
key = (value or '').strip().lower()
|
|
if key:
|
|
addresses.add(key)
|
|
index['byaddress'].setdefault(key, printer.assetid)
|
|
index['addressesof'][printer.assetid] = addresses
|
|
|
|
for value in (printer.windowsname, printer.sharename,
|
|
_install_name(printer, asset),
|
|
asset.assetnumber, asset.name):
|
|
key = (value or '').strip().lower()
|
|
if key:
|
|
index['byqueuename'].setdefault(key, printer.assetid)
|
|
|
|
return index
|
|
|
|
|
|
def _match_observed_queue(row, index):
|
|
"""The printer assetid an observed queue is, or None.
|
|
|
|
PORT ADDRESS first. An IP or FQDN names one device and cannot be reused by
|
|
a differently-named queue on the next bay, so it is the only key worth
|
|
trusting. The Windows PORT NAME is looked up in the same address table
|
|
because a standard TCP/IP port created outside the client is named after the
|
|
host address itself - that is still an exact match on an address ShopDB
|
|
holds, not a guess at one.
|
|
|
|
Queue name second, and nothing after it. An unmatched queue is reported as
|
|
unknown: a wrong match seeds a wrong assignment, which is worse than no
|
|
assignment at all.
|
|
"""
|
|
for value in (row.portaddress, row.portname):
|
|
key = (value or '').strip().lower()
|
|
if key and key in index['byaddress']:
|
|
return index['byaddress'][key]
|
|
|
|
return index['byqueuename'].get((row.queuename or '').strip().lower())
|
|
|
|
|
|
def _assigned_expectations(assignment, index):
|
|
"""{printer assetid: what an install of it would look like}.
|
|
|
|
Keyed off the resolved assignment, so a PC that inherits its bay's printers
|
|
is compared against the bay's, which is what the client would have
|
|
installed. An assigned asset that is retired or is not a printer is left out
|
|
entirely: for-host skips it too, so a bay cannot be missing it.
|
|
"""
|
|
expected = {}
|
|
assignments = assignment.get('assignments') or []
|
|
if not assignments:
|
|
return expected
|
|
|
|
universaldrivers = (PrinterDriver.query
|
|
.filter(PrinterDriver.modelnumberid.is_(None),
|
|
PrinterDriver.isactive == True)
|
|
.order_by(PrinterDriver.name).all())
|
|
|
|
for item in assignments:
|
|
entry = index['printers'].get(item['assetid'])
|
|
if entry is None:
|
|
continue
|
|
printer, asset = entry
|
|
driver = _printer_driver(printer, universaldrivers)
|
|
expected[printer.assetid] = {
|
|
'printerid': printer.printerid,
|
|
'printerassetid': printer.assetid,
|
|
'printername': asset.name or asset.assetnumber,
|
|
'queuename': _install_name(printer, asset),
|
|
'portaddress': index['addressof'].get(printer.assetid),
|
|
'addresses': index['addressesof'].get(printer.assetid) or set(),
|
|
'drivername': driver.drivername if driver else None,
|
|
'isdefault': bool(item.get('isdefault')),
|
|
'inheritedfromassetid': item.get('inheritedfromassetid'),
|
|
}
|
|
return expected
|
|
|
|
|
|
def _observed_driftfields(row, want):
|
|
"""Which installed properties disagree with the assignment.
|
|
|
|
Port address and driver name only. A queue NAME that differs is reported -
|
|
expectedqueuename is in the payload - but is not drift: the port says it is
|
|
the same device, and a locally renamed queue still prints to it, so renaming
|
|
it back is a preference rather than a fault.
|
|
|
|
The address is judged against every address the printer answers on rather
|
|
than against the one an install would prefer, because hostname and IP are
|
|
the same device. Address drift is therefore a queue that carries the
|
|
printer's NAME while printing somewhere else - the failure that is invisible
|
|
from the server and obvious to whoever is standing at the machine.
|
|
|
|
A queue with no port address (a non-TCP port, or a reporting host too old to
|
|
read one) cannot be compared on address, so only its driver is judged.
|
|
Comparing against a blank would report every such queue as drifted and the
|
|
view would be noise.
|
|
"""
|
|
fields = []
|
|
|
|
observedaddress = (row.portaddress or '').strip().lower()
|
|
addresses = want.get('addresses') or set()
|
|
if observedaddress and addresses and observedaddress not in addresses:
|
|
fields.append('portaddress')
|
|
|
|
observeddriver = (row.drivername or '').strip().lower()
|
|
wanteddriver = (want['drivername'] or '').strip().lower()
|
|
if observeddriver and wanteddriver and observeddriver != wanteddriver:
|
|
fields.append('drivername')
|
|
|
|
return fields
|
|
|
|
|
|
def _observed_entry(row=None, want=None, classification=None, printer=None,
|
|
asset=None, driftfields=None):
|
|
"""One row of the comparison, observed side and assigned side in one shape.
|
|
|
|
Both sides in every entry so a reviewer never has to join two lists: a
|
|
missing printer has no observed half, an unknown queue has no assigned half,
|
|
and everything in between carries what it has and nulls for what it lacks.
|
|
"""
|
|
return {
|
|
'classification': classification,
|
|
'queuename': (row.queuename if row is not None
|
|
else (want or {}).get('queuename')),
|
|
'drivername': row.drivername if row is not None else None,
|
|
'portname': row.portname if row is not None else None,
|
|
'portaddress': row.portaddress if row is not None else None,
|
|
'isdefault': bool(row.isdefault) if row is not None else False,
|
|
'isshared': bool(row.isshared) if row is not None else False,
|
|
'printerid': (printer.printerid if printer is not None
|
|
else (want or {}).get('printerid')),
|
|
'printerassetid': (printer.assetid if printer is not None
|
|
else (want or {}).get('printerassetid')),
|
|
'printername': ((asset.name or asset.assetnumber) if asset is not None
|
|
else (want or {}).get('printername')),
|
|
'expectedqueuename': (want or {}).get('queuename'),
|
|
'expectedportaddress': (want or {}).get('portaddress'),
|
|
'expecteddrivername': (want or {}).get('drivername'),
|
|
'isassigneddefault': bool((want or {}).get('isdefault')),
|
|
'inheritedfromassetid': (want or {}).get('inheritedfromassetid'),
|
|
'driftfields': driftfields or [],
|
|
}
|
|
|
|
|
|
def _observed_comparison(rows, expected, index):
|
|
"""Every observed queue classified, then the assigned printers nobody saw.
|
|
|
|
matching - assigned, present, installed the way the assignment says.
|
|
drifted - assigned and present, but on another port or another driver.
|
|
extra - a printer ShopDB knows, installed here without being assigned.
|
|
missing - assigned, and the host did not report it.
|
|
unknown - a queue that matches no printer in ShopDB.
|
|
"""
|
|
queues = []
|
|
seen = set()
|
|
|
|
for row in rows:
|
|
printerassetid = _match_observed_queue(row, index)
|
|
if printerassetid is None:
|
|
queues.append(_observed_entry(row=row, classification=_OBSERVED_UNKNOWN))
|
|
continue
|
|
|
|
printer, asset = index['printers'][printerassetid]
|
|
want = expected.get(printerassetid)
|
|
if want is None:
|
|
queues.append(_observed_entry(row=row, classification=_OBSERVED_EXTRA,
|
|
printer=printer, asset=asset))
|
|
continue
|
|
|
|
seen.add(printerassetid)
|
|
driftfields = _observed_driftfields(row, want)
|
|
queues.append(_observed_entry(
|
|
row=row, want=want, printer=printer, asset=asset,
|
|
driftfields=driftfields,
|
|
classification=_OBSERVED_DRIFTED if driftfields else _OBSERVED_MATCHING))
|
|
|
|
for printerassetid, want in expected.items():
|
|
if printerassetid not in seen:
|
|
queues.append(_observed_entry(want=want, classification=_OBSERVED_MISSING))
|
|
|
|
return queues
|
|
|
|
|
|
def _seed_candidate(queues):
|
|
"""The assignment a seed would write, and the queues it would refuse.
|
|
|
|
Matched queues only, in the order _observed_rows returns them. The observed
|
|
default carries over only when it matched a printer: a default outside the
|
|
set is rejected by the assignment writer anyway, and pointing a bay at a
|
|
queue it was never told to install fails on the bay with nothing in ShopDB
|
|
saying why.
|
|
"""
|
|
printerassetids = []
|
|
skipped = []
|
|
defaultprinterassetid = None
|
|
|
|
for entry in queues:
|
|
if entry['classification'] == _OBSERVED_MISSING:
|
|
continue
|
|
if entry['classification'] == _OBSERVED_UNKNOWN:
|
|
skipped.append({
|
|
'queuename': entry['queuename'],
|
|
'drivername': entry['drivername'],
|
|
'portname': entry['portname'],
|
|
'portaddress': entry['portaddress'],
|
|
'isdefault': entry['isdefault'],
|
|
})
|
|
continue
|
|
if entry['printerassetid'] not in printerassetids:
|
|
printerassetids.append(entry['printerassetid'])
|
|
if entry['isdefault'] and defaultprinterassetid is None:
|
|
defaultprinterassetid = entry['printerassetid']
|
|
|
|
return {
|
|
'printerassetids': printerassetids,
|
|
'defaultprinterassetid': defaultprinterassetid,
|
|
'skipped': skipped,
|
|
}
|
|
|
|
|
|
def _observed_summary(queues):
|
|
counts = {name: 0 for name in _OBSERVED_CLASSIFICATIONS}
|
|
for entry in queues:
|
|
counts[entry['classification']] += 1
|
|
return counts
|
|
|
|
|
|
def _observed_host_block(hostname, asset, index):
|
|
"""One host's report, classified against what that host would install.
|
|
|
|
The assigned side is resolved from the REPORTING PC, not from whatever asset
|
|
a caller asked about: a PC's own rows shadow the machine's, so a bay with an
|
|
override is converged when it matches the override. `source` says which of
|
|
the two the comparison used.
|
|
"""
|
|
rows = _observed_rows(hostname)
|
|
assignment = (resolve_asset_printers(asset) if asset is not None
|
|
else {'assignments': [], 'source': 'none'})
|
|
queues = _observed_comparison(rows, _assigned_expectations(assignment, index),
|
|
index)
|
|
|
|
# One report is written in one pass, so every row carries the same stamp.
|
|
observedat = rows[0].observedat if rows else None
|
|
|
|
return {
|
|
'hostname': hostname,
|
|
'assetid': asset.assetid if asset is not None else None,
|
|
'assetnumber': asset.assetnumber if asset is not None else None,
|
|
'source': assignment['source'],
|
|
'observedat': observedat.isoformat() if observedat else None,
|
|
'queues': queues,
|
|
'summary': _observed_summary(queues),
|
|
'seedcandidate': _seed_candidate(queues),
|
|
}
|
|
|
|
|
|
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('printers.view')
|
|
def observed_for_host(hostname: str):
|
|
"""What a host last reported, each queue judged against what it is assigned.
|
|
|
|
The mirror of for-host: that endpoint says what this bay SHOULD have, this
|
|
one says what it told us it DOES have, and puts the two side by side. Read
|
|
only - nothing here changes an assignment, however wrong the bay looks.
|
|
|
|
404 only when the hostname means nothing here: no report and no computer. A
|
|
known PC that has never reported is an empty queue list, and a report from a
|
|
host with no computer record still comes back - everything on it is extra or
|
|
unknown, which is exactly the answer a technician needs.
|
|
|
|
`seedcandidate` is a preview of what POST
|
|
/api/printers/assignments/seed-from-observed would write from this report.
|
|
"""
|
|
try:
|
|
found = _computer_by_hostname(hostname)
|
|
except ImportError:
|
|
# No computers plugin, so no hostname -> asset resolution and no
|
|
# assigned side. The report itself is still worth returning.
|
|
found = None
|
|
|
|
rows = _observed_rows(hostname)
|
|
if not rows and not found:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'No printer report and no computer for hostname {hostname}',
|
|
http_code=404)
|
|
|
|
computer, asset = found if found else (None, None)
|
|
known = (computer.hostname if computer is not None
|
|
else (rows[0].hostname if rows else hostname))
|
|
return success_response(_observed_host_block(known, asset, _printer_match_index()))
|
|
|
|
|
|
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('printers.view')
|
|
def observed_for_asset(asset_id: int):
|
|
"""The same comparison, reached from an asset page: one block per host.
|
|
|
|
Assigned state lives on the MACHINE and observed state is reported by the
|
|
PCs, so a machine answers with a block for each PC that controls it. Blocks
|
|
rather than one merged list because a dualpath pair or a part marker
|
|
legitimately puts two PCs on one machine, and the only actionable thing
|
|
about drift is which box to walk to.
|
|
|
|
An asset nothing reports for - a machine with no PC, or a PC with no
|
|
computer record - is an empty `hosts` list and a 200. This hangs off the
|
|
asset page, and on the day it ships most bays have not reported yet.
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
if not asset or not asset.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
|
|
hosts = []
|
|
seen = set()
|
|
own = _own_hostname(asset)
|
|
if own:
|
|
hosts.append((own, asset))
|
|
seen.add(own.lower())
|
|
for hostname, pcasset in _controlling_computers(asset_id):
|
|
if hostname.lower() not in seen:
|
|
seen.add(hostname.lower())
|
|
hosts.append((hostname, pcasset))
|
|
|
|
index = _printer_match_index()
|
|
return success_response({
|
|
'assetid': asset_id,
|
|
'assetnumber': asset.assetnumber,
|
|
'hosts': [_observed_host_block(hostname, pcasset, index)
|
|
for hostname, pcasset in hosts],
|
|
})
|
|
|
|
|
|
def _controlling_computers(assetid):
|
|
"""(hostname, PC asset) for the active PCs that control this asset.
|
|
|
|
Observed state is reported by the PC and the assignment belongs to the
|
|
machine, so both the comparison and a seed onto a machine have to cross the
|
|
controls edge. Read incoming here (PC -> machine) because the machine is the
|
|
asset being asked about, which is the same edge resolve_asset_printers walks
|
|
outgoing. Oldest edge first, so two PCs on one machine list in a fixed
|
|
order.
|
|
"""
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
except ImportError:
|
|
return []
|
|
|
|
typeids = _relationship_typeids(_CONTROLS)[_CONTROLS]
|
|
if not typeids:
|
|
return []
|
|
|
|
rows = (db.session.query(Computer.hostname, Asset)
|
|
.select_from(AssetRelationship)
|
|
.join(Computer, Computer.assetid == AssetRelationship.sourceassetid)
|
|
.join(Asset, Asset.assetid == Computer.assetid)
|
|
.filter(AssetRelationship.targetassetid == assetid,
|
|
AssetRelationship.relationshiptypeid.in_(typeids),
|
|
AssetRelationship.isactive == True,
|
|
Asset.isactive == True)
|
|
.order_by(AssetRelationship.relationshipid)
|
|
.all())
|
|
|
|
controllers = []
|
|
seen = set()
|
|
for hostname, pcasset in rows:
|
|
key = (hostname or '').strip().lower()
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
controllers.append((hostname, pcasset))
|
|
return controllers
|
|
|
|
|
|
def _own_hostname(asset):
|
|
"""The asset's own hostname when it is a PC, else None."""
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
except ImportError:
|
|
return None
|
|
|
|
computer = Computer.query.filter_by(assetid=asset.assetid).first()
|
|
if computer is None or not computer.hostname:
|
|
return None
|
|
return computer.hostname
|
|
|
|
|
|
def _seed_source_hostnames(asset):
|
|
"""Hosts whose report could seed this asset: its own, then its controllers.
|
|
|
|
Its own first because seeding a PC from a different PC's report is never
|
|
what was meant; the controllers because the normal target is the MACHINE,
|
|
which reports nothing itself.
|
|
"""
|
|
hostnames = []
|
|
own = _own_hostname(asset)
|
|
if own:
|
|
hostnames.append(own)
|
|
|
|
for hostname, _pcasset in _controlling_computers(asset.assetid):
|
|
if hostname.lower() not in [name.lower() for name in hostnames]:
|
|
hostnames.append(hostname)
|
|
return hostnames
|
|
|
|
|
|
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
|
|
methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def seed_assignment_from_observed(asset_id: int):
|
|
"""Write what a PC observed as the assignment of the asset in the path.
|
|
|
|
THE ONE PATH FROM OBSERVED TO ASSIGNED, and a person has to ask for it. No
|
|
collector, no cycle and no background job reaches this route: a bay that
|
|
installed the wrong printer must never be able to make itself right by
|
|
reporting it. The reviewer reads the comparison (GET
|
|
/api/printers/observed/<hostname>, or /observed/for-asset/<id> from an asset
|
|
page), agrees with it, and posts here.
|
|
|
|
Normally posted against the MACHINE, so the assignment survives a reimage
|
|
and follows the bay (see resolve_asset_printers); posting it against the PC
|
|
works and is warned about, because the PC's own rows then shadow the
|
|
machine's for good.
|
|
|
|
Body, all optional:
|
|
{"hostname": "PC01", which report to seed from. Omitted, the asset's
|
|
own hostname or its single controlling PC.
|
|
"allowunmatched": false} proceed when some queue matches no printer.
|
|
|
|
Refuses rather than guesses:
|
|
409 when more than one controlling PC has reported - which bay is right is
|
|
not something this endpoint can know.
|
|
409 when any queue matches no printer, listing every one of them, unless
|
|
allowunmatched says to seed the rest anyway.
|
|
400 when nothing matched, because writing the empty set would silently
|
|
unassign the asset.
|
|
Nothing is written on any of those; the assignment is left exactly as found.
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
if not asset or not asset.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
hostname = (data.get('hostname') or '').strip()
|
|
allowunmatched = bool(data.get('allowunmatched'))
|
|
|
|
if hostname:
|
|
rows = _observed_rows(hostname)
|
|
if not rows:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'No printer report from hostname {hostname}',
|
|
http_code=404)
|
|
else:
|
|
reporting = [name for name in _seed_source_hostnames(asset)
|
|
if _observed_rows(name)]
|
|
if not reporting:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
'No host has reported printers for this asset - name one with '
|
|
'{"hostname": "..."}',
|
|
http_code=404)
|
|
if len(reporting) > 1:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'Several hosts report printers for this asset - name the one to '
|
|
'seed from',
|
|
details={'hostnames': reporting},
|
|
http_code=409)
|
|
hostname = reporting[0]
|
|
rows = _observed_rows(hostname)
|
|
|
|
index = _printer_match_index()
|
|
# Classified with no assigned side: seeding asks what each queue IS, not
|
|
# whether the asset already has it. The reconcile below is the whole set.
|
|
queues = _observed_comparison(rows, {}, index)
|
|
candidate = _seed_candidate(queues)
|
|
|
|
if candidate['skipped'] and not allowunmatched:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'{0} observed queue(s) match no printer in ShopDB. Add them as '
|
|
'printer assets, or repost with allowunmatched to seed the '
|
|
'rest.'.format(len(candidate['skipped'])),
|
|
details={
|
|
'hostname': hostname,
|
|
'skipped': candidate['skipped'],
|
|
'printerassetids': candidate['printerassetids'],
|
|
},
|
|
http_code=409)
|
|
|
|
if not candidate['printerassetids']:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Nothing to seed: no queue reported by {hostname} matches a printer '
|
|
'in ShopDB',
|
|
details={'hostname': hostname, 'skipped': candidate['skipped']})
|
|
|
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
|
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
|
return error_response(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
'Relationship types are not seeded - run: flask seed reference-data',
|
|
http_code=500)
|
|
|
|
warnings = []
|
|
observeddefault = next((entry for entry in queues if entry['isdefault']), None)
|
|
if observeddefault is not None and candidate['defaultprinterassetid'] is None:
|
|
warnings.append(
|
|
'Default queue "{0}" matches no printer in ShopDB; no default '
|
|
'assigned'.format(observeddefault['queuename']))
|
|
elif observeddefault is None:
|
|
warnings.append(f'{hostname} reported no default printer; no default assigned')
|
|
if candidate['skipped']:
|
|
warnings.append('{0} unmatched queue(s) were not assigned'.format(
|
|
len(candidate['skipped'])))
|
|
if _outgoing_rows(asset_id, typeids[_CONTROLS]):
|
|
# Own rows shadow rather than merge, so seeding the PC of a bay quietly
|
|
# takes that bay off the machine's assignment for good.
|
|
warnings.append(
|
|
'This asset controls another asset: its own printers now shadow the '
|
|
'assignment of the machine it controls')
|
|
|
|
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
|
typeids[_USES_PRINTER], candidate['printerassetids'])
|
|
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
|
typeids[_DEFAULT_PRINTER],
|
|
[candidate['defaultprinterassetid']]
|
|
if candidate['defaultprinterassetid'] is not None else [])
|
|
db.session.commit()
|
|
|
|
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
|
logger.info('Seeded printer assignment for asset %s from %s: %d printer(s), '
|
|
'%d skipped', asset_id, hostname, len(printerassetids),
|
|
len(candidate['skipped']))
|
|
return success_response({
|
|
'assetid': asset_id,
|
|
'seededfromhostname': hostname,
|
|
'printerassetids': printerassetids,
|
|
'defaultprinterassetid': defaultassetid,
|
|
'skipped': candidate['skipped'],
|
|
'warnings': warnings,
|
|
}, message='Printer assignment seeded from observed state')
|
|
|
|
|
|
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_printer(printer_id: int):
|
|
"""Get a single printer with full details."""
|
|
printer = db.session.get(Printer, printer_id)
|
|
|
|
if not printer:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer with ID {printer_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
result = printer.asset.to_dict() if printer.asset else {}
|
|
result['printer'] = printer.to_dict()
|
|
|
|
# Add communications
|
|
if printer.asset:
|
|
comms = Communication.query.filter_by(assetid=printer.asset.assetid).all()
|
|
result['communications'] = [c.to_dict() for c in comms]
|
|
|
|
# Attach active drivers that match this printer's model
|
|
if printer.modelnumberid:
|
|
drivers = PrinterDriver.query.filter_by(
|
|
modelnumberid=printer.modelnumberid, isactive=True
|
|
).order_by(PrinterDriver.name).all()
|
|
result['drivers'] = [d.to_dict() for d in drivers]
|
|
else:
|
|
result['drivers'] = []
|
|
|
|
return success_response(result)
|
|
|
|
|
|
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_printer_by_asset(asset_id: int):
|
|
"""Get printer data by asset ID."""
|
|
printer = Printer.query.filter_by(assetid=asset_id).first()
|
|
|
|
if not printer:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer for asset {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
result = printer.asset.to_dict() if printer.asset else {}
|
|
result['printer'] = printer.to_dict()
|
|
|
|
return success_response(result)
|
|
|
|
|
|
@printers_asset_bp.route('', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.create')
|
|
def create_printer():
|
|
"""
|
|
Create new printer (creates both Asset and Printer records).
|
|
|
|
Required fields:
|
|
- assetnumber: Business identifier
|
|
|
|
Optional fields:
|
|
- name, serialnumber, statusid, locationid, businessunitid
|
|
- printertypeid, vendorid, modelnumberid, hostname
|
|
- windowsname, sharename, iscsf, installpath, pin
|
|
- iscolor, isduplex, isnetwork
|
|
- 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 printer asset type
|
|
printer_type = AssetType.query.filter_by(assettype='printer').first()
|
|
if not printer_type:
|
|
return error_response(
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
'Printer 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'),
|
|
serialnumber=data.get('serialnumber'),
|
|
gaugelabreference=data.get('gaugelabreference'),
|
|
maintenancereference=data.get('maintenancereference'),
|
|
assettypeid=printer_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 printer extension
|
|
printer = Printer(
|
|
assetid=asset.assetid,
|
|
printertypeid=data.get('printertypeid'),
|
|
vendorid=data.get('vendorid'),
|
|
modelnumberid=data.get('modelnumberid'),
|
|
hostname=data.get('hostname'),
|
|
windowsname=data.get('windowsname'),
|
|
sharename=data.get('sharename'),
|
|
iscsf=data.get('iscsf', False),
|
|
installpath=data.get('installpath'),
|
|
pin=data.get('pin'),
|
|
iscolor=data.get('iscolor', False),
|
|
isduplex=data.get('isduplex', False),
|
|
isnetwork=data.get('isnetwork', True)
|
|
)
|
|
|
|
db.session.add(printer)
|
|
|
|
# Create communication record if IP provided
|
|
if data.get('ipaddress'):
|
|
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
|
if ip_comtype:
|
|
comm = Communication(
|
|
assetid=asset.assetid,
|
|
comtypeid=ip_comtype.comtypeid,
|
|
ipaddress=data['ipaddress'],
|
|
isprimary=True
|
|
)
|
|
db.session.add(comm)
|
|
|
|
# Preserve legacy timestamps in import mode (no-op otherwise)
|
|
apply_import_timestamps(asset, data)
|
|
|
|
db.session.commit()
|
|
|
|
result = asset.to_dict()
|
|
result['printer'] = printer.to_dict()
|
|
|
|
return success_response(result, message='Printer created', http_code=201)
|
|
|
|
|
|
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def update_printer(printer_id: int):
|
|
"""Update printer (both Asset and Printer records)."""
|
|
printer = db.session.get(Printer, printer_id)
|
|
|
|
if not printer:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer with ID {printer_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
asset = printer.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
|
|
)
|
|
|
|
# Update asset fields (optional identifiers gated per-type in Settings)
|
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
|
'maintenancereference', 'statusid',
|
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
|
'notes', 'isactive']
|
|
for key in asset_fields:
|
|
if key in data:
|
|
setattr(asset, key, data[key])
|
|
|
|
# Update printer fields
|
|
printer_fields = ['printertypeid', 'vendorid', 'modelnumberid', 'hostname',
|
|
'windowsname', 'sharename', 'iscsf', 'installpath', 'pin',
|
|
'iscolor', 'isduplex', 'isnetwork']
|
|
for key in printer_fields:
|
|
if key in data:
|
|
setattr(printer, key, data[key])
|
|
|
|
# Upsert the primary IP communication when an ipaddress is supplied, so a
|
|
# single PUT updates core, extension, and network in one call.
|
|
if 'ipaddress' in data:
|
|
ip = (data.get('ipaddress') or '').strip()
|
|
comm = Communication.query.filter_by(
|
|
assetid=asset.assetid, isprimary=True).first()
|
|
if ip:
|
|
if comm:
|
|
comm.ipaddress = ip
|
|
else:
|
|
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
|
if ip_comtype:
|
|
db.session.add(Communication(
|
|
assetid=asset.assetid,
|
|
comtypeid=ip_comtype.comtypeid,
|
|
ipaddress=ip,
|
|
isprimary=True,
|
|
))
|
|
elif comm:
|
|
comm.ipaddress = None
|
|
|
|
apply_import_timestamps(asset, data)
|
|
db.session.commit()
|
|
|
|
result = asset.to_dict()
|
|
result['printer'] = printer.to_dict()
|
|
|
|
return success_response(result, message='Printer updated')
|
|
|
|
|
|
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printers.delete')
|
|
def delete_printer(printer_id: int):
|
|
"""Delete (soft delete) printer."""
|
|
printer = db.session.get(Printer, printer_id)
|
|
|
|
if not printer:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer with ID {printer_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
# Soft delete the asset
|
|
printer.asset.isactive = False
|
|
db.session.commit()
|
|
|
|
return success_response(message='Printer deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Supply Levels (Zabbix Integration)
|
|
# =============================================================================
|
|
|
|
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_printer_supplies(printer_id: int):
|
|
"""Get supply levels from Zabbix (real-time lookup)."""
|
|
printer = db.session.get(Printer, printer_id)
|
|
|
|
if not printer:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404)
|
|
|
|
# Get IP address from communications
|
|
comm = Communication.query.filter_by(
|
|
assetid=printer.assetid,
|
|
isprimary=True
|
|
).first()
|
|
if not comm:
|
|
comm = Communication.query.filter_by(assetid=printer.assetid).first()
|
|
|
|
if not comm or not comm.ipaddress:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address')
|
|
|
|
service = ZabbixService()
|
|
if not service.isconfigured or not service.isreachable:
|
|
# fail soft when zabbix off or down
|
|
return success_response({
|
|
'ipaddress': comm.ipaddress,
|
|
'pingstatus': '-1',
|
|
'supplies': []
|
|
})
|
|
|
|
# vendor drives waste-cartridge rules; modelnumberid drives part lookup
|
|
vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None
|
|
|
|
raw_supplies = service.getsuppliesbyip(comm.ipaddress) or []
|
|
supplies = [
|
|
_annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies
|
|
]
|
|
|
|
return success_response({
|
|
'ipaddress': comm.ipaddress,
|
|
'pingstatus': service.getpingstatus(comm.ipaddress),
|
|
'supplies': supplies
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Low Supplies
|
|
# =============================================================================
|
|
|
|
def _annotate_supply(supply, vendor_name, modelnumberid):
|
|
"""Add status, remaining percent, and part numbers to a raw supply dict.
|
|
|
|
Waste cartridge direction depends on vendor, so classification lives in
|
|
the supply_parts helper. Part numbers come from the modelsupplies table.
|
|
"""
|
|
level = supply.get('level', 0)
|
|
name = supply.get('name', 'Unknown')
|
|
supplytype = derivesupplytype(name)
|
|
color = derivecolor(name, supply.get('color'))
|
|
cls = classifysupply(level, name, vendor_name)
|
|
return {
|
|
'name': name,
|
|
'level': level,
|
|
'color': color,
|
|
'supplytype': supplytype,
|
|
'status': cls['status'],
|
|
'remaining': cls['remaining'],
|
|
'iswaste': cls['iswaste'],
|
|
'isdrum': cls['isdrum'],
|
|
'partnumbers': lookupsupplies(modelnumberid, color, supplytype),
|
|
}
|
|
|
|
|
|
def _get_low_supplies_data():
|
|
"""Build low supplies data (cached for 5 minutes)."""
|
|
cached = cache.get('printers_low_supplies')
|
|
if cached is not None:
|
|
return cached
|
|
|
|
service = ZabbixService()
|
|
if not service.isconfigured or not service.isreachable:
|
|
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
|
|
|
# active printers with an IP, with vendor and model for waste/part rules
|
|
rows = (
|
|
db.session.query(Printer, Asset, Communication, Vendor, Model)
|
|
.join(Asset, Asset.assetid == Printer.assetid)
|
|
.join(Communication, Communication.assetid == Asset.assetid)
|
|
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
|
|
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
|
|
.filter(Asset.isactive == True)
|
|
.filter(Communication.ipaddress.isnot(None))
|
|
.filter(Communication.ipaddress != '')
|
|
.all()
|
|
)
|
|
|
|
# dedupe by printer id (a printer may have several comms)
|
|
seen = set()
|
|
unique_printers = []
|
|
for printer, asset, comm, vendor, model in rows:
|
|
if printer.printerid not in seen:
|
|
seen.add(printer.printerid)
|
|
unique_printers.append((printer, asset, comm, vendor, model))
|
|
|
|
results = []
|
|
total_checked = 0
|
|
|
|
for printer, asset, comm, vendor, model in unique_printers:
|
|
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
|
|
if supplies is None:
|
|
continue
|
|
|
|
total_checked += 1
|
|
|
|
vendor_name = vendor.vendor if vendor else None
|
|
model_number = model.modelnumber if model else None
|
|
modelnumberid = model.modelnumberid if model else None
|
|
|
|
# ONLY the supplies that need attention. A printer reporting one empty
|
|
# black cartridge alongside three full colour ones was listing all four,
|
|
# so the reader had to find the problem inside the row rather than being
|
|
# shown it. The whole report exists to answer "what needs replacing".
|
|
annotated = []
|
|
has_low = False
|
|
for s in supplies:
|
|
item = _annotate_supply(s, vendor_name, modelnumberid)
|
|
if item['status'] == 'ok':
|
|
continue
|
|
has_low = True
|
|
annotated.append(item)
|
|
|
|
if has_low:
|
|
# location name for the report row
|
|
# Via the relationship, the way the printers list does it. The
|
|
# previous lookup went through db.session.get on locationid, and
|
|
# every row came back with no location even where one is set.
|
|
location_name = (asset.location.locationname
|
|
if asset.location else None)
|
|
|
|
results.append({
|
|
'printerid': printer.printerid,
|
|
'printername': asset.name or printer.hostname or '',
|
|
'assetnumber': asset.assetnumber or '',
|
|
'ipaddress': comm.ipaddress,
|
|
'vendor': vendor_name,
|
|
'model': model_number,
|
|
'location': location_name,
|
|
'mapx': asset.mapx,
|
|
'levelid': asset.levelid,
|
|
'mapy': asset.mapy,
|
|
'supplies': annotated
|
|
})
|
|
|
|
low_count = 0
|
|
critical_count = 0
|
|
for p in results:
|
|
has_critical = any(s['status'] == 'critical' for s in p['supplies'])
|
|
has_low = any(s['status'] == 'low' for s in p['supplies'])
|
|
if has_critical:
|
|
critical_count += 1
|
|
elif has_low:
|
|
low_count += 1
|
|
|
|
data = {
|
|
'printers': results,
|
|
'summary': {
|
|
'total_checked': total_checked,
|
|
'low': low_count,
|
|
'critical': critical_count
|
|
}
|
|
}
|
|
|
|
cache.set('printers_low_supplies', data, timeout=300)
|
|
return data
|
|
|
|
|
|
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def low_supplies():
|
|
"""Get printers with low or critical supply levels."""
|
|
data = _get_low_supplies_data()
|
|
return success_response(data)
|
|
|
|
|
|
@printers_asset_bp.route('/lookup', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def printer_lookup():
|
|
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
|
|
|
|
Zabbix uses this to jump straight to a printer record. Query with
|
|
?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id.
|
|
"""
|
|
ip = (request.args.get('ip') or '').strip()
|
|
fqdn = (request.args.get('fqdn') or '').strip()
|
|
lookup_value = ip or fqdn
|
|
|
|
if not lookup_value:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Provide ip or fqdn'
|
|
)
|
|
|
|
# match the IP against any active printer communication
|
|
row = (
|
|
db.session.query(Printer, Asset)
|
|
.join(Asset, Asset.assetid == Printer.assetid)
|
|
.join(Communication, Communication.assetid == Asset.assetid)
|
|
.filter(Asset.isactive == True)
|
|
.filter(Communication.ipaddress == lookup_value)
|
|
.first()
|
|
)
|
|
|
|
if not row:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Printer not found: {lookup_value}',
|
|
http_code=404
|
|
)
|
|
|
|
printer, asset = row
|
|
return success_response({
|
|
'printerid': printer.printerid,
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
'name': asset.name or printer.hostname,
|
|
})
|
|
|
|
|
|
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.create')
|
|
def refresh_supplies_cache():
|
|
"""Clear cached Zabbix supply data so the next read pulls fresh values.
|
|
|
|
Backs the toner report Refresh button (parity with adminclearcache.asp
|
|
type=zabbix).
|
|
"""
|
|
ZabbixService().clearcache()
|
|
return success_response(message='Supply cache cleared')
|
|
|
|
|
|
# =============================================================================
|
|
# Dashboard
|
|
# =============================================================================
|
|
|
|
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def dashboard_summary():
|
|
"""Get printer dashboard summary data."""
|
|
# Total active printers
|
|
total = db.session.query(Printer).join(Asset).filter(
|
|
Asset.isactive == True
|
|
).count()
|
|
|
|
# Count by printer type
|
|
by_type = db.session.query(
|
|
PrinterType.printertype,
|
|
db.func.count(Printer.printerid)
|
|
).join(Printer, Printer.printertypeid == PrinterType.printertypeid
|
|
).join(Asset, Asset.assetid == Printer.assetid
|
|
).filter(Asset.isactive == True
|
|
).group_by(PrinterType.printertype
|
|
).all()
|
|
|
|
# Count by vendor
|
|
by_vendor = db.session.query(
|
|
Vendor.vendor,
|
|
db.func.count(Printer.printerid)
|
|
).join(Printer, Printer.vendorid == Vendor.vendorid
|
|
).join(Asset, Asset.assetid == Printer.assetid
|
|
).filter(Asset.isactive == True
|
|
).group_by(Vendor.vendor
|
|
).all()
|
|
|
|
# Get real low/critical supply counts (skip if Zabbix not reachable)
|
|
low_count = 0
|
|
critical_count = 0
|
|
service = ZabbixService()
|
|
if service.isconfigured and service.isreachable:
|
|
try:
|
|
supply_data = _get_low_supplies_data()
|
|
low_count = supply_data['summary']['low']
|
|
critical_count = supply_data['summary']['critical']
|
|
except Exception as e:
|
|
logger.warning(f"Could not fetch supply data for dashboard: {e}")
|
|
|
|
return success_response({
|
|
'total': total,
|
|
'totalprinters': total,
|
|
'online': total,
|
|
'lowsupplies': low_count,
|
|
'criticalsupplies': critical_count,
|
|
'bytype': [{'type': t, 'count': c} for t, c in by_type],
|
|
'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
|
|
})
|
|
|
|
|
|
# =============================================================================
|
|
# Model Supplies (data-driven toner/drum/waste part numbers)
|
|
# =============================================================================
|
|
|
|
def _validate_supply_payload(data):
|
|
"""Return an error message if the supply payload is invalid, else None."""
|
|
if not data:
|
|
return 'No data provided'
|
|
if not data.get('partnumber'):
|
|
return 'partnumber is required'
|
|
supplytype = data.get('supplytype', 'toner')
|
|
if supplytype not in SUPPLY_TYPES:
|
|
return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}"
|
|
color = data.get('color', 'none')
|
|
if color not in SUPPLY_COLORS:
|
|
return f"color must be one of {', '.join(SUPPLY_COLORS)}"
|
|
capacitytier = data.get('capacitytier', 'standard')
|
|
if capacitytier not in CAPACITY_TIERS:
|
|
return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}"
|
|
return None
|
|
|
|
|
|
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def supplies_meta():
|
|
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
|
|
return success_response({
|
|
'supplytypes': list(SUPPLY_TYPES),
|
|
'colors': list(SUPPLY_COLORS),
|
|
'capacitytiers': list(CAPACITY_TIERS),
|
|
})
|
|
|
|
|
|
@printers_asset_bp.route('/models', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_supply_models():
|
|
"""List models with a supply count, for the supply-management picker.
|
|
|
|
Query parameters:
|
|
- search: filter by model number
|
|
- vendor_id: filter by vendor
|
|
- withsupplies: 'true' to only return models that already have supplies
|
|
"""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount')
|
|
query = (
|
|
db.session.query(Model, Vendor.vendor, supplycount)
|
|
.outerjoin(Vendor, Vendor.vendorid == Model.vendorid)
|
|
.outerjoin(ModelSupply, db.and_(
|
|
ModelSupply.modelnumberid == Model.modelnumberid,
|
|
ModelSupply.isactive == True,
|
|
))
|
|
.group_by(Model.modelnumberid, Vendor.vendor)
|
|
)
|
|
|
|
# Toner/drum/waste only apply to printers, so restrict the picker to
|
|
# printer models: those attached to a printer asset, or those that already
|
|
# carry supply mappings. Keeps machine/controller models out of the list.
|
|
printer_model_ids = (
|
|
db.session.query(Printer.modelnumberid)
|
|
.filter(Printer.modelnumberid.isnot(None))
|
|
)
|
|
supply_model_ids = db.session.query(ModelSupply.modelnumberid)
|
|
query = query.filter(db.or_(
|
|
Model.modelnumberid.in_(printer_model_ids),
|
|
Model.modelnumberid.in_(supply_model_ids),
|
|
))
|
|
|
|
if search := request.args.get('search'):
|
|
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
|
|
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
|
query = query.filter(Model.vendorid == int(vendor_id))
|
|
if request.args.get('withsupplies', '').lower() == 'true':
|
|
query = query.having(supplycount > 0)
|
|
|
|
query = query.order_by(Model.modelnumber)
|
|
|
|
total = query.count()
|
|
rows = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
|
|
data = [{
|
|
'modelnumberid': model.modelnumberid,
|
|
'modelnumber': model.modelnumber,
|
|
'vendor': vendor,
|
|
'vendorid': model.vendorid,
|
|
'supplycount': count,
|
|
} for model, vendor, count in rows]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_model_supplies(modelnumberid: int):
|
|
"""List all supplies mapped to a model."""
|
|
model = db.session.get(Model, modelnumberid)
|
|
if not model:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
|
|
|
|
supplies = (
|
|
ModelSupply.query
|
|
.filter_by(modelnumberid=modelnumberid, isactive=True)
|
|
.order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier)
|
|
.all()
|
|
)
|
|
return success_response({
|
|
'modelnumberid': modelnumberid,
|
|
'modelnumber': model.modelnumber,
|
|
'supplies': [s.to_dict() for s in supplies],
|
|
})
|
|
|
|
|
|
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printers.create')
|
|
def create_model_supply(modelnumberid: int):
|
|
"""Add a supply to a model."""
|
|
model = db.session.get(Model, modelnumberid)
|
|
if not model:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404)
|
|
|
|
data = request.get_json()
|
|
message = _validate_supply_payload(data)
|
|
if message:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, message)
|
|
|
|
existing = ModelSupply.query.filter_by(
|
|
modelnumberid=modelnumberid,
|
|
partnumber=data['partnumber'],
|
|
).first()
|
|
if existing:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Part number '{data['partnumber']}' already mapped to this model",
|
|
http_code=409,
|
|
)
|
|
|
|
supply = ModelSupply(
|
|
modelnumberid=modelnumberid,
|
|
supplytype=data.get('supplytype', 'toner'),
|
|
color=data.get('color', 'none'),
|
|
capacitytier=data.get('capacitytier', 'standard'),
|
|
partnumber=data['partnumber'],
|
|
marketingname=data.get('marketingname'),
|
|
pageyield=data.get('pageyield'),
|
|
notes=data.get('notes'),
|
|
)
|
|
db.session.add(supply)
|
|
db.session.commit()
|
|
|
|
return success_response(supply.to_dict(), message='Supply added', http_code=201)
|
|
|
|
|
|
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('printers.edit')
|
|
def update_model_supply(modelsupplyid: int):
|
|
"""Update a model supply."""
|
|
supply = db.session.get(ModelSupply, modelsupplyid)
|
|
if not supply:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
# validate only the fields present
|
|
merged = {
|
|
'partnumber': data.get('partnumber', supply.partnumber),
|
|
'supplytype': data.get('supplytype', supply.supplytype),
|
|
'color': data.get('color', supply.color),
|
|
'capacitytier': data.get('capacitytier', supply.capacitytier),
|
|
}
|
|
message = _validate_supply_payload(merged)
|
|
if message:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, message)
|
|
|
|
if 'partnumber' in data and data['partnumber'] != supply.partnumber:
|
|
clash = ModelSupply.query.filter_by(
|
|
modelnumberid=supply.modelnumberid,
|
|
partnumber=data['partnumber'],
|
|
).first()
|
|
if clash:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Part number '{data['partnumber']}' already mapped to this model",
|
|
http_code=409,
|
|
)
|
|
|
|
for field in ('supplytype', 'color', 'capacitytier', 'partnumber',
|
|
'marketingname', 'pageyield', 'notes'):
|
|
if field in data:
|
|
setattr(supply, field, data[field])
|
|
|
|
db.session.commit()
|
|
return success_response(supply.to_dict(), message='Supply updated')
|
|
|
|
|
|
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('printers.delete')
|
|
def delete_model_supply(modelsupplyid: int):
|
|
"""Delete a model supply."""
|
|
supply = db.session.get(ModelSupply, modelsupplyid)
|
|
if not supply:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404)
|
|
|
|
db.session.delete(supply)
|
|
db.session.commit()
|
|
return success_response(message='Supply deleted')
|
|
|
|
|
|
def _shortsupplyname(name):
|
|
"""'Black Toner Level' -> 'Black'. The card has one line per printer, and
|
|
the words Toner and Level carry no information when every row is a toner
|
|
level."""
|
|
text = (name or 'supply').strip()
|
|
for noise in (' Cartridge Level', ' Toner Level', ' Level', ' Cartridge'):
|
|
if text.endswith(noise):
|
|
text = text[:-len(noise)]
|
|
break
|
|
return text or 'supply'
|
|
|
|
|
|
def _reordertip(supply):
|
|
"""What to order, for the chip's tooltip.
|
|
|
|
A percentage says a cartridge is nearly out; the part number says what to
|
|
buy, which is the next thing someone needs and today means opening the
|
|
printer's page to find it. Every capacity tier is listed, because the
|
|
report has always shown all reorder options.
|
|
"""
|
|
parts = supply.get('partnumbers') or []
|
|
if not parts:
|
|
return 'No part number on file for this model'
|
|
return ', '.join(
|
|
'{}{}'.format(part['partnumber'],
|
|
' ({})'.format(part['capacitytier'])
|
|
if part.get('capacitytier') else '')
|
|
for part in parts)
|
|
|
|
|
|
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('printers.view')
|
|
def dashboard_supplies():
|
|
"""Printers needing a cartridge, flattened to one row per printer.
|
|
|
|
Reuses the existing low-supplies query and its five-minute cache, so the
|
|
card costs nothing extra: a Zabbix round-trip per printer on every dashboard
|
|
load would make this the slowest page in the app.
|
|
|
|
Critical first, then low. A printer with several depleted cartridges appears
|
|
once, listing them - a row per cartridge would report one printer three
|
|
times and read as three problems.
|
|
"""
|
|
data = _get_low_supplies_data()
|
|
|
|
threshold = 5
|
|
setting = Setting.query.filter_by(key='printers_dashboardpercent').first()
|
|
if setting and (setting.value or '').strip():
|
|
try:
|
|
threshold = int(setting.value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
rows = []
|
|
for printer in data.get('printers', []):
|
|
# The CARD is tighter than the report. The report lists anything the
|
|
# thresholds call low, which is the right scope for planning an order;
|
|
# the dashboard is asking what to walk out and change today, and a
|
|
# cartridge at 18% is not that. Anything at or below the threshold.
|
|
depleted = [supply for supply in printer['supplies']
|
|
if isinstance(supply.get('remaining'), (int, float))
|
|
and supply['remaining'] <= threshold]
|
|
if not depleted:
|
|
continue
|
|
depleted.sort(key=lambda supply: supply['remaining'])
|
|
rows.append({
|
|
'printerid': printer['printerid'],
|
|
'printername': printer['printername'] or printer['assetnumber'],
|
|
'location': printer['location'] or 'No location set',
|
|
# Coordinates for the hover preview. Either may be None - a
|
|
# printer never placed on the floor plan still belongs on the
|
|
# card, it just has nothing to preview.
|
|
'mapx': printer.get('mapx'),
|
|
'mapy': printer.get('mapy'),
|
|
# The level those pixels belong to (ADR-017). Without it the hover
|
|
# preview cannot draw the marker and says so, which is what the
|
|
# dashboard card and the toner report were both doing.
|
|
'levelid': printer.get('levelid'),
|
|
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
|
'supplies': [{
|
|
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
|
supply.get('remaining')),
|
|
'title': _reordertip(supply),
|
|
'level': supply.get('status'),
|
|
} for supply in depleted],
|
|
})
|
|
|
|
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 (
|
|
ORDER_HORIZON_DAYS, analyse, band, orderlist,
|
|
)
|
|
from ..services.supply_parts import (
|
|
derivecolor, derivesupplytype, lookupsupplies,
|
|
)
|
|
|
|
try:
|
|
days = max(1, min(365, int(request.args.get('days', 90))))
|
|
except (TypeError, ValueError):
|
|
days = 90
|
|
|
|
empty = {
|
|
'cartridges': [], 'unestimated': [], 'orderlist': [], 'days': days,
|
|
'horizondays': ORDER_HORIZON_DAYS,
|
|
}
|
|
|
|
service = ZabbixService()
|
|
if not service.isconfigured or not service.isreachable:
|
|
return success_response(dict(
|
|
empty, available=False,
|
|
reason='Zabbix is not configured or not reachable'))
|
|
|
|
rows = (
|
|
db.session.query(Printer, Asset, Communication, Vendor, Model)
|
|
.join(Asset, Asset.assetid == Printer.assetid)
|
|
.join(Communication, Communication.assetid == Asset.assetid)
|
|
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
|
|
.outerjoin(Model, Model.modelnumberid == Printer.modelnumberid)
|
|
.filter(Asset.isactive == True,
|
|
Communication.ipaddress.isnot(None),
|
|
Communication.ipaddress != '')
|
|
.all()
|
|
)
|
|
|
|
seen = set()
|
|
cartridges, unestimated = [], []
|
|
|
|
for printer, asset, comm, vendor, model 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.getlevelhistory(itemids, days=days)
|
|
|
|
# The cartridge is what gets ordered, so the cartridge is the row.
|
|
# Nesting supplies under a printer made the reader unpack a printer to
|
|
# find out whether anything on it needed doing.
|
|
for supply in supplies:
|
|
points = history.get(str(supply.get('itemid')), [])
|
|
# The live read is the level the report shows, so it is also the
|
|
# level the countdown is computed from - history lags a poll, and a
|
|
# row whose level and days-left came from different moments reads
|
|
# as broken.
|
|
detail = analyse(points, currentlevel=supply.get('level'))
|
|
|
|
name = supply.get('name') or 'Unknown'
|
|
color = derivecolor(name, supply.get('color'))
|
|
supplytype = derivesupplytype(name)
|
|
detail.update({
|
|
'name': name,
|
|
'color': color,
|
|
'supplytype': supplytype,
|
|
'partnumbers': lookupsupplies(
|
|
model.modelnumberid if model else None, color, supplytype),
|
|
'printerid': printer.printerid,
|
|
'printername': asset.name or printer.hostname or '',
|
|
'assetnumber': asset.assetnumber or '',
|
|
'ipaddress': comm.ipaddress,
|
|
'vendor': vendor.vendor if vendor else None,
|
|
'model': model.modelnumber if model else None,
|
|
'band': band(detail['daysleft']),
|
|
})
|
|
# The chart series is per cartridge and nothing on this report
|
|
# draws it yet; sending it multiplies the payload for nothing.
|
|
detail.pop('points', None)
|
|
(cartridges if detail['band'] else unestimated).append(detail)
|
|
|
|
# Soonest first: the point of the report is what to order next.
|
|
cartridges.sort(key=lambda c: (c['daysleft'], c['printername']))
|
|
unestimated.sort(key=lambda c: (c['printername'], c['name']))
|
|
|
|
counts = {name: 0 for name in ('empty', 'soon', 'month', 'later')}
|
|
for cartridge in cartridges:
|
|
counts[cartridge['band']] += 1
|
|
toorder = orderlist(cartridges)
|
|
|
|
return success_response(dict(
|
|
empty,
|
|
cartridges=cartridges,
|
|
unestimated=unestimated,
|
|
orderlist=toorder,
|
|
available=True,
|
|
summary={
|
|
'bands': counts,
|
|
'estimated': len(cartridges),
|
|
'unestimated': len(unestimated),
|
|
'replacements': sum(c['replacements']
|
|
for c in cartridges + unestimated),
|
|
'toorder': sum(item['quantity'] for item in toorder),
|
|
},
|
|
))
|