Zabbix supply backend rebuild + data-driven model supplies
Rewrite the printer Zabbix integration (Bearer auth, host-by-IP, tag-based supply lookup, ping) and replace the hardcoded toner table with a modelsupplies table + CRUD + seed. Add mock Zabbix server, live test harness, and the Playwright screenshot tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +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']
|
||||
__all__ = [
|
||||
'ZabbixService',
|
||||
'classifysupply',
|
||||
'derivesupplytype',
|
||||
'derivecolor',
|
||||
'lookupsupplies',
|
||||
'seedsupplies',
|
||||
]
|
||||
|
||||
362
plugins/printers/services/seed_supplies.py
Normal file
362
plugins/printers/services/seed_supplies.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""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.extensions import db
|
||||
from shopdb.core.models import Vendor, Model
|
||||
from shopdb.core.models.machine import MachineType
|
||||
|
||||
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.
|
||||
"""
|
||||
printertype = MachineType.query.filter_by(category='Printer').first()
|
||||
printertypeid = printertype.machinetypeid if printertype else None
|
||||
|
||||
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:
|
||||
model = Model(
|
||||
modelnumber=family['canonical'],
|
||||
vendorid=vendor.vendorid,
|
||||
machinetypeid=printertypeid,
|
||||
)
|
||||
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]
|
||||
@@ -1,4 +1,22 @@
|
||||
"""Zabbix service for real-time printer supply lookups."""
|
||||
"""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
|
||||
@@ -12,53 +30,59 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZabbixService:
|
||||
"""
|
||||
Zabbix API service for real-time printer supply lookups.
|
||||
"""Zabbix API client for printer supply and ping lookups."""
|
||||
|
||||
Queries Zabbix by IP address to get current supply levels.
|
||||
Use getsuppliesbyip_cached() for cached lookups or
|
||||
getsuppliesbyip() for live data.
|
||||
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||
REACHABLE_CHECK_TTL = 60
|
||||
|
||||
Configuration:
|
||||
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
|
||||
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
|
||||
ZABBIX_TOKEN: Zabbix API authentication token
|
||||
"""
|
||||
# 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)
|
||||
|
||||
CACHE_TTL = 600 # 10 minutes
|
||||
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
|
||||
# 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
|
||||
self._enabled = None
|
||||
|
||||
# -- configuration -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def isenabled(self) -> bool:
|
||||
"""Check if Zabbix integration is enabled."""
|
||||
# Check database setting first, fall back to env var
|
||||
"""Whether the integration is switched on."""
|
||||
from shopdb.core.models import Setting
|
||||
db_enabled = Setting.get('zabbix_enabled')
|
||||
if db_enabled is not None:
|
||||
return bool(db_enabled)
|
||||
# Fall back to env var for backwards compatibility
|
||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||
|
||||
@property
|
||||
def isconfigured(self) -> bool:
|
||||
"""Check if Zabbix is enabled and configured."""
|
||||
"""Enabled, and a URL plus token are present."""
|
||||
if not self.isenabled:
|
||||
return False
|
||||
# Check database settings first, fall back to env vars
|
||||
from shopdb.core.models 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:
|
||||
"""Check if Zabbix is reachable (cached for 60 seconds)."""
|
||||
if not self.isenabled or not self.isconfigured:
|
||||
"""Cheap connectivity probe, cached for 60s."""
|
||||
if not self.isconfigured:
|
||||
return False
|
||||
|
||||
cache_key = 'zabbix_reachable'
|
||||
@@ -66,138 +90,165 @@ class ZabbixService:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Quick connectivity check with 500ms timeout
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
timeout=0.5
|
||||
)
|
||||
reachable = response.status_code in (200, 401, 403, 405)
|
||||
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(f"Zabbix reachability check: {reachable}")
|
||||
logger.debug("Zabbix reachability: %s", reachable)
|
||||
return reachable
|
||||
|
||||
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
|
||||
"""Make a Zabbix API call."""
|
||||
# -- 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,
|
||||
'auth': self._token,
|
||||
'id': 1
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json-rpc",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self._url}/api_jsonrpc.php",
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
|
||||
headers=headers,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
logger.error(f"Zabbix API error: {data['error']}")
|
||||
return None
|
||||
|
||||
return data.get('result')
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Zabbix API request failed: {e}")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||
return None
|
||||
|
||||
def gethostbyip(self, ip: str) -> Optional[Dict]:
|
||||
"""Find a Zabbix host by IP address."""
|
||||
result = self._apicall('host.get', {
|
||||
'output': ['hostid', 'host', 'name'],
|
||||
'filter': {'ip': ip},
|
||||
'selectInterfaces': ['ip']
|
||||
})
|
||||
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] if result else None
|
||||
return result[0].get("hostid")
|
||||
return None
|
||||
|
||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Get printer supply levels by IP address.
|
||||
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"
|
||||
|
||||
Returns list of supplies with name and level percentage.
|
||||
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.
|
||||
"""
|
||||
# Find host by IP
|
||||
host = self.gethostbyip(ip)
|
||||
if not host:
|
||||
logger.debug(f"No Zabbix host found for IP {ip}")
|
||||
hostid = self.gethostidbyip(ip)
|
||||
if not hostid:
|
||||
logger.debug("No Zabbix host for IP %s", ip)
|
||||
return None
|
||||
|
||||
hostid = host['hostid']
|
||||
|
||||
# Get supply-related items
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'key_': 'supply' # Common key pattern for printer supplies
|
||||
},
|
||||
'searchWildcardsEnabled': True
|
||||
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:
|
||||
# Try alternate patterns
|
||||
items = self._apicall('item.get', {
|
||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
||||
'hostids': hostid,
|
||||
'search': {
|
||||
'name': 'toner'
|
||||
},
|
||||
'searchWildcardsEnabled': 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)))
|
||||
level = int(float(item.get("lastvalue", 0)))
|
||||
except (ValueError, TypeError):
|
||||
level = 0
|
||||
|
||||
supplies.append({
|
||||
'name': item.get('name', 'Unknown'),
|
||||
'level': level,
|
||||
'itemid': item.get('itemid'),
|
||||
'key': item.get('key_'),
|
||||
"name": item.get("name", "Unknown"),
|
||||
"level": level,
|
||||
"color": self._extract_color(item),
|
||||
"itemid": item.get("itemid"),
|
||||
})
|
||||
|
||||
return supplies
|
||||
|
||||
def gethostid(self, ip: str) -> Optional[str]:
|
||||
"""Get Zabbix host ID for an IP address."""
|
||||
host = self.gethostbyip(ip)
|
||||
return host['hostid'] if host else None
|
||||
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]]:
|
||||
"""Get printer supply levels with caching (10-minute TTL)."""
|
||||
cache_key = f'zabbix_supplies_{ip}'
|
||||
"""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):
|
||||
"""Clear cached supply data for one IP or all."""
|
||||
"""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(f"zabbix_supplies_{ip}")
|
||||
cache.delete("printers_low_supplies")
|
||||
cache.delete("zabbix_reachable")
|
||||
|
||||
Reference in New Issue
Block a user