From 0d40780f534559f89d5935727e13e427436f9747 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 29 Jul 2026 12:38:51 -0400 Subject: [PATCH] printers: printer installer map + install-batch endpoint Rebuilds the classic printer-installer feature: pick printers on the shopfloor map, download a .bat that installs them. Backend (asset_routes.py): GET /api/printers/install-batch?printerids=1,2,3 returns a .bat attachment. Groups printers the way the classic installprinter.asp did - HP/Xerox via the universal PrinterInstaller.exe /PRINTER="a,b,c", printers with a .exe installpath via that installer /SILENT, and anything else (no installpath, or a .zip) listed for manual install instead of being run blindly. Download URLs derive from the site_base_url setting + the IIS-served /installers folder (no hardcoded host). Reuses the existing install-list query shape. Frontend: PrinterInstallerMap.vue - full-screen Leaflet shopfloor map (reuses mapConfig), a marker per network printer at its mapx/mapy, click to toggle-select, sidebar with the selection + an Install button that downloads the batch. Toplevel route /printer-installer, printersApi.installList(), and an Installer Map button on the printers list. Tests: install-batch grouping (universal/specific/manual) + requires-ids. --- frontend/src/api/index.js | 4 + plugins/printers/api/asset_routes.py | 179 ++++++++++++- plugins/printers/frontend/routes.js | 6 + .../frontend/views/PrinterInstallerMap.vue | 238 ++++++++++++++++++ .../printers/frontend/views/PrintersList.vue | 1 + .../test_printer_install_batch.py | 77 ++++++ 6 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 plugins/printers/frontend/views/PrinterInstallerMap.vue create mode 100644 tests/test_plugins/test_printer_install_batch.py diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 9888472..b488b84 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -318,6 +318,10 @@ export const printersApi = { dashboardSummary() { return api.get('/printers/dashboard/summary') }, + // Flat network-printer list (with mapx/mapy) for the installer map. + installList() { + return api.get('/printers/install-list') + }, drivers: { list(params = {}) { return api.get('/printers/drivers', { params }) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 8a95e98..d45401b 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1,11 +1,12 @@ """Printers API routes - new Asset-based architecture.""" import logging +import re -from flask import Blueprint, request +from flask import Blueprint, request, Response from flask_jwt_extended import jwt_required -from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query +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 from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS @@ -354,6 +355,180 @@ def printer_install_list(): 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 (already includes the /shopdb mount); fall back to + the request root so a site that never set it still produces a usable batch.""" + base = (Setting.get('site_base_url') or '').strip().rstrip('/') + if base: + return base + return request.url_root.rstrip('/') + + +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.vendor if printer.vendor else '').strip() + 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('pause') + 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(): diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index c89165a..90d3cea 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -66,6 +66,12 @@ export default [ ] export const toplevel = [ + { + path: '/printer-installer', + name: 'printer-installer-map', + component: () => import('./views/PrinterInstallerMap.vue'), + meta: { requiresAuth: true, plugin: 'printers' } + }, { path: '/print/printer-qr', name: 'print-printer-qr-batch', diff --git a/plugins/printers/frontend/views/PrinterInstallerMap.vue b/plugins/printers/frontend/views/PrinterInstallerMap.vue new file mode 100644 index 0000000..775bda2 --- /dev/null +++ b/plugins/printers/frontend/views/PrinterInstallerMap.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue index f267e17..7b3af3c 100644 --- a/plugins/printers/frontend/views/PrintersList.vue +++ b/plugins/printers/frontend/views/PrintersList.vue @@ -3,6 +3,7 @@