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:
44
plugins/printers/migrations/versions/0002_modelsupplies.py
Normal file
44
plugins/printers/migrations/versions/0002_modelsupplies.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""printers plugin: add modelsupplies table
|
||||||
|
|
||||||
|
Data-driven model -> toner/drum/waste part-number mapping. Replaces the old
|
||||||
|
hardcoded supply_parts table.
|
||||||
|
|
||||||
|
Revision ID: 0002_modelsupplies_printers
|
||||||
|
Revises: 0001_baseline_printers
|
||||||
|
Create Date: 2026-06-25
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = '0002_modelsupplies_printers'
|
||||||
|
down_revision = '0001_baseline_printers'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'modelsupplies',
|
||||||
|
sa.Column('modelsupplyid', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('modelnumberid', sa.Integer,
|
||||||
|
sa.ForeignKey('models.modelnumberid'), nullable=False),
|
||||||
|
sa.Column('supplytype', sa.String(20), nullable=False, server_default='toner'),
|
||||||
|
sa.Column('color', sa.String(20), nullable=False, server_default='none'),
|
||||||
|
sa.Column('capacitytier', sa.String(20), nullable=False, server_default='standard'),
|
||||||
|
sa.Column('partnumber', sa.String(50), nullable=False),
|
||||||
|
sa.Column('marketingname', sa.String(120)),
|
||||||
|
sa.Column('pageyield', sa.Integer),
|
||||||
|
sa.Column('notes', sa.Text),
|
||||||
|
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('isactive', sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.UniqueConstraint('modelnumberid', 'partnumber', name='uq_modelsupply_part'),
|
||||||
|
)
|
||||||
|
op.create_index('idx_modelsupplies_modelnumberid', 'modelsupplies', ['modelnumberid'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_modelsupplies_modelnumberid', table_name='modelsupplies')
|
||||||
|
op.drop_table('modelsupplies')
|
||||||
@@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
from .printer_extension import PrinterData # Legacy model for Machine-based architecture
|
from .printer_extension import PrinterData # Legacy model for Machine-based architecture
|
||||||
from .printer import Printer, PrinterType # New Asset-based models
|
from .printer import Printer, PrinterType # New Asset-based models
|
||||||
|
from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||||
|
ModelSupply,
|
||||||
|
SUPPLY_TYPES,
|
||||||
|
SUPPLY_COLORS,
|
||||||
|
CAPACITY_TIERS,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'PrinterData', # Legacy
|
'PrinterData', # Legacy
|
||||||
'Printer', # New
|
'Printer', # New
|
||||||
'PrinterType', # New
|
'PrinterType', # New
|
||||||
|
'ModelSupply',
|
||||||
|
'SUPPLY_TYPES',
|
||||||
|
'SUPPLY_COLORS',
|
||||||
|
'CAPACITY_TIERS',
|
||||||
]
|
]
|
||||||
|
|||||||
57
plugins/printers/models/model_supply.py
Normal file
57
plugins/printers/models/model_supply.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""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.extensions import db
|
||||||
|
from shopdb.core.models.base import 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
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
"""Printers plugin services."""
|
"""Printers plugin services."""
|
||||||
|
|
||||||
from .zabbix_service import ZabbixService
|
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
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@@ -12,53 +30,59 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class ZabbixService:
|
class ZabbixService:
|
||||||
"""
|
"""Zabbix API client for printer supply and ping lookups."""
|
||||||
Zabbix API service for real-time printer supply lookups.
|
|
||||||
|
|
||||||
Queries Zabbix by IP address to get current supply levels.
|
CACHE_TTL = 300 # 5 min, matches the classic Application cache
|
||||||
Use getsuppliesbyip_cached() for cached lookups or
|
REACHABLE_CHECK_TTL = 60
|
||||||
getsuppliesbyip() for live data.
|
|
||||||
|
|
||||||
Configuration:
|
# quick fail for the reachability probe
|
||||||
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
|
REACHABLE_TIMEOUT = 1.0
|
||||||
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
|
# (connect, read) for real API calls; item.get is slow, give it room
|
||||||
ZABBIX_TOKEN: Zabbix API authentication token
|
API_TIMEOUT = (3.0, 5.0)
|
||||||
"""
|
|
||||||
|
|
||||||
CACHE_TTL = 600 # 10 minutes
|
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
|
||||||
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
|
SUPPLY_TAGS = [
|
||||||
|
{"tag": "component", "value": "supplies", "operator": 0},
|
||||||
|
{"tag": "type", "value": "level", "operator": 0},
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._url = None
|
self._url = None
|
||||||
self._token = None
|
self._token = None
|
||||||
self._enabled = None
|
|
||||||
|
# -- configuration -------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isenabled(self) -> bool:
|
def isenabled(self) -> bool:
|
||||||
"""Check if Zabbix integration is enabled."""
|
"""Whether the integration is switched on."""
|
||||||
# Check database setting first, fall back to env var
|
|
||||||
from shopdb.core.models import Setting
|
from shopdb.core.models import Setting
|
||||||
db_enabled = Setting.get('zabbix_enabled')
|
db_enabled = Setting.get('zabbix_enabled')
|
||||||
if db_enabled is not None:
|
if db_enabled is not None:
|
||||||
return bool(db_enabled)
|
return bool(db_enabled)
|
||||||
# Fall back to env var for backwards compatibility
|
|
||||||
return current_app.config.get('ZABBIX_ENABLED', False)
|
return current_app.config.get('ZABBIX_ENABLED', False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isconfigured(self) -> bool:
|
def isconfigured(self) -> bool:
|
||||||
"""Check if Zabbix is enabled and configured."""
|
"""Enabled, and a URL plus token are present."""
|
||||||
if not self.isenabled:
|
if not self.isenabled:
|
||||||
return False
|
return False
|
||||||
# Check database settings first, fall back to env vars
|
|
||||||
from shopdb.core.models import Setting
|
from shopdb.core.models import Setting
|
||||||
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
|
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')
|
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
|
||||||
return bool(self._url and self._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
|
@property
|
||||||
def isreachable(self) -> bool:
|
def isreachable(self) -> bool:
|
||||||
"""Check if Zabbix is reachable (cached for 60 seconds)."""
|
"""Cheap connectivity probe, cached for 60s."""
|
||||||
if not self.isenabled or not self.isconfigured:
|
if not self.isconfigured:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
cache_key = 'zabbix_reachable'
|
cache_key = 'zabbix_reachable'
|
||||||
@@ -66,138 +90,165 @@ class ZabbixService:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Quick connectivity check with 500ms timeout
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
|
||||||
f"{self._url}/api_jsonrpc.php",
|
# any non-5xx answer means the web tier responded, so the server is
|
||||||
timeout=0.5
|
# 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 in (200, 401, 403, 405)
|
reachable = response.status_code < 500
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
reachable = False
|
reachable = False
|
||||||
|
|
||||||
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
|
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
|
return reachable
|
||||||
|
|
||||||
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
|
# -- low level call ------------------------------------------------------
|
||||||
"""Make a Zabbix API 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:
|
if not self.isconfigured:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'jsonrpc': '2.0',
|
"jsonrpc": "2.0",
|
||||||
'method': method,
|
"method": method,
|
||||||
'params': params,
|
"params": params,
|
||||||
'auth': self._token,
|
"id": 1,
|
||||||
'id': 1
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json-rpc",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self._url}/api_jsonrpc.php",
|
self.endpoint,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={'Content-Type': 'application/json'},
|
headers=headers,
|
||||||
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
|
timeout=self.API_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
if 'error' in data:
|
logger.error("Zabbix %s call failed: %s", method, exc)
|
||||||
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}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def gethostbyip(self, ip: str) -> Optional[Dict]:
|
if "error" in data:
|
||||||
"""Find a Zabbix host by IP address."""
|
logger.error("Zabbix %s error: %s", method, data["error"])
|
||||||
result = self._apicall('host.get', {
|
return None
|
||||||
'output': ['hostid', 'host', 'name'],
|
|
||||||
'filter': {'ip': ip},
|
|
||||||
'selectInterfaces': ['ip']
|
|
||||||
})
|
|
||||||
|
|
||||||
|
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:
|
if result:
|
||||||
return result[0] if result else None
|
return result[0].get("hostid")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
|
def _extract_color(self, item: Dict) -> str:
|
||||||
"""
|
"""Pull and normalise the color tag, falling back to the item name."""
|
||||||
Get printer supply levels by IP address.
|
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
|
hostid = self.gethostidbyip(ip)
|
||||||
host = self.gethostbyip(ip)
|
if not hostid:
|
||||||
if not host:
|
logger.debug("No Zabbix host for IP %s", ip)
|
||||||
logger.debug(f"No Zabbix host found for IP {ip}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
hostid = host['hostid']
|
items = self._apicall("item.get", {
|
||||||
|
"output": ["itemid", "name", "lastvalue", "lastclock",
|
||||||
# Get supply-related items
|
"units", "status", "state"],
|
||||||
items = self._apicall('item.get', {
|
"hostids": hostid,
|
||||||
'output': ['itemid', 'name', 'lastvalue', 'key_'],
|
"selectTags": "extend",
|
||||||
'hostids': hostid,
|
"evaltype": 0, # and
|
||||||
'search': {
|
"tags": self.SUPPLY_TAGS,
|
||||||
'key_': 'supply' # Common key pattern for printer supplies
|
"sortfield": "name",
|
||||||
},
|
"monitored": True,
|
||||||
'searchWildcardsEnabled': 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:
|
if not items:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
supplies = []
|
supplies = []
|
||||||
for item in items:
|
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:
|
try:
|
||||||
level = int(float(item.get('lastvalue', 0)))
|
level = int(float(item.get("lastvalue", 0)))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
level = 0
|
level = 0
|
||||||
|
|
||||||
supplies.append({
|
supplies.append({
|
||||||
'name': item.get('name', 'Unknown'),
|
"name": item.get("name", "Unknown"),
|
||||||
'level': level,
|
"level": level,
|
||||||
'itemid': item.get('itemid'),
|
"color": self._extract_color(item),
|
||||||
'key': item.get('key_'),
|
"itemid": item.get("itemid"),
|
||||||
})
|
})
|
||||||
|
|
||||||
return supplies
|
return supplies
|
||||||
|
|
||||||
def gethostid(self, ip: str) -> Optional[str]:
|
def getpingstatus(self, ip: str) -> str:
|
||||||
"""Get Zabbix host ID for an IP address."""
|
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||||
host = self.gethostbyip(ip)
|
hostid = self.gethostidbyip(ip)
|
||||||
return host['hostid'] if host else None
|
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]]:
|
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
|
||||||
"""Get printer supply levels with caching (10-minute TTL)."""
|
"""getsuppliesbyip with a 5-minute per-IP cache."""
|
||||||
cache_key = f'zabbix_supplies_{ip}'
|
cache_key = f"zabbix_supplies_{ip}"
|
||||||
result = cache.get(cache_key)
|
result = cache.get(cache_key)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result = self.getsuppliesbyip(ip)
|
result = self.getsuppliesbyip(ip)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
cache.set(cache_key, result, timeout=self.CACHE_TTL)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def clearcache(self, ip: str = None):
|
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:
|
if ip:
|
||||||
cache.delete(f'zabbix_supplies_{ip}')
|
cache.delete(f"zabbix_supplies_{ip}")
|
||||||
cache.delete('printers_low_supplies')
|
cache.delete("printers_low_supplies")
|
||||||
|
cache.delete("zabbix_reachable")
|
||||||
|
|||||||
0
tests/test_plugins/__init__.py
Normal file
0
tests/test_plugins/__init__.py
Normal file
121
tests/test_plugins/test_modelsupplies.py
Normal file
121
tests/test_plugins/test_modelsupplies.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tests for the model-supplies (toner part-number) management API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model(db):
|
||||||
|
"""A vendor + printer model to attach supplies to."""
|
||||||
|
from shopdb.core.models import Vendor, Model
|
||||||
|
|
||||||
|
vendor = Vendor(vendor='TestVendor')
|
||||||
|
db.session.add(vendor)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid)
|
||||||
|
db.session.add(model)
|
||||||
|
db.session.commit()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplies_meta_lists_allowed_values(client, db):
|
||||||
|
response = client.get('/api/printers/supplies/meta')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()['data']
|
||||||
|
assert 'toner' in data['supplytypes']
|
||||||
|
assert 'black' in data['colors']
|
||||||
|
assert 'metered' in data['capacitytiers']
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_list_model_supply(client, model, auth_headers):
|
||||||
|
create = client.post(
|
||||||
|
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json={
|
||||||
|
'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard',
|
||||||
|
'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert create.status_code == 201
|
||||||
|
|
||||||
|
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||||
|
assert listing.status_code == 200
|
||||||
|
supplies = listing.get_json()['data']['supplies']
|
||||||
|
assert len(supplies) == 1
|
||||||
|
assert supplies[0]['partnumber'] == 'W2020A'
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_partnumber_rejected(client, model, auth_headers):
|
||||||
|
payload = {'partnumber': 'W2020A', 'color': 'black'}
|
||||||
|
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json=payload, headers=auth_headers)
|
||||||
|
second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json=payload, headers=auth_headers)
|
||||||
|
assert second.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_enum_rejected(client, model, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json={'partnumber': 'X1', 'color': 'purple'},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_requires_auth(client, model):
|
||||||
|
response = client.post(
|
||||||
|
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json={'partnumber': 'X1'},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_and_delete_supply(client, model, auth_headers):
|
||||||
|
created = client.post(
|
||||||
|
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'},
|
||||||
|
headers=auth_headers,
|
||||||
|
).get_json()['data']
|
||||||
|
supplyid = created['modelsupplyid']
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f'/api/printers/supplies/{supplyid}',
|
||||||
|
json={'capacitytier': 'high', 'marketingname': '414X Black'},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.get_json()['data']['capacitytier'] == 'high'
|
||||||
|
|
||||||
|
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
|
||||||
|
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||||
|
assert listing.get_json()['data']['supplies'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_listmodels_reports_supplycount(client, model, auth_headers):
|
||||||
|
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||||
|
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = client.get('/api/printers/models', query_string={'search': 'C999'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.get_json()['data']
|
||||||
|
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
||||||
|
assert match['supplycount'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_supplies_corrected_data(app, db):
|
||||||
|
"""The seed loads corrected part numbers (C405 colors, no B405 waste)."""
|
||||||
|
with app.app_context():
|
||||||
|
from plugins.printers.services import seedsupplies, lookupsupplies
|
||||||
|
from shopdb.core.models import Model
|
||||||
|
|
||||||
|
seedsupplies()
|
||||||
|
|
||||||
|
c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first()
|
||||||
|
yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner')
|
||||||
|
assert any(s['partnumber'] == '106R03501' for s in yellow)
|
||||||
|
|
||||||
|
b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first()
|
||||||
|
assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == []
|
||||||
142
tests/test_plugins/test_zabbix_live.py
Normal file
142
tests/test_plugins/test_zabbix_live.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""End-to-end test of ZabbixService against the mock Zabbix JSON-RPC server.
|
||||||
|
|
||||||
|
Exercises the real HTTP path: Bearer auth, host.get by IP, tag-filtered
|
||||||
|
item.get, supply parsing, ping, and the low-supplies roll-up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools.mock_zabbix import serve_in_thread
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port():
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(('127.0.0.1', 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_zabbix(app):
|
||||||
|
"""Boot the mock server and point app config at it."""
|
||||||
|
port = _free_port()
|
||||||
|
server = serve_in_thread(port, 'testtoken')
|
||||||
|
app.config['ZABBIX_ENABLED'] = True
|
||||||
|
app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}'
|
||||||
|
app.config['ZABBIX_TOKEN'] = 'testtoken'
|
||||||
|
yield
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_reachable_and_configured(app, db, mock_zabbix):
|
||||||
|
from plugins.printers.services import ZabbixService
|
||||||
|
with app.app_context():
|
||||||
|
service = ZabbixService()
|
||||||
|
assert service.isconfigured
|
||||||
|
assert service.isreachable
|
||||||
|
|
||||||
|
|
||||||
|
def test_gethostid_by_ip(app, db, mock_zabbix):
|
||||||
|
from plugins.printers.services import ZabbixService
|
||||||
|
with app.app_context():
|
||||||
|
service = ZabbixService()
|
||||||
|
assert service.gethostidbyip('10.20.30.40') == '10501'
|
||||||
|
assert service.gethostidbyip('1.2.3.4') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplies_parsed_with_color_and_status_filter(app, db, mock_zabbix):
|
||||||
|
from plugins.printers.services import ZabbixService
|
||||||
|
with app.app_context():
|
||||||
|
supplies = ZabbixService().getsuppliesbyip('10.20.30.40')
|
||||||
|
# disabled item (status=1) dropped, three active remain
|
||||||
|
names = {s['name'] for s in supplies}
|
||||||
|
assert 'Disabled Drum Level' not in names
|
||||||
|
assert len(supplies) == 3
|
||||||
|
black = next(s for s in supplies if s['name'].startswith('Black'))
|
||||||
|
assert black['color'] == 'black'
|
||||||
|
assert black['level'] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_status(app, db, mock_zabbix):
|
||||||
|
from plugins.printers.services import ZabbixService
|
||||||
|
with app.app_context():
|
||||||
|
assert ZabbixService().getpingstatus('10.20.30.40') == '1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_token_returns_no_data(app, db):
|
||||||
|
"""Wrong token -> API errors -> service returns nothing, fails soft."""
|
||||||
|
port = _free_port()
|
||||||
|
server = serve_in_thread(port, 'rightsecret')
|
||||||
|
app.config['ZABBIX_ENABLED'] = True
|
||||||
|
app.config['ZABBIX_URL'] = f'http://127.0.0.1:{port}'
|
||||||
|
app.config['ZABBIX_TOKEN'] = 'wrongsecret'
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
assert ZabbixService_gethost(app) is None
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def ZabbixService_gethost(app):
|
||||||
|
from plugins.printers.services import ZabbixService
|
||||||
|
return ZabbixService().gethostidbyip('10.20.30.40')
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_supplies_rollup_flags_waste_and_toner(app, db, mock_zabbix):
|
||||||
|
"""The mock host has a 4% black toner and a 97%-full waste -> both flagged."""
|
||||||
|
from shopdb.core.models import (
|
||||||
|
Vendor, Model, Asset, AssetType, Communication, CommunicationType
|
||||||
|
)
|
||||||
|
from plugins.printers.models import Printer
|
||||||
|
from plugins.printers.api.asset_routes import _get_low_supplies_data
|
||||||
|
from shopdb.extensions import cache
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# an HP printer at the mock's known IP
|
||||||
|
vendor = Vendor(vendor='HP')
|
||||||
|
db.session.add(vendor)
|
||||||
|
db.session.flush()
|
||||||
|
model = Model(modelnumber='HP M454', vendorid=vendor.vendorid)
|
||||||
|
db.session.add(model)
|
||||||
|
|
||||||
|
atype = AssetType.query.filter_by(assettype='printer').first()
|
||||||
|
if not atype:
|
||||||
|
atype = AssetType(assettype='printer', pluginname='printers',
|
||||||
|
tablename='printers')
|
||||||
|
db.session.add(atype)
|
||||||
|
db.session.flush()
|
||||||
|
asset = Asset(assetnumber='PRN-1', name='Test Printer',
|
||||||
|
assettypeid=atype.assettypeid)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
printer = Printer(assetid=asset.assetid, vendorid=vendor.vendorid,
|
||||||
|
modelnumberid=model.modelnumberid)
|
||||||
|
db.session.add(printer)
|
||||||
|
|
||||||
|
comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||||
|
if not comtype:
|
||||||
|
comtype = CommunicationType(comtype='IP')
|
||||||
|
db.session.add(comtype)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(Communication(assetid=asset.assetid,
|
||||||
|
comtypeid=comtype.comtypeid,
|
||||||
|
ipaddress='10.20.30.40', isprimary=True))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
cache.delete('printers_low_supplies')
|
||||||
|
data = _get_low_supplies_data()
|
||||||
|
|
||||||
|
assert data['summary']['total_checked'] == 1
|
||||||
|
assert len(data['printers']) == 1
|
||||||
|
row = data['printers'][0]
|
||||||
|
statuses = {s['name']: s['status'] for s in row['supplies']}
|
||||||
|
# 4% black toner is critical
|
||||||
|
assert statuses['Black Toner Level'] == 'critical'
|
||||||
|
# 97%-full waste (HP, non-inverted) -> 3% remaining -> critical
|
||||||
|
assert statuses['Waste Cartridge Level'] == 'critical'
|
||||||
|
# 60% cyan is fine
|
||||||
|
assert statuses['Cyan Toner Level'] == 'ok'
|
||||||
61
tools/docker-compose.zabbix.yml
Normal file
61
tools/docker-compose.zabbix.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Real Zabbix 7.0 server for full integration testing.
|
||||||
|
#
|
||||||
|
# docker compose -f tools/docker-compose.zabbix.yml up -d
|
||||||
|
#
|
||||||
|
# Web UI: http://localhost:8888 (default login Admin / zabbix)
|
||||||
|
# API: http://localhost:8888/api_jsonrpc.php
|
||||||
|
#
|
||||||
|
# After it is up:
|
||||||
|
# 1. Log in, go to Users > API tokens, create a token, copy it.
|
||||||
|
# 2. Data collection > Hosts > Create host. Name the host by its IP
|
||||||
|
# (e.g. 10.20.30.40) so host.get filter {host:[ip]} finds it, the same
|
||||||
|
# way the production shop Zabbix names printer hosts.
|
||||||
|
# 3. Add items tagged component=supplies, type=level, color=<color> to
|
||||||
|
# mirror the real printer templates (the mock server documents the shape).
|
||||||
|
# 4. Point the app at it:
|
||||||
|
# ZABBIX_ENABLED=true
|
||||||
|
# ZABBIX_URL=http://localhost:8888
|
||||||
|
# ZABBIX_TOKEN=<token from step 1>
|
||||||
|
# or set zabbix_enabled / zabbix_url / zabbix_token in the settings table.
|
||||||
|
#
|
||||||
|
# For API-contract testing only, prefer tools/mock_zabbix.py - it needs no
|
||||||
|
# image pulls and returns ready-made tagged supply items.
|
||||||
|
|
||||||
|
services:
|
||||||
|
zabbix-postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: zabbix
|
||||||
|
POSTGRES_PASSWORD: zabbixpass
|
||||||
|
POSTGRES_DB: zabbix
|
||||||
|
volumes:
|
||||||
|
- zabbix-pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
zabbix-server:
|
||||||
|
image: zabbix/zabbix-server-pgsql:alpine-7.0-latest
|
||||||
|
environment:
|
||||||
|
DB_SERVER_HOST: zabbix-postgres
|
||||||
|
POSTGRES_USER: zabbix
|
||||||
|
POSTGRES_PASSWORD: zabbixpass
|
||||||
|
POSTGRES_DB: zabbix
|
||||||
|
depends_on:
|
||||||
|
- zabbix-postgres
|
||||||
|
ports:
|
||||||
|
- "10051:10051"
|
||||||
|
|
||||||
|
zabbix-web:
|
||||||
|
image: zabbix/zabbix-web-nginx-pgsql:alpine-7.0-latest
|
||||||
|
environment:
|
||||||
|
DB_SERVER_HOST: zabbix-postgres
|
||||||
|
POSTGRES_USER: zabbix
|
||||||
|
POSTGRES_PASSWORD: zabbixpass
|
||||||
|
POSTGRES_DB: zabbix
|
||||||
|
ZBX_SERVER_HOST: zabbix-server
|
||||||
|
PHP_TZ: America/New_York
|
||||||
|
depends_on:
|
||||||
|
- zabbix-server
|
||||||
|
ports:
|
||||||
|
- "8888:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
zabbix-pgdata:
|
||||||
144
tools/mock_zabbix.py
Normal file
144
tools/mock_zabbix.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Mock Zabbix 7.0 JSON-RPC server for testing the printer supply integration.
|
||||||
|
|
||||||
|
Speaks just enough of the Zabbix API (api_jsonrpc.php) to exercise our
|
||||||
|
ZabbixService end to end over real HTTP: Bearer token auth, host.get by IP,
|
||||||
|
and item.get returning printer supply items tagged the way the real Zabbix
|
||||||
|
templates tag them (component=supplies, type=level, color=<color>).
|
||||||
|
|
||||||
|
Run standalone:
|
||||||
|
python tools/mock_zabbix.py --port 18080 --token testtoken
|
||||||
|
|
||||||
|
Then point the app at it:
|
||||||
|
ZABBIX_ENABLED=true
|
||||||
|
ZABBIX_URL=http://localhost:18080
|
||||||
|
ZABBIX_TOKEN=testtoken
|
||||||
|
|
||||||
|
It is also imported by tests/test_plugins/test_zabbix_live.py, which boots it
|
||||||
|
in a background thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
# one fake host, named by its IP (matches how the real shop Zabbix names hosts)
|
||||||
|
HOSTS = {
|
||||||
|
'10.20.30.40': '10501',
|
||||||
|
}
|
||||||
|
|
||||||
|
# supply level items for host 10501, with Zabbix-style tags
|
||||||
|
SUPPLY_ITEMS = {
|
||||||
|
'10501': [
|
||||||
|
{'itemid': '1', 'name': 'Black Toner Level', 'lastvalue': '4',
|
||||||
|
'status': '0', 'state': '0',
|
||||||
|
'tags': [{'tag': 'component', 'value': 'supplies'},
|
||||||
|
{'tag': 'type', 'value': 'level'},
|
||||||
|
{'tag': 'color', 'value': 'black'}]},
|
||||||
|
{'itemid': '2', 'name': 'Cyan Toner Level', 'lastvalue': '60',
|
||||||
|
'status': '0', 'state': '0',
|
||||||
|
'tags': [{'tag': 'component', 'value': 'supplies'},
|
||||||
|
{'tag': 'type', 'value': 'level'},
|
||||||
|
{'tag': 'color', 'value': 'cyan'}]},
|
||||||
|
{'itemid': '3', 'name': 'Waste Cartridge Level', 'lastvalue': '97',
|
||||||
|
'status': '0', 'state': '0',
|
||||||
|
'tags': [{'tag': 'component', 'value': 'supplies'},
|
||||||
|
{'tag': 'type', 'value': 'level'}]},
|
||||||
|
{'itemid': '4', 'name': 'Disabled Drum Level', 'lastvalue': '0',
|
||||||
|
'status': '1', 'state': '0',
|
||||||
|
'tags': [{'tag': 'component', 'value': 'supplies'},
|
||||||
|
{'tag': 'type', 'value': 'level'}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
PING_ITEMS = {
|
||||||
|
'10501': '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_handler(token):
|
||||||
|
class ZabbixHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass # quiet
|
||||||
|
|
||||||
|
def _send(self, payload, code=200):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
# reachability probe hits the endpoint with GET
|
||||||
|
self._send({'jsonrpc': '2.0', 'error': {'code': -32600}, 'id': None})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
request = json.loads(self.rfile.read(length) or b'{}')
|
||||||
|
method = request.get('method')
|
||||||
|
params = request.get('params', {})
|
||||||
|
reqid = request.get('id', 1)
|
||||||
|
|
||||||
|
auth = self.headers.get('Authorization', '')
|
||||||
|
if auth != f'Bearer {token}':
|
||||||
|
self._send({'jsonrpc': '2.0',
|
||||||
|
'error': {'code': -32602, 'message': 'Not authorised'},
|
||||||
|
'id': reqid})
|
||||||
|
return
|
||||||
|
|
||||||
|
result = self._dispatch(method, params)
|
||||||
|
self._send({'jsonrpc': '2.0', 'result': result, 'id': reqid})
|
||||||
|
|
||||||
|
def _dispatch(self, method, params):
|
||||||
|
if method in ('apiinfo.version',):
|
||||||
|
return '7.0.0'
|
||||||
|
if method == 'hostgroup.get':
|
||||||
|
return [{'groupid': '1'}]
|
||||||
|
if method == 'host.get':
|
||||||
|
ips = (params.get('filter') or {}).get('host', [])
|
||||||
|
out = []
|
||||||
|
for ip in ips:
|
||||||
|
if ip in HOSTS:
|
||||||
|
out.append({'hostid': HOSTS[ip], 'host': ip, 'name': ip})
|
||||||
|
return out
|
||||||
|
if method == 'item.get':
|
||||||
|
hostids = params.get('hostids')
|
||||||
|
hostid = hostids[0] if isinstance(hostids, list) else hostids
|
||||||
|
search = params.get('search') or {}
|
||||||
|
if 'icmpping' in (search.get('key_') or ''):
|
||||||
|
value = PING_ITEMS.get(hostid)
|
||||||
|
return [{'lastvalue': value}] if value is not None else []
|
||||||
|
return SUPPLY_ITEMS.get(hostid, [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
return ZabbixHandler
|
||||||
|
|
||||||
|
|
||||||
|
def serve(port, token):
|
||||||
|
server = ThreadingHTTPServer(('127.0.0.1', port), build_handler(token))
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def serve_in_thread(port, token):
|
||||||
|
"""Start the mock in a daemon thread. Returns the server (call shutdown())."""
|
||||||
|
server = serve(port, token)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description='Mock Zabbix JSON-RPC server')
|
||||||
|
parser.add_argument('--port', type=int, default=18080)
|
||||||
|
parser.add_argument('--token', default='testtoken')
|
||||||
|
args = parser.parse_args()
|
||||||
|
httpd = serve(args.port, args.token)
|
||||||
|
print(f"Mock Zabbix on http://127.0.0.1:{args.port}/api_jsonrpc.php "
|
||||||
|
f"(token: {args.token})")
|
||||||
|
print(f"Known host: {list(HOSTS)[0]} -> supplies + icmpping")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
httpd.shutdown()
|
||||||
140
tools/setup_zabbix_fixture.py
Normal file
140
tools/setup_zabbix_fixture.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Provision the local docker Zabbix (tools/docker-compose.zabbix.yml) with a
|
||||||
|
printer host shaped like the real shop Zabbix, so ZabbixService can be tested
|
||||||
|
against a live server instead of the mock.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- an API token (printed at the end; put it in .env as ZABBIX_TOKEN)
|
||||||
|
- host group "Printers"
|
||||||
|
- host named by IP "10.20.30.40" with an SNMP-less agent interface
|
||||||
|
- trapper items tagged component=supplies, type=level, color=<c>
|
||||||
|
- an icmpping item
|
||||||
|
|
||||||
|
Item values are pushed afterwards with zabbix_sender (see seed_values()).
|
||||||
|
Run: python tools/setup_zabbix_fixture.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "http://localhost:8888/api_jsonrpc.php"
|
||||||
|
HOST_IP = "10.20.30.40"
|
||||||
|
ADMIN_USER = "Admin"
|
||||||
|
ADMIN_PASS = "zabbix"
|
||||||
|
|
||||||
|
# name, color tag, key (must be unique per host)
|
||||||
|
SUPPLY_ITEMS = [
|
||||||
|
("Black Toner Level", "black", "supply.black"),
|
||||||
|
("Cyan Toner Level", "cyan", "supply.cyan"),
|
||||||
|
("Magenta Toner Level", "magenta", "supply.magenta"),
|
||||||
|
("Yellow Toner Level", "yellow", "supply.yellow"),
|
||||||
|
("Waste Cartridge Level", "", "supply.waste"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, params, auth=None):
|
||||||
|
headers = {"Content-Type": "application/json-rpc"}
|
||||||
|
if auth:
|
||||||
|
headers["Authorization"] = f"Bearer {auth}"
|
||||||
|
resp = requests.post(
|
||||||
|
BASE,
|
||||||
|
json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if "error" in data:
|
||||||
|
raise RuntimeError(f"{method}: {data['error']}")
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
# user.login returns a session token; usable as Bearer in 7.0
|
||||||
|
return call("user.login", {"username": ADMIN_USER, "password": ADMIN_PASS})
|
||||||
|
|
||||||
|
|
||||||
|
def make_api_token(sess):
|
||||||
|
# a real, persistent API token (survives logout, matches prod usage)
|
||||||
|
existing = call("token.get", {"filter": {"name": "shopdb-flask-test"}}, sess)
|
||||||
|
if existing:
|
||||||
|
tokenid = existing[0]["tokenid"]
|
||||||
|
else:
|
||||||
|
created = call("token.create", {
|
||||||
|
"name": "shopdb-flask-test",
|
||||||
|
"userid": call("user.get", {"output": ["userid"],
|
||||||
|
"filter": {"username": ADMIN_USER}}, sess)[0]["userid"],
|
||||||
|
}, sess)
|
||||||
|
tokenid = created["tokenids"][0]
|
||||||
|
return call("token.generate", [tokenid], sess)[0]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_hostgroup(sess):
|
||||||
|
found = call("hostgroup.get", {"filter": {"name": "Printers"}}, sess)
|
||||||
|
if found:
|
||||||
|
return found[0]["groupid"]
|
||||||
|
return call("hostgroup.create", {"name": "Printers"}, sess)["groupids"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_host(sess, groupid):
|
||||||
|
found = call("host.get", {"filter": {"host": [HOST_IP]}, "output": ["hostid"]}, sess)
|
||||||
|
if found:
|
||||||
|
hostid = found[0]["hostid"]
|
||||||
|
# wipe existing items so re-runs are clean
|
||||||
|
items = call("item.get", {"hostids": hostid, "output": ["itemid"]}, sess)
|
||||||
|
if items:
|
||||||
|
call("item.delete", [i["itemid"] for i in items], sess)
|
||||||
|
return hostid
|
||||||
|
created = call("host.create", {
|
||||||
|
"host": HOST_IP,
|
||||||
|
"groups": [{"groupid": groupid}],
|
||||||
|
"interfaces": [{
|
||||||
|
"type": 1, "main": 1, "useip": 1,
|
||||||
|
"ip": HOST_IP, "dns": "", "port": "10050",
|
||||||
|
}],
|
||||||
|
}, sess)
|
||||||
|
return created["hostids"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def create_items(sess, hostid):
|
||||||
|
for name, color, key in SUPPLY_ITEMS:
|
||||||
|
tags = [
|
||||||
|
{"tag": "component", "value": "supplies"},
|
||||||
|
{"tag": "type", "value": "level"},
|
||||||
|
]
|
||||||
|
if color:
|
||||||
|
tags.append({"tag": "color", "value": color})
|
||||||
|
call("item.create", {
|
||||||
|
"name": name,
|
||||||
|
"key_": key,
|
||||||
|
"hostid": hostid,
|
||||||
|
"type": 2, # Zabbix trapper, lets zabbix_sender push values
|
||||||
|
"value_type": 3, # unsigned int
|
||||||
|
"tags": tags,
|
||||||
|
}, sess)
|
||||||
|
# ping item, untagged, key icmpping
|
||||||
|
call("item.create", {
|
||||||
|
"name": "ICMP ping",
|
||||||
|
"key_": "icmpping",
|
||||||
|
"hostid": hostid,
|
||||||
|
"type": 2,
|
||||||
|
"value_type": 3,
|
||||||
|
}, sess)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sess = login()
|
||||||
|
token = make_api_token(sess)
|
||||||
|
groupid = ensure_hostgroup(sess)
|
||||||
|
hostid = ensure_host(sess, groupid)
|
||||||
|
create_items(sess, hostid)
|
||||||
|
print("OK")
|
||||||
|
print(f"hostid={hostid}")
|
||||||
|
print(f"ZABBIX_TOKEN={token}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"FAILED: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
55
tools/shot.py
Normal file
55
tools/shot.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Headless-Chromium screenshot helper for the dev UI.
|
||||||
|
|
||||||
|
Logs in once via the API, injects the token into localStorage the same way
|
||||||
|
the auth store does, then screenshots each path passed on the command line.
|
||||||
|
|
||||||
|
venv/bin/python tools/shot.py /printers/1 /reports/toner /printers/1/edit
|
||||||
|
|
||||||
|
Images land in the scratchpad dir as shot_<sanitised-path>.png.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
UI = "http://localhost:5173"
|
||||||
|
API = "http://localhost:5001/api"
|
||||||
|
USERNAME = "270015376"
|
||||||
|
PASSWORD = "changeme"
|
||||||
|
OUTDIR = "/tmp/claude-1000/-home-camp-projects/effc3424-ed5e-4b09-b83e-d141bee23c42/scratchpad"
|
||||||
|
|
||||||
|
|
||||||
|
def login():
|
||||||
|
body = json.dumps({"username": USERNAME, "password": PASSWORD}).encode()
|
||||||
|
loginrequest = urllib.request.Request(f"{API}/auth/login", data=body,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
data = json.load(urllib.request.urlopen(loginrequest))["data"]
|
||||||
|
return data["access_token"], data["refresh_token"], data["user"]
|
||||||
|
|
||||||
|
|
||||||
|
def main(paths):
|
||||||
|
token, refresh, user = login()
|
||||||
|
seed = f"""
|
||||||
|
localStorage.setItem('token', {json.dumps(token)});
|
||||||
|
localStorage.setItem('refreshToken', {json.dumps(refresh)});
|
||||||
|
localStorage.setItem('user', {json.dumps(json.dumps(user))});
|
||||||
|
"""
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||||
|
context.add_init_script(seed)
|
||||||
|
page = context.new_page()
|
||||||
|
for path in paths:
|
||||||
|
page.goto(f"{UI}{path}", wait_until="networkidle", timeout=30000)
|
||||||
|
page.wait_for_timeout(1200) # let supply fetch + render settle
|
||||||
|
name = "shot_" + (path.strip("/").replace("/", "_") or "home") + ".png"
|
||||||
|
out = f"{OUTDIR}/{name}"
|
||||||
|
page.screenshot(path=out, full_page=True)
|
||||||
|
print(out)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:] or ["/printers/1"])
|
||||||
Reference in New Issue
Block a user