Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
5
plugins/printers/__init__.py
Normal file
5
plugins/printers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Printers plugin - extends machines with printer-specific functionality."""
|
||||
|
||||
from .plugin import PrintersPlugin
|
||||
|
||||
__all__ = ['PrintersPlugin']
|
||||
7
plugins/printers/api/__init__.py
Normal file
7
plugins/printers/api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Printers plugin API."""
|
||||
|
||||
from .asset_routes import printers_asset_bp # Asset-based API
|
||||
|
||||
__all__ = [
|
||||
'printers_asset_bp',
|
||||
]
|
||||
1156
plugins/printers/api/asset_routes.py
Normal file
1156
plugins/printers/api/asset_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
25
plugins/printers/manifest.json
Normal file
25
plugins/printers/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "printers",
|
||||
"version": "1.0.0",
|
||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/printers",
|
||||
"provides": {
|
||||
"machine_category": "Printer",
|
||||
"features": [
|
||||
"printer_extensions",
|
||||
"driver_management",
|
||||
"supply_tracking",
|
||||
"zabbix_integration",
|
||||
"qr_codes"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"zabbix_url": "",
|
||||
"zabbix_token": "",
|
||||
"supply_alert_threshold": 10,
|
||||
"default_driver_source": "internal"
|
||||
}
|
||||
}
|
||||
14
plugins/printers/migrations/env.py
Normal file
14
plugins/printers/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the printers plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_printers. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'printers'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/printers/migrations/script.py.mako
Normal file
24
plugins/printers/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
28
plugins/printers/migrations/versions/0001_printers_anchor.py
Normal file
28
plugins/printers/migrations/versions/0001_printers_anchor.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""printers plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_printers. From this
|
||||
anchor forward, new printers schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
20
plugins/printers/models/__init__.py
Normal file
20
plugins/printers/models/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Printers plugin models."""
|
||||
|
||||
from .printer import Printer, PrinterType # Asset-based models
|
||||
from .printer_driver import PrinterDriver
|
||||
from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||
ModelSupply,
|
||||
SUPPLY_TYPES,
|
||||
SUPPLY_COLORS,
|
||||
CAPACITY_TIERS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Printer',
|
||||
'PrinterType',
|
||||
'PrinterDriver',
|
||||
'ModelSupply',
|
||||
'SUPPLY_TYPES',
|
||||
'SUPPLY_COLORS',
|
||||
'CAPACITY_TIERS',
|
||||
]
|
||||
56
plugins/printers/models/model_supply.py
Normal file
56
plugins/printers/models/model_supply.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Model-to-supply mapping - data-driven toner/drum/waste part numbers.
|
||||
|
||||
Replaces the old hardcoded part-number table. Each row maps one printer
|
||||
model to one supply part (a toner of a given color and capacity tier, or a
|
||||
drum/waste/maintenance item). Lets new models and their toners be added
|
||||
through the API/UI without a code change.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
# allowed values, surfaced to the UI via the /supplies/meta endpoint
|
||||
SUPPLY_TYPES = ('toner', 'drum', 'waste', 'maintenance')
|
||||
SUPPLY_COLORS = ('black', 'cyan', 'magenta', 'yellow', 'none')
|
||||
CAPACITY_TIERS = ('standard', 'high', 'extrahigh', 'metered', 'dmo')
|
||||
|
||||
|
||||
class ModelSupply(BaseModel):
|
||||
"""One supply part belonging to one printer model."""
|
||||
__tablename__ = 'modelsupplies'
|
||||
|
||||
modelsupplyid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# toner, drum, waste, maintenance
|
||||
supplytype = db.Column(db.String(20), nullable=False, default='toner')
|
||||
# black, cyan, magenta, yellow, or none (drum/waste have no color)
|
||||
color = db.Column(db.String(20), nullable=False, default='none')
|
||||
# standard, high, extrahigh, metered, dmo
|
||||
capacitytier = db.Column(db.String(20), nullable=False, default='standard')
|
||||
|
||||
partnumber = db.Column(db.String(50), nullable=False)
|
||||
marketingname = db.Column(db.String(120))
|
||||
pageyield = db.Column(db.Integer, comment='Rated page yield at 5 percent coverage')
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
model = db.relationship('Model', backref='supplies')
|
||||
|
||||
# one part number per model, no duplicates
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('modelnumberid', 'partnumber', name='uq_modelsupply_part'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModelSupply {self.partnumber} ({self.color}/{self.capacitytier})>"
|
||||
|
||||
def to_dict(self):
|
||||
data = super().to_dict()
|
||||
if self.model:
|
||||
data['modelnumber'] = self.model.modelnumber
|
||||
return data
|
||||
123
plugins/printers/models/printer.py
Normal file
123
plugins/printers/models/printer.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Printer plugin models - new Asset-based architecture."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class PrinterType(BaseModel):
|
||||
"""
|
||||
Printer type classification.
|
||||
|
||||
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
|
||||
"""
|
||||
__tablename__ = 'printertypes'
|
||||
|
||||
printertypeid = db.Column(db.Integer, primary_key=True)
|
||||
printertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PrinterType {self.printertype}>"
|
||||
|
||||
|
||||
class Printer(BaseModel):
|
||||
"""
|
||||
Printer-specific extension data (new Asset architecture).
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores printer-specific fields like type, Windows name, share name, etc.
|
||||
"""
|
||||
__tablename__ = 'printers'
|
||||
|
||||
printerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Printer classification
|
||||
printertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printertypes.printertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Windows/Network naming
|
||||
windowsname = db.Column(
|
||||
db.String(255),
|
||||
comment='Windows printer name (e.g., \\\\server\\printer)'
|
||||
)
|
||||
sharename = db.Column(
|
||||
db.String(100),
|
||||
comment='CSF/share name'
|
||||
)
|
||||
|
||||
# Installation
|
||||
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
|
||||
installpath = db.Column(db.String(255), comment='Driver install path')
|
||||
|
||||
# Printer PIN (for secure print)
|
||||
pin = db.Column(db.String(20))
|
||||
|
||||
# Features
|
||||
iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
|
||||
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
|
||||
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('printer', uselist=False, lazy='joined')
|
||||
)
|
||||
printertype = db.relationship('PrinterType', backref='printers')
|
||||
vendor = db.relationship('Vendor', backref='printer_items')
|
||||
model = db.relationship('Model', backref='printer_items')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_printer_type', 'printertypeid'),
|
||||
db.Index('idx_printer_hostname', 'hostname'),
|
||||
db.Index('idx_printer_windowsname', 'windowsname'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Printer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.printertype:
|
||||
result['printertypename'] = self.printertype.printertype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
if self.model.imageurl:
|
||||
result['imageurl'] = self.model.imageurl
|
||||
|
||||
return result
|
||||
33
plugins/printers/models/printer_driver.py
Normal file
33
plugins/printers/models/printer_driver.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Printer driver: a named link (SMB path or HTTP URL) to driver files."""
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class PrinterDriver(db.Model):
|
||||
__tablename__ = 'printerdrivers'
|
||||
|
||||
driverid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
||||
location = db.Column(db.String(500), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
# Optional: attach a driver to a specific printer model
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
model = db.relationship('Model')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'driverid': self.driverid,
|
||||
'name': self.name,
|
||||
'location': self.location,
|
||||
'description': self.description,
|
||||
'modelnumberid': self.modelnumberid,
|
||||
'modelname': self.model.modelnumber if self.model else None,
|
||||
'isactive': bool(self.isactive),
|
||||
}
|
||||
241
plugins/printers/plugin.py
Normal file
241
plugins/printers/plugin.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Printers plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import Printer, PrinterType, ModelSupply, PrinterDriver
|
||||
from .api import printers_asset_bp
|
||||
from .services import ZabbixService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrintersPlugin(BasePlugin):
|
||||
"""
|
||||
Printers plugin - manages printer assets.
|
||||
|
||||
Supports both legacy Machine-based architecture and new Asset-based architecture:
|
||||
- Legacy: PrinterData table linked to machines
|
||||
- New: Printer table linked to assets
|
||||
|
||||
Features:
|
||||
- PrinterType classification
|
||||
- Windows/network naming
|
||||
- Zabbix integration for real-time supply level lookups
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
self._zabbixservice = None
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'printers'),
|
||||
version=self._manifest.get('version', '2.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Printer management with Zabbix integration'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/printers'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""
|
||||
Return Flask Blueprint with API routes.
|
||||
|
||||
Returns the new Asset-based blueprint.
|
||||
Legacy Machine-based blueprint is registered separately in init_app.
|
||||
"""
|
||||
return printers_asset_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [
|
||||
Printer, # Asset-based
|
||||
PrinterType, # printer type classification
|
||||
PrinterDriver, # driver links (SMB/HTTP)
|
||||
ModelSupply, # model -> toner/drum/waste part numbers
|
||||
]
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
"""Return plugin services."""
|
||||
return {
|
||||
'zabbix': ZabbixService,
|
||||
}
|
||||
|
||||
@property
|
||||
def zabbixservice(self) -> ZabbixService:
|
||||
"""Get Zabbix service instance."""
|
||||
if self._zabbixservice is None:
|
||||
self._zabbixservice = ZabbixService()
|
||||
return self._zabbixservice
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
app.config.setdefault('ZABBIX_URL', '')
|
||||
app.config.setdefault('ZABBIX_TOKEN', '')
|
||||
|
||||
logger.info(f"Printers plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_printer_types()
|
||||
logger.info("Printers plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure printer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='printer').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='printer',
|
||||
pluginname='printers',
|
||||
tablename='printers',
|
||||
description='Printers (laser, inkjet, label, MFP, plotter)',
|
||||
icon='printer'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: printer")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_printer_types(self) -> None:
|
||||
"""Ensure basic printer types exist (new architecture)."""
|
||||
printer_types = [
|
||||
('Laser', 'Standard laser printer', 'printer'),
|
||||
('Inkjet', 'Inkjet printer', 'printer'),
|
||||
('Label', 'Label/barcode printer', 'barcode'),
|
||||
('Card', 'ID / card printer', 'id-card'),
|
||||
('MFP', 'Multifunction printer with scan/copy/fax', 'printer'),
|
||||
('Plotter', 'Large format plotter', 'drafting-compass'),
|
||||
('Thermal', 'Thermal printer', 'temperature-high'),
|
||||
('Dot Matrix', 'Dot matrix printer', 'th'),
|
||||
('Other', 'Other printer type', 'printer'),
|
||||
]
|
||||
|
||||
for name, description, icon in printer_types:
|
||||
existing = PrinterType.query.filter_by(printertype=name).first()
|
||||
if not existing:
|
||||
pt = PrinterType(
|
||||
printertype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(pt)
|
||||
logger.debug(f"Created printer type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Printers plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('printers')
|
||||
def printerscli():
|
||||
"""Printers plugin commands."""
|
||||
pass
|
||||
|
||||
@printerscli.command('check-supplies')
|
||||
@click.argument('ip')
|
||||
def checksupplies(ip):
|
||||
"""Check supply levels for a printer by IP (via Zabbix)."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
service = ZabbixService()
|
||||
|
||||
if not service.isconfigured:
|
||||
click.echo('Error: Zabbix not configured. Set ZABBIX_URL and ZABBIX_TOKEN.')
|
||||
return
|
||||
|
||||
supplies = service.getsuppliesbyip(ip)
|
||||
if not supplies:
|
||||
click.echo(f'No supply data found for {ip}')
|
||||
return
|
||||
|
||||
click.echo(f'Supply levels for {ip}:')
|
||||
for supply in supplies:
|
||||
click.echo(f" {supply['name']}: {supply['level']}%")
|
||||
|
||||
@printerscli.command('seed-supplies')
|
||||
def seedsuppliescommand():
|
||||
"""Seed corrected model->toner part numbers into modelsupplies."""
|
||||
from flask import current_app
|
||||
from .services import seedsupplies
|
||||
|
||||
with current_app.app_context():
|
||||
summary = seedsupplies()
|
||||
click.echo(
|
||||
f"Seeded supplies: {summary['suppliesadded']} added across "
|
||||
f"{summary['modelstouched']} models."
|
||||
)
|
||||
|
||||
return [printerscli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Printer Status',
|
||||
'component': 'PrinterStatusWidget',
|
||||
'endpoint': '/api/printers/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 10,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Printers',
|
||||
'icon': 'printer',
|
||||
'route': '/printers',
|
||||
'position': 20,
|
||||
},
|
||||
]
|
||||
|
||||
def get_reports(self) -> List[Dict]:
|
||||
"""Return report card definitions for the Reports hub."""
|
||||
return [
|
||||
{
|
||||
'id': 'toner',
|
||||
'name': 'Toner Report',
|
||||
'description': 'Printers with low or critical toner/supply levels',
|
||||
'category': 'printers',
|
||||
'route': '/reports/toner',
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('printers.view', 'View printers', 'printers'),
|
||||
('printers.create', 'Create printers', 'printers'),
|
||||
('printers.edit', 'Edit printers', 'printers'),
|
||||
('printers.delete', 'Delete printers', 'printers'),
|
||||
]
|
||||
1
plugins/printers/schemas/__init__.py
Normal file
1
plugins/printers/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Printers plugin schemas (for future Marshmallow serialization)."""
|
||||
19
plugins/printers/services/__init__.py
Normal file
19
plugins/printers/services/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Printers plugin services."""
|
||||
|
||||
from .zabbix_service import ZabbixService
|
||||
from .supply_parts import (
|
||||
classifysupply,
|
||||
derivesupplytype,
|
||||
derivecolor,
|
||||
lookupsupplies,
|
||||
)
|
||||
from .seed_supplies import seedsupplies
|
||||
|
||||
__all__ = [
|
||||
'ZabbixService',
|
||||
'classifysupply',
|
||||
'derivesupplytype',
|
||||
'derivecolor',
|
||||
'lookupsupplies',
|
||||
'seedsupplies',
|
||||
]
|
||||
358
plugins/printers/services/seed_supplies.py
Normal file
358
plugins/printers/services/seed_supplies.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Seed data for model -> supply mappings.
|
||||
|
||||
Corrected against official HP and Xerox sources (verification pass
|
||||
2026-06-25). Replaces the old hardcoded supply_parts table, which had
|
||||
roughly ten wrong part numbers, scrambled colors, and HP high-yield
|
||||
cartridges mislabeled as metered.
|
||||
|
||||
Each family matches printer models by substring (matchkeys), the same way
|
||||
the classic ASP report did, then attaches its supplies. seedsupplies()
|
||||
finds or creates the vendor and a canonical model row, then inserts any
|
||||
missing supply rows. Re-running is safe: existing part numbers are skipped.
|
||||
|
||||
Key facts encoded here:
|
||||
- HP "X" = high yield, NOT metered. HP metered cartridges are contractual
|
||||
"C-suffix" SKUs and exist only on Enterprise/Managed hardware, so the
|
||||
Pro color families (M454/M479, M251/M252/M277, M254/M255) have none.
|
||||
- Xerox uses distinct sold / metered / dmo part numbers per color.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from shopdb.api import db, Vendor, Model
|
||||
|
||||
from ..models import ModelSupply
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _toner(color, tier, partnumber, marketingname, pageyield=None):
|
||||
return {
|
||||
'supplytype': 'toner', 'color': color, 'capacitytier': tier,
|
||||
'partnumber': partnumber, 'marketingname': marketingname,
|
||||
'pageyield': pageyield,
|
||||
}
|
||||
|
||||
|
||||
def _part(supplytype, partnumber, marketingname, notes=None):
|
||||
return {
|
||||
'supplytype': supplytype, 'color': 'none', 'capacitytier': 'standard',
|
||||
'partnumber': partnumber, 'marketingname': marketingname,
|
||||
'pageyield': None, 'notes': notes,
|
||||
}
|
||||
|
||||
|
||||
# Corrected supply catalog. Each entry: vendor, canonical model name,
|
||||
# substring matchkeys, and the list of supplies.
|
||||
SEED = [
|
||||
# ----- HP color, Pro (no metered variant exists) -----
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M454 / M479',
|
||||
'matchkeys': ['M454', 'M479'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'W2020A', '414A Black', 2400),
|
||||
_toner('black', 'high', 'W2020X', '414X Black', 7500),
|
||||
_toner('cyan', 'standard', 'W2021A', '414A Cyan', 2100),
|
||||
_toner('cyan', 'high', 'W2021X', '414X Cyan', 6000),
|
||||
_toner('yellow', 'standard', 'W2022A', '414A Yellow', 2100),
|
||||
_toner('yellow', 'high', 'W2022X', '414X Yellow', 6000),
|
||||
_toner('magenta', 'standard', 'W2023A', '414A Magenta', 2100),
|
||||
_toner('magenta', 'high', 'W2023X', '414X Magenta', 6000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M251 / M252 / M277',
|
||||
'matchkeys': ['M251', 'M252', 'M277', 'M274'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CF400A', '201A Black', 1500),
|
||||
_toner('black', 'high', 'CF400X', '201X Black', 2800),
|
||||
_toner('cyan', 'standard', 'CF401A', '201A Cyan', 1400),
|
||||
_toner('cyan', 'high', 'CF401X', '201X Cyan', 2500),
|
||||
_toner('yellow', 'standard', 'CF402A', '201A Yellow', 1400),
|
||||
_toner('yellow', 'high', 'CF402X', '201X Yellow', 2500),
|
||||
_toner('magenta', 'standard', 'CF403A', '201A Magenta', 1400),
|
||||
_toner('magenta', 'high', 'CF403X', '201X Magenta', 2500),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M254 / M255',
|
||||
'matchkeys': ['M254', 'M255', 'M280', 'M281'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CF500A', '202A Black', 1400),
|
||||
_toner('black', 'high', 'CF500X', '202X Black', 3200),
|
||||
_toner('cyan', 'standard', 'CF501A', '202A Cyan', 1300),
|
||||
_toner('cyan', 'high', 'CF501X', '202X Cyan', 2500),
|
||||
_toner('yellow', 'standard', 'CF502A', '202A Yellow', 1300),
|
||||
_toner('yellow', 'high', 'CF502X', '202X Yellow', 2500),
|
||||
_toner('magenta', 'standard', 'CF503A', '202A Magenta', 1300),
|
||||
_toner('magenta', 'high', 'CF503X', '202X Magenta', 2500),
|
||||
],
|
||||
},
|
||||
# ----- HP mono (X = high yield; XC/YC = contractual metered) -----
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet M404 / M406 / M428 / M430',
|
||||
'matchkeys': ['M404', 'M406', 'M428', 'M430'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CF258A', '58A Black', 3000),
|
||||
_toner('black', 'high', 'CF258X', '58X Black', 10000),
|
||||
_toner('black', 'metered', 'CF258XC', '58X Black (Contract)', 10000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet M607 / M608 / M609 / M631 / M632 / M633',
|
||||
'matchkeys': ['M607', 'M608', 'M609', 'M631', 'M632', 'M633'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CF237A', '37A Black', 11000),
|
||||
_toner('black', 'high', 'CF237X', '37X Black', 25000),
|
||||
# 37Y extra-high does NOT fit the M607
|
||||
_toner('black', 'extrahigh', 'CF237Y', '37Y Black (not M607)', 41000),
|
||||
_toner('black', 'metered', 'CF237YC', '37Y Black (Contract)', 41000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet M506 / M527 / M501',
|
||||
'matchkeys': ['M506', 'M527', 'M501'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CF287A', '87A Black', 9000),
|
||||
_toner('black', 'high', 'CF287X', '87X Black', 18000),
|
||||
_toner('black', 'metered', 'CF287XC', '87X Black (Contract)', 18000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet M602 / M603 / M4555',
|
||||
'matchkeys': ['M602', 'M603', 'M4555'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CE390A', '90A Black', 10000),
|
||||
# 90X does NOT fit the M601
|
||||
_toner('black', 'high', 'CE390X', '90X Black (not M601)', 24000),
|
||||
_toner('black', 'metered', 'CE390XC', '90X Black (Contract)', 24000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet P3015 / M521 / M525',
|
||||
'matchkeys': ['P3015', 'M521', 'M525'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'CE255A', '55A Black', 6000),
|
||||
_toner('black', 'high', 'CE255X', '55X Black', 12500),
|
||||
_toner('black', 'metered', 'CE255XC', '55X Black (Contract)', 12500),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet 4250 / 4350',
|
||||
'matchkeys': ['4250', '4350'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'Q5942A', '42A Black', 10000),
|
||||
_toner('black', 'high', 'Q5942X', '42X Black', 20000),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet M610 / M611 / M612 / M634 / M635 / M636',
|
||||
'matchkeys': ['M610', 'M611', 'M612', 'M634', 'M635', 'M636'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', 'W1470A', '147A Black', 10500),
|
||||
_toner('black', 'high', 'W1470X', '147X Black', 25200),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'HP', 'canonical': 'HP LaserJet Pro 4001 / 4101 (148 series)',
|
||||
'matchkeys': ['4001', '4101', '4002', '4102'],
|
||||
'supplies': [
|
||||
_toner('black', 'high', 'W1480X', '148X Black', 9500),
|
||||
_toner('black', 'metered', 'W1020XC', '148 Black (Contract)', 9500),
|
||||
],
|
||||
},
|
||||
|
||||
# ----- Xerox VersaLink color -----
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C415',
|
||||
'matchkeys': ['C415'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '006R04677', 'C415 Black', 2400),
|
||||
_toner('cyan', 'standard', '006R04678', 'C415 Cyan', 2000),
|
||||
_toner('magenta', 'standard', '006R04679', 'C415 Magenta', 2000),
|
||||
_toner('yellow', 'standard', '006R04680', 'C415 Yellow', 2000),
|
||||
_toner('black', 'high', '006R04685', 'C415 Black (High)', 10500),
|
||||
_toner('cyan', 'high', '006R04686', 'C415 Cyan (High)', 7000),
|
||||
_toner('magenta', 'high', '006R04687', 'C415 Magenta (High)', 7000),
|
||||
_toner('yellow', 'high', '006R04688', 'C415 Yellow (High)', 7000),
|
||||
_toner('black', 'metered', '006R04693', 'C415 Black (Metered)', 15000),
|
||||
_toner('cyan', 'metered', '006R04694', 'C415 Cyan (Metered)', 10000),
|
||||
_toner('magenta', 'metered', '006R04695', 'C415 Magenta (Metered)', 10000),
|
||||
_toner('yellow', 'metered', '006R04696', 'C415 Yellow (Metered)', 10000),
|
||||
_part('drum', '013R00701', 'C415 Drum / Imaging Unit'),
|
||||
_part('waste', '008R13325', 'C415 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C405',
|
||||
'matchkeys': ['C405', 'C400'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '106R03500', 'C405 Black', 2500),
|
||||
_toner('yellow', 'standard', '106R03501', 'C405 Yellow', 2500),
|
||||
_toner('cyan', 'standard', '106R03502', 'C405 Cyan', 2500),
|
||||
_toner('magenta', 'standard', '106R03503', 'C405 Magenta', 2500),
|
||||
_toner('black', 'high', '106R03512', 'C405 Black (High)', 5000),
|
||||
_toner('black', 'extrahigh', '106R03524', 'C405 Black (Extra High)', 10500),
|
||||
_part('drum', '108R01121', 'C400 / C405 Drum'),
|
||||
_part('waste', '108R01124', 'C400 / C405 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C7100 / C7120 / C7125 / C7130',
|
||||
'matchkeys': ['C7100', 'C7120', 'C7125', 'C7130'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '006R01824', 'C7125 Black (Sold)', 31300),
|
||||
_toner('cyan', 'standard', '006R01825', 'C7125 Cyan (Sold)', 18500),
|
||||
_toner('magenta', 'standard', '006R01826', 'C7125 Magenta (Sold)', 18500),
|
||||
_toner('yellow', 'standard', '006R01827', 'C7125 Yellow (Sold)', 18500),
|
||||
_toner('black', 'metered', '006R01820', 'C7125 Black (Metered)', 22500),
|
||||
_toner('cyan', 'metered', '006R01821', 'C7125 Cyan (Metered)', 15500),
|
||||
_toner('magenta', 'metered', '006R01822', 'C7125 Magenta (Metered)', 15500),
|
||||
_toner('yellow', 'metered', '006R01823', 'C7125 Yellow (Metered)', 15500),
|
||||
_part('drum', '013R00688', 'C7125 Drum'),
|
||||
_part('waste', '115R00129', 'C7000 / C7100 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B7100 / B7125 / B7130 / B7135',
|
||||
'matchkeys': ['B7100', 'B7125', 'B7130', 'B7135'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '006R01818', 'B7125 Black (Sold/High)', 34300),
|
||||
_toner('black', 'metered', '006R01817', 'B7125 Black (Metered)', 34300),
|
||||
_toner('black', 'dmo', '006R01819', 'B7125 Black (DMO)', 34300),
|
||||
_part('drum', '013R00687', 'B7125 Drum'),
|
||||
_part('waste', '115R00129', 'B7000 / B7100 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B400 / B405',
|
||||
'matchkeys': ['B400', 'B405'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '106R03580', 'B405 Black', 5900),
|
||||
_toner('black', 'high', '106R03582', 'B405 Black (High)', 13900),
|
||||
_toner('black', 'extrahigh', '106R03584', 'B405 Black (Extra High)', 24600),
|
||||
_toner('black', 'metered', '106R03586', 'B405 Black (Metered)', 24600),
|
||||
_part('drum', '101R00554', 'B400 / B405 Drum'),
|
||||
# note: B405 has no waste cartridge
|
||||
],
|
||||
},
|
||||
|
||||
# ----- Xerox AltaLink color -----
|
||||
{
|
||||
'vendor': 'Xerox', 'canonical': 'Xerox AltaLink C8130 / C8135 / C8145 / C8155 / C8170',
|
||||
'matchkeys': ['C8130', 'C8135', 'C8145', 'C8155', 'C8170'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '006R01746', 'C8135 Black (Sold)'),
|
||||
_toner('cyan', 'standard', '006R01747', 'C8135 Cyan (Sold)'),
|
||||
_toner('magenta', 'standard', '006R01748', 'C8135 Magenta (Sold)'),
|
||||
_toner('yellow', 'standard', '006R01749', 'C8135 Yellow (Sold)'),
|
||||
_toner('black', 'metered', '006R01742', 'C8135 Black (Metered)'),
|
||||
_toner('cyan', 'metered', '006R01743', 'C8135 Cyan (Metered)'),
|
||||
_toner('magenta', 'metered', '006R01744', 'C8135 Magenta (Metered)'),
|
||||
_toner('yellow', 'metered', '006R01745', 'C8135 Yellow (Metered)'),
|
||||
_part('drum', '013R00681', 'C8135 Drum'),
|
||||
_part('waste', '008R08101', 'C8135 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
{
|
||||
'vendor': 'Xerox',
|
||||
'canonical': 'Xerox AltaLink C8030 / C8035 / C8045 / C8055 / EC8036',
|
||||
'matchkeys': ['C8030', 'C8035', 'C8045', 'C8055', 'EC8036', 'EC8056', 'C8036'],
|
||||
'supplies': [
|
||||
_toner('black', 'standard', '006R01697', 'C8030 Black (Sold)'),
|
||||
_toner('cyan', 'standard', '006R01698', 'C8030 Cyan (Sold)'),
|
||||
_toner('magenta', 'standard', '006R01699', 'C8030 Magenta (Sold)'),
|
||||
_toner('yellow', 'standard', '006R01700', 'C8030 Yellow (Sold)'),
|
||||
_toner('black', 'metered', '006R01701', 'C8030 Black (Metered)'),
|
||||
_toner('cyan', 'metered', '006R01702', 'C8030 Cyan (Metered)'),
|
||||
_toner('magenta', 'metered', '006R01703', 'C8030 Magenta (Metered)'),
|
||||
_toner('yellow', 'metered', '006R01704', 'C8030 Yellow (Metered)'),
|
||||
# legacy WorkCentre 78xx cross-reference set, still compatible
|
||||
_toner('black', 'high', '006R01509', 'WC7800 Black (legacy)'),
|
||||
_toner('yellow', 'high', '006R01510', 'WC7800 Yellow (legacy)'),
|
||||
_toner('magenta', 'high', '006R01511', 'WC7800 Magenta (legacy)'),
|
||||
_toner('cyan', 'high', '006R01512', 'WC7800 Cyan (legacy)'),
|
||||
_part('drum', '013R00662', 'C8030 / EC8036 Drum'),
|
||||
_part('waste', '008R13061', 'C8030 / EC8036 Waste Cartridge'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _find_or_create_vendor(name):
|
||||
vendor = Vendor.query.filter_by(vendor=name).first()
|
||||
if not vendor:
|
||||
vendor = Vendor(vendor=name)
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
return vendor
|
||||
|
||||
|
||||
def _matching_models(matchkeys, vendorid):
|
||||
"""Existing models whose modelnumber contains any matchkey (this vendor)."""
|
||||
found = []
|
||||
for key in matchkeys:
|
||||
rows = Model.query.filter(
|
||||
Model.vendorid == vendorid,
|
||||
Model.modelnumber.ilike(f'%{key}%'),
|
||||
).all()
|
||||
for row in rows:
|
||||
if row not in found:
|
||||
found.append(row)
|
||||
return found
|
||||
|
||||
|
||||
def seedsupplies():
|
||||
"""Seed corrected model->supply data. Idempotent.
|
||||
|
||||
Returns a summary dict with counts. Attaches supplies to every existing
|
||||
model that matches a family's keys; if a family matches no existing model,
|
||||
creates a canonical model row so its toners are still available.
|
||||
"""
|
||||
models_touched = 0
|
||||
supplies_added = 0
|
||||
|
||||
for family in SEED:
|
||||
vendor = _find_or_create_vendor(family['vendor'])
|
||||
targets = _matching_models(family['matchkeys'], vendor.vendorid)
|
||||
|
||||
if not targets:
|
||||
# modeltypeid is a legacy Model column (nullable); printers are
|
||||
# asset-based now and carry their type via PrinterType, not here.
|
||||
model = Model(
|
||||
modelnumber=family['canonical'],
|
||||
vendorid=vendor.vendorid,
|
||||
)
|
||||
db.session.add(model)
|
||||
db.session.flush()
|
||||
targets = [model]
|
||||
|
||||
for model in targets:
|
||||
models_touched += 1
|
||||
existing = {
|
||||
supply.partnumber
|
||||
for supply in ModelSupply.query.filter_by(
|
||||
modelnumberid=model.modelnumberid
|
||||
).all()
|
||||
}
|
||||
for spec in family['supplies']:
|
||||
if spec['partnumber'] in existing:
|
||||
continue
|
||||
db.session.add(ModelSupply(
|
||||
modelnumberid=model.modelnumberid,
|
||||
supplytype=spec['supplytype'],
|
||||
color=spec['color'],
|
||||
capacitytier=spec['capacitytier'],
|
||||
partnumber=spec['partnumber'],
|
||||
marketingname=spec.get('marketingname'),
|
||||
pageyield=spec.get('pageyield'),
|
||||
notes=spec.get('notes'),
|
||||
))
|
||||
supplies_added += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"Seeded printer supplies: %d models touched, %d supplies added",
|
||||
models_touched, supplies_added
|
||||
)
|
||||
return {'modelstouched': models_touched, 'suppliesadded': supplies_added}
|
||||
107
plugins/printers/services/supply_parts.py
Normal file
107
plugins/printers/services/supply_parts.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Printer supply classification and part-number lookup.
|
||||
|
||||
Part numbers now live in the modelsupplies table (see seed_supplies.py),
|
||||
managed through the API/UI. This module keeps the runtime logic that is not
|
||||
per-model data: classifying a reported level into ok/low/critical (waste
|
||||
cartridges invert), deriving supply type and color from a Zabbix item name,
|
||||
and reading the matching part numbers out of the database.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# alert thresholds (percent remaining)
|
||||
CRITICAL_THRESHOLD = 5
|
||||
LOW_THRESHOLD = 10
|
||||
|
||||
|
||||
def derivesupplytype(name: str) -> str:
|
||||
"""Map a Zabbix item name to a supply type."""
|
||||
lowername = (name or "").lower()
|
||||
if "waste" in lowername:
|
||||
return "waste"
|
||||
if "drum" in lowername or "imaging" in lowername:
|
||||
return "drum"
|
||||
if "maintenance" in lowername or "fuser" in lowername:
|
||||
return "maintenance"
|
||||
return "toner"
|
||||
|
||||
|
||||
def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
|
||||
"""Best-effort supply color from a Zabbix color tag, then the item name."""
|
||||
color = (tagcolor or "").lower()
|
||||
if "black" in color:
|
||||
return "black"
|
||||
if color in ("cyan", "magenta", "yellow"):
|
||||
return color
|
||||
if color in ("grey", "gray"):
|
||||
return "gray"
|
||||
|
||||
lowername = (name or "").lower()
|
||||
for candidate in ("cyan", "magenta", "yellow", "black"):
|
||||
if candidate in lowername:
|
||||
return candidate
|
||||
return "none"
|
||||
|
||||
|
||||
def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
|
||||
"""Classify one supply item into ok/low/critical.
|
||||
|
||||
Waste cartridge fill is inverted vs a toner level: a full waste cartridge
|
||||
is bad. Standard vendors report waste as percent FULL (high = bad). Xerox
|
||||
EC/AltaLink series report waste as percent capacity REMAINING (low = bad),
|
||||
same direction as toner. Normalise everything to percent remaining first.
|
||||
"""
|
||||
lowername = (name or "").lower()
|
||||
iswaste = "waste" in lowername
|
||||
isdrum = "drum" in lowername or "imaging" in lowername
|
||||
isxerox = bool(vendor) and "xerox" in vendor.lower()
|
||||
|
||||
if iswaste and not isxerox:
|
||||
remaining = 100 - level
|
||||
else:
|
||||
remaining = level
|
||||
|
||||
if remaining <= CRITICAL_THRESHOLD:
|
||||
status = "critical"
|
||||
elif remaining <= LOW_THRESHOLD:
|
||||
status = "low"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"remaining": round(remaining, 1),
|
||||
"iswaste": iswaste,
|
||||
"isdrum": isdrum,
|
||||
}
|
||||
|
||||
|
||||
def lookupsupplies(modelnumberid: Optional[int], color: str,
|
||||
supplytype: str) -> List[Dict]:
|
||||
"""Part-number options for a model + color + supply type, from the DB.
|
||||
|
||||
Returns every matching capacity tier (standard / high / metered / ...) so
|
||||
the report can show all reorder options, like the classic report did.
|
||||
"""
|
||||
if not modelnumberid:
|
||||
return []
|
||||
|
||||
from ..models import ModelSupply
|
||||
|
||||
query = ModelSupply.query.filter_by(
|
||||
modelnumberid=modelnumberid,
|
||||
supplytype=supplytype,
|
||||
isactive=True,
|
||||
)
|
||||
# toners are color-specific; drum/waste/maintenance are not
|
||||
if supplytype == 'toner' and color and color != 'none':
|
||||
query = query.filter_by(color=color)
|
||||
|
||||
rows = query.order_by(ModelSupply.capacitytier).all()
|
||||
return [{
|
||||
'partnumber': row.partnumber,
|
||||
'marketingname': row.marketingname,
|
||||
'capacitytier': row.capacitytier,
|
||||
'pageyield': row.pageyield,
|
||||
} for row in rows]
|
||||
254
plugins/printers/services/zabbix_service.py
Normal file
254
plugins/printers/services/zabbix_service.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Zabbix service for real-time printer supply lookups.
|
||||
|
||||
Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
|
||||
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
|
||||
the live integration:
|
||||
|
||||
- Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
|
||||
The old payload "auth" field is rejected by Zabbix 7.0.
|
||||
- Hosts are named by IP address, so a host is located with
|
||||
host.get filter {host: [ip]}, not by interface address.
|
||||
- Supply levels come from items tagged component=supplies AND type=level,
|
||||
not from a key_ substring search.
|
||||
- Each level item carries a color tag used for display and part lookup.
|
||||
|
||||
Configuration (database Setting overrides env var):
|
||||
ZABBIX_ENABLED: turn the integration on
|
||||
ZABBIX_URL: base URL or full api_jsonrpc.php URL
|
||||
ZABBIX_TOKEN: API token
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from shopdb.api import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZabbixService:
|
||||
"""Zabbix API client for printer supply and ping lookups."""
|
||||
|
||||
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||
REACHABLE_CHECK_TTL = 60
|
||||
|
||||
# quick fail for the reachability probe
|
||||
REACHABLE_TIMEOUT = 1.0
|
||||
# (connect, read) for real API calls; item.get is slow, give it room
|
||||
API_TIMEOUT = (3.0, 5.0)
|
||||
|
||||
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
|
||||
SUPPLY_TAGS = [
|
||||
{"tag": "component", "value": "supplies", "operator": 0},
|
||||
{"tag": "type", "value": "level", "operator": 0},
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self._url = None
|
||||
self._token = None
|
||||
|
||||
# -- configuration -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def isenabled(self) -> bool:
|
||||
"""Whether the integration is switched on."""
|
||||
from shopdb.api import Setting
|
||||
db_enabled = Setting.get('zabbix_enabled')
|
||||
if db_enabled is not None:
|
||||
return bool(db_enabled)
|
||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||
|
||||
@property
|
||||
def isconfigured(self) -> bool:
|
||||
"""Enabled, and a URL plus token are present."""
|
||||
if not self.isenabled:
|
||||
return False
|
||||
from shopdb.api import Setting
|
||||
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
||||
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
||||
return bool(self._url and self._token)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
|
||||
url = (self._url or "").rstrip("/")
|
||||
if url.endswith("api_jsonrpc.php"):
|
||||
return url
|
||||
return f"{url}/api_jsonrpc.php"
|
||||
|
||||
@property
|
||||
def isreachable(self) -> bool:
|
||||
"""Cheap connectivity probe, cached for 60s."""
|
||||
if not self.isconfigured:
|
||||
return False
|
||||
|
||||
cache_key = 'zabbix_reachable'
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
|
||||
# any non-5xx answer means the web tier responded, so the server is
|
||||
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
|
||||
# wants a POST with json-rpc content type); that still counts.
|
||||
reachable = response.status_code < 500
|
||||
except requests.RequestException:
|
||||
reachable = False
|
||||
|
||||
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
||||
logger.debug("Zabbix reachability: %s", reachable)
|
||||
return reachable
|
||||
|
||||
# -- low level call ------------------------------------------------------
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[object]:
|
||||
"""One JSON-RPC call. Returns the result, or None on any error."""
|
||||
if not self.isconfigured:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json-rpc",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||
return None
|
||||
|
||||
if "error" in data:
|
||||
logger.error("Zabbix %s error: %s", method, data["error"])
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
# -- host / item lookups -------------------------------------------------
|
||||
|
||||
def gethostidbyip(self, ip: str) -> Optional[str]:
|
||||
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
|
||||
result = self._apicall("host.get", {
|
||||
"output": ["hostid"],
|
||||
"filter": {"host": [ip]},
|
||||
})
|
||||
if result:
|
||||
return result[0].get("hostid")
|
||||
return None
|
||||
|
||||
def _extract_color(self, item: Dict) -> str:
|
||||
"""Pull and normalise the color tag, falling back to the item name."""
|
||||
color = ""
|
||||
for tag in item.get("tags", []) or []:
|
||||
if tag.get("tag") == "color":
|
||||
color = (tag.get("value") or "").lower()
|
||||
break
|
||||
if "black" in color:
|
||||
color = "black"
|
||||
elif color in ("grey", "gray"):
|
||||
color = "gray"
|
||||
|
||||
if not color:
|
||||
name = (item.get("name") or "").lower()
|
||||
for candidate in ("cyan", "magenta", "yellow", "black"):
|
||||
if candidate in name:
|
||||
color = candidate
|
||||
break
|
||||
if not color and ("gray" in name or "grey" in name):
|
||||
color = "gray"
|
||||
return color
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""Current supply levels for a printer, by IP.
|
||||
|
||||
Returns a list of dicts {name, level, color, itemid, status, state},
|
||||
or None if the host is not in Zabbix. Drum/maintenance items are kept
|
||||
(callers decide what to surface); only disabled (status=1) and
|
||||
unsupported (state=1) items are dropped, matching the classic report.
|
||||
"""
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
logger.debug("No Zabbix host for IP %s", ip)
|
||||
return None
|
||||
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["itemid", "name", "lastvalue", "lastclock",
|
||||
"units", "status", "state"],
|
||||
"hostids": hostid,
|
||||
"selectTags": "extend",
|
||||
"evaltype": 0, # and
|
||||
"tags": self.SUPPLY_TAGS,
|
||||
"sortfield": "name",
|
||||
"monitored": True,
|
||||
})
|
||||
if not items:
|
||||
return []
|
||||
|
||||
supplies = []
|
||||
for item in items:
|
||||
# skip disabled or unsupported items
|
||||
if str(item.get("status", "0")) != "0":
|
||||
continue
|
||||
if str(item.get("state", "0")) != "0":
|
||||
continue
|
||||
try:
|
||||
level = int(float(item.get("lastvalue", 0)))
|
||||
except (ValueError, TypeError):
|
||||
level = 0
|
||||
supplies.append({
|
||||
"name": item.get("name", "Unknown"),
|
||||
"level": level,
|
||||
"color": self._extract_color(item),
|
||||
"itemid": item.get("itemid"),
|
||||
})
|
||||
return supplies
|
||||
|
||||
def getpingstatus(self, ip: str) -> str:
|
||||
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
return "-1"
|
||||
items = self._apicall("item.get", {
|
||||
"output": ["lastvalue"],
|
||||
"hostids": hostid,
|
||||
"search": {"key_": "icmpping"},
|
||||
})
|
||||
if items:
|
||||
return str(items[0].get("lastvalue", "-1"))
|
||||
return "-1"
|
||||
|
||||
# -- caching wrappers ----------------------------------------------------
|
||||
|
||||
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""getsuppliesbyip with a 5-minute per-IP cache."""
|
||||
cache_key = f"zabbix_supplies_{ip}"
|
||||
result = cache.get(cache_key)
|
||||
if result is not None:
|
||||
return result
|
||||
result = self.getsuppliesbyip(ip)
|
||||
if result is not None:
|
||||
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
||||
return result
|
||||
|
||||
def clearcache(self, ip: str = None):
|
||||
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
|
||||
if ip:
|
||||
cache.delete(f"zabbix_supplies_{ip}")
|
||||
cache.delete("printers_low_supplies")
|
||||
cache.delete("zabbix_reachable")
|
||||
Reference in New Issue
Block a user