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 @@
+
+ Printer Installer
+ Click printers on the map to select, then install.
+