From f1b3b65532d00f47c9ff2f59ff46bea2c846a19f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 08:35:02 -0400 Subject: [PATCH] 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 --- .../migrations/versions/0002_modelsupplies.py | 44 +++ plugins/printers/models/__init__.py | 10 + plugins/printers/models/model_supply.py | 57 +++ plugins/printers/services/__init__.py | 16 +- plugins/printers/services/seed_supplies.py | 362 ++++++++++++++++++ plugins/printers/services/supply_parts.py | 107 ++++++ plugins/printers/services/zabbix_service.py | 255 +++++++----- tests/test_plugins/__init__.py | 0 tests/test_plugins/test_modelsupplies.py | 121 ++++++ tests/test_plugins/test_zabbix_live.py | 142 +++++++ tools/docker-compose.zabbix.yml | 61 +++ tools/mock_zabbix.py | 144 +++++++ tools/setup_zabbix_fixture.py | 140 +++++++ tools/shot.py | 55 +++ 14 files changed, 1411 insertions(+), 103 deletions(-) create mode 100644 plugins/printers/migrations/versions/0002_modelsupplies.py create mode 100644 plugins/printers/models/model_supply.py create mode 100644 plugins/printers/services/seed_supplies.py create mode 100644 plugins/printers/services/supply_parts.py create mode 100644 tests/test_plugins/__init__.py create mode 100644 tests/test_plugins/test_modelsupplies.py create mode 100644 tests/test_plugins/test_zabbix_live.py create mode 100644 tools/docker-compose.zabbix.yml create mode 100644 tools/mock_zabbix.py create mode 100644 tools/setup_zabbix_fixture.py create mode 100644 tools/shot.py diff --git a/plugins/printers/migrations/versions/0002_modelsupplies.py b/plugins/printers/migrations/versions/0002_modelsupplies.py new file mode 100644 index 0000000..9c1feb6 --- /dev/null +++ b/plugins/printers/migrations/versions/0002_modelsupplies.py @@ -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') diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index 93b2333..c21cdc0 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -2,9 +2,19 @@ from .printer_extension import PrinterData # Legacy model for Machine-based architecture 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__ = [ 'PrinterData', # Legacy 'Printer', # New 'PrinterType', # New + 'ModelSupply', + 'SUPPLY_TYPES', + 'SUPPLY_COLORS', + 'CAPACITY_TIERS', ] diff --git a/plugins/printers/models/model_supply.py b/plugins/printers/models/model_supply.py new file mode 100644 index 0000000..398cc93 --- /dev/null +++ b/plugins/printers/models/model_supply.py @@ -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"" + + def to_dict(self): + data = super().to_dict() + if self.model: + data['modelnumber'] = self.model.modelnumber + return data diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 7791543..6a78fa9 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -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', +] diff --git a/plugins/printers/services/seed_supplies.py b/plugins/printers/services/seed_supplies.py new file mode 100644 index 0000000..9bfcb67 --- /dev/null +++ b/plugins/printers/services/seed_supplies.py @@ -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} diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py new file mode 100644 index 0000000..178b109 --- /dev/null +++ b/plugins/printers/services/supply_parts.py @@ -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] diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py index a54563c..d2465bb 100644 --- a/plugins/printers/services/zabbix_service.py +++ b/plugins/printers/services/zabbix_service.py @@ -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 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") diff --git a/tests/test_plugins/__init__.py b/tests/test_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_plugins/test_modelsupplies.py b/tests/test_plugins/test_modelsupplies.py new file mode 100644 index 0000000..f83d6e7 --- /dev/null +++ b/tests/test_plugins/test_modelsupplies.py @@ -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') == [] diff --git a/tests/test_plugins/test_zabbix_live.py b/tests/test_plugins/test_zabbix_live.py new file mode 100644 index 0000000..5f87f24 --- /dev/null +++ b/tests/test_plugins/test_zabbix_live.py @@ -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' diff --git a/tools/docker-compose.zabbix.yml b/tools/docker-compose.zabbix.yml new file mode 100644 index 0000000..d1bfcec --- /dev/null +++ b/tools/docker-compose.zabbix.yml @@ -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= 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= +# 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: diff --git a/tools/mock_zabbix.py b/tools/mock_zabbix.py new file mode 100644 index 0000000..de4e1ac --- /dev/null +++ b/tools/mock_zabbix.py @@ -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=). + +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() diff --git a/tools/setup_zabbix_fixture.py b/tools/setup_zabbix_fixture.py new file mode 100644 index 0000000..929c913 --- /dev/null +++ b/tools/setup_zabbix_fixture.py @@ -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= + - 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) diff --git a/tools/shot.py b/tools/shot.py new file mode 100644 index 0000000..0766fd3 --- /dev/null +++ b/tools/shot.py @@ -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_.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"])