2786 lines
109 KiB
Python
2786 lines
109 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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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
|
|
|
|
# An UPDATE may not blank the asset number. Create validates it and the
|
|
# column is NOT NULL, but the conflict check below only runs when the value
|
|
# DIFFERS, and '' never collides with anything - so a payload carrying an
|
|
# empty assetnumber wrote it straight through. A form that loaded blank
|
|
# (a failed reference load, a partial fetch) then saved the blank over a
|
|
# real record.
|
|
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'assetnumber cannot be empty')
|
|
|
|
# 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'])
|
|
@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.
|
|
"""
|
|
# .get('level') with no default: a supply with no reading carries None, and
|
|
# a 0 default would put back the very substitution this reports around.
|
|
level = supply.get('level')
|
|
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,
|
|
'lastseen': supply.get('lastseen'),
|
|
'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
|
|
unknown_printers = 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
|
|
has_unknown = False
|
|
for s in supplies:
|
|
item = _annotate_supply(s, vendor_name, modelnumberid)
|
|
if item['status'] == 'ok':
|
|
continue
|
|
# A supply nobody could read is not a supply that needs replacing,
|
|
# so it does not join the list of things to act on. It is counted
|
|
# instead: a printer that has gone quiet should be visible as that,
|
|
# not disappear from the report and not masquerade as empty.
|
|
if item['status'] == 'unknown':
|
|
has_unknown = True
|
|
continue
|
|
has_low = True
|
|
annotated.append(item)
|
|
|
|
if has_unknown:
|
|
unknown_printers += 1
|
|
|
|
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,
|
|
# Printers with at least one unreadable supply. They are NOT in
|
|
# 'printers' above - nothing about them needs ordering - but a
|
|
# count that only ever went up when a cartridge ran low would hide
|
|
# a fleet quietly going dark.
|
|
'unknown': unknown_printers
|
|
}
|
|
}
|
|
|
|
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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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),
|
|
},
|
|
))
|