From f1b3b65532d00f47c9ff2f59ff46bea2c846a19f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 08:35:02 -0400 Subject: [PATCH 01/32] 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"]) From 4626280dc4a5114ebbc92b1a5d14e9a8588a4811 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 08:35:03 -0400 Subject: [PATCH 02/32] Add gauge lab + maintenance asset references with enable/disable toggles Dedicated assets.gaugelabreference and assets.maintenancereference columns (distinct from assetnumber), surfaced on equipment. Add global per-identifier enable/disable settings (gauge/maintenance/FQDN) read via a shared composable and toggled in System Settings. Co-Authored-By: Claude Opus 4.8 --- .../src/composables/identifierSettings.js | 36 +++++++++++ .../src/views/settings/SystemSettings.vue | 60 ++++++++++++++++++- migrations/versions/7b02_gaugelabreference.py | 44 ++++++++++++++ shopdb/core/api/settings.py | 22 +++++++ shopdb/core/models/asset.py | 11 ++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 frontend/src/composables/identifierSettings.js create mode 100644 migrations/versions/7b02_gaugelabreference.py diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js new file mode 100644 index 0000000..1e7fd87 --- /dev/null +++ b/frontend/src/composables/identifierSettings.js @@ -0,0 +1,36 @@ +// Global enable/disable flags for optional asset identifiers, read from the +// settings table. Loaded once and shared across components. Defaults to +// enabled when a flag is missing so a fresh install shows the identifiers. +import { reactive } from 'vue' +import { settingsApi } from '../api' + +const identifierflags = reactive({ + gaugelabreference: true, + maintenancereference: true, + fqdn: true, + loaded: false +}) + +let inflight = null + +export function useIdentifierFlags() { + if (!identifierflags.loaded && !inflight) { + inflight = settingsApi.list() + .then(({ data }) => { + const map = {} + ;(data.data || []).forEach(s => { map[s.key] = s.value }) + if ('identifier_gaugelabreference_enabled' in map) { + identifierflags.gaugelabreference = map.identifier_gaugelabreference_enabled !== false + } + if ('identifier_maintenancereference_enabled' in map) { + identifierflags.maintenancereference = map.identifier_maintenancereference_enabled !== false + } + if ('identifier_fqdn_enabled' in map) { + identifierflags.fqdn = map.identifier_fqdn_enabled !== false + } + identifierflags.loaded = true + }) + .catch(() => { identifierflags.loaded = true }) + } + return identifierflags +} diff --git a/frontend/src/views/settings/SystemSettings.vue b/frontend/src/views/settings/SystemSettings.vue index 95c0671..9e0cd03 100644 --- a/frontend/src/views/settings/SystemSettings.vue +++ b/frontend/src/views/settings/SystemSettings.vue @@ -375,6 +375,60 @@ + + +
+

Asset Identifiers

+ +
+

+ Enable or disable optional asset identifiers. When disabled, the identifier + is hidden from asset forms and detail pages across the system. +

+ +
+ +
+ +
+ +
+ +
+ +
+
+
{{ error }}
@@ -409,7 +463,11 @@ const settings = reactive({ saml_acs_url: '', saml_allow_local_login: true, saml_auto_create_users: true, - saml_admin_group: '' + saml_admin_group: '', + // Asset identifiers + identifier_gaugelabreference_enabled: true, + identifier_maintenancereference_enabled: true, + identifier_fqdn_enabled: true }) const loading = ref(true) diff --git a/migrations/versions/7b02_gaugelabreference.py b/migrations/versions/7b02_gaugelabreference.py new file mode 100644 index 0000000..165b98b --- /dev/null +++ b/migrations/versions/7b02_gaugelabreference.py @@ -0,0 +1,44 @@ +"""Add Asset.gaugelabreference and Asset.maintenancereference + +Adds dedicated external-reference columns to assets, distinct from +assetnumber. Equipment commonly carries an authoritative gauge lab reference +and a maintenance system reference; assetnumber stays the generic asset tag +and name the layperson label. + +Revision ID: 7b02_gaugelabref +Revises: 7a01_adr001_position +Create Date: 2026-06-25 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7b02_gaugelabref' +down_revision = '7a01_adr001_position' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('assets') as batch_op: + batch_op.add_column(sa.Column( + 'gaugelabreference', sa.String(length=50), nullable=True, + comment='Gauge lab asset reference (authoritative tag the gauge ' + 'lab assigns to equipment); distinct from assetnumber')) + batch_op.add_column(sa.Column( + 'maintenancereference', sa.String(length=50), nullable=True, + comment='Maintenance system asset reference; distinct from ' + 'assetnumber')) + batch_op.create_index('ix_assets_gaugelabreference', + ['gaugelabreference']) + batch_op.create_index('ix_assets_maintenancereference', + ['maintenancereference']) + + +def downgrade(): + with op.batch_alter_table('assets') as batch_op: + batch_op.drop_index('ix_assets_maintenancereference') + batch_op.drop_index('ix_assets_gaugelabreference') + batch_op.drop_column('maintenancereference') + batch_op.drop_column('gaugelabreference') diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 8d41844..023246f 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -134,6 +134,28 @@ def create_setting(): def seed_default_settings(): """Seed default settings if they don't exist.""" defaults = [ + # Asset identifier feature toggles (global, per identifier) + { + 'key': 'identifier_gaugelabreference_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': 'Show the Gauge Lab Reference identifier on assets' + }, + { + 'key': 'identifier_maintenancereference_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': 'Show the Maintenance Reference identifier on assets' + }, + { + 'key': 'identifier_fqdn_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': 'Show the FQDN / hostname identifier on assets' + }, # Zabbix integration { 'key': 'zabbix_enabled', diff --git a/shopdb/core/models/asset.py b/shopdb/core/models/asset.py index c76cf79..4b82ac2 100644 --- a/shopdb/core/models/asset.py +++ b/shopdb/core/models/asset.py @@ -74,6 +74,17 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin): db.String(100), comment='Display name/alias' ) + gaugelabreference = db.Column( + db.String(50), + index=True, + comment='Gauge lab asset reference (authoritative tag the gauge lab ' + 'assigns to equipment); distinct from assetnumber' + ) + maintenancereference = db.Column( + db.String(50), + index=True, + comment='Maintenance system asset reference; distinct from assetnumber' + ) serialnumber = db.Column( db.String(100), index=True, From 0436e8b0af8fb7046214e800a7eb6eddf49eebdc Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 08:35:32 -0400 Subject: [PATCH 03/32] Asset-side printer save, status CRUD, real printer types, network fix - Printers save through the asset blueprint (PUT /printers) instead of the legacy machines API; restrict supply-model picker to printer models. - Asset statuses get full CRUD (PUT/DELETE with in-use guard); canonical set. - Printer types reseeded to a real classification set + list filter. - Equipment accepts gauge/maintenance references. - Fix network list emitting network_device instead of networkdevice (View 404). Co-Authored-By: Claude Opus 4.8 --- plugins/equipment/api/routes.py | 8 +- plugins/network/api/routes.py | 2 +- plugins/printers/api/asset_routes.py | 390 ++++++++++++++++++++++++--- plugins/printers/plugin.py | 17 +- shopdb/cli/__init__.py | 20 +- shopdb/config.py | 1 + shopdb/core/api/assets.py | 50 ++++ shopdb/plugins/alembic_template.py | 2 +- 8 files changed, 451 insertions(+), 39 deletions(-) diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py index 1b0a533..59841c6 100644 --- a/plugins/equipment/api/routes.py +++ b/plugins/equipment/api/routes.py @@ -282,6 +282,8 @@ def create_equipment(): asset = Asset( assetnumber=data['assetnumber'], name=data.get('name'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), serialnumber=data.get('serialnumber'), assettypeid=equipment_type.assettypeid, statusid=data.get('statusid', 1), @@ -357,8 +359,10 @@ def update_equipment(equipment_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', - 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + asset_fields = ['assetnumber', 'name', 'gaugelabreference', + 'maintenancereference', 'serialnumber', 'statusid', + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] for key in asset_fields: if key in data: old_val = getattr(asset, key) diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index aaed6e1..fff93d9 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -207,7 +207,7 @@ def list_network_devices(): data = [] for netdev in items: item = netdev.asset.to_dict() if netdev.asset else {} - item['network_device'] = netdev.to_dict() + item['networkdevice'] = netdev.to_dict() data.append(item) return paginated_response(data, page, per_page, total) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index ceca6c8..dd7237f 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -15,8 +15,15 @@ from shopdb.utils.responses import ( ) from shopdb.utils.pagination import get_pagination_params, paginate_query -from ..models import Printer, PrinterType -from ..services import ZabbixService +from ..models import Printer, PrinterType, ModelSupply +from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS +from ..services import ( + ZabbixService, + classifysupply, + derivesupplytype, + derivecolor, + lookupsupplies, +) logger = logging.getLogger(__name__) @@ -134,8 +141,8 @@ def list_printers(): ) # Type filter - if type_id := request.args.get('type_id'): - query = query.filter(Printer.printertypeid == int(type_id)) + if typeid := request.args.get('typeid', request.args.get('type_id')): + query = query.filter(Printer.printertypeid == int(typeid)) # Vendor filter if vendor_id := request.args.get('vendor_id'): @@ -357,9 +364,10 @@ def update_printer(printer_id: int): http_code=409 ) - # Update asset fields + # Update asset fields (gauge lab / maintenance refs are equipment-only) asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', - 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] + 'locationid', 'businessunitid', 'mapx', 'mapy', + 'notes', 'isactive'] for key in asset_fields: if key in data: setattr(asset, key, data[key]) @@ -372,6 +380,27 @@ def update_printer(printer_id: int): if key in data: setattr(printer, key, data[key]) + # Upsert the primary IP communication when an ipaddress is supplied, so a + # single PUT updates core, extension, and network in one call. + if 'ipaddress' in data: + ip = (data.get('ipaddress') or '').strip() + comm = Communication.query.filter_by( + assetid=asset.assetid, isprimary=True).first() + if ip: + if comm: + comm.ipaddress = ip + else: + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + if ip_comtype: + db.session.add(Communication( + assetid=asset.assetid, + comtypeid=ip_comtype.comtypeid, + ipaddress=ip, + isprimary=True, + )) + elif comm: + comm.ipaddress = None + db.session.commit() result = asset.to_dict() @@ -426,17 +455,25 @@ def get_printer_supplies(printer_id: int): service = ZabbixService() if not service.isconfigured or not service.isreachable: - # Return empty supplies if Zabbix not available (fail gracefully) + # fail soft when zabbix off or down return success_response({ 'ipaddress': comm.ipaddress, + 'pingstatus': '-1', 'supplies': [] }) - supplies = service.getsuppliesbyip(comm.ipaddress) + # vendor drives waste-cartridge rules; modelnumberid drives part lookup + vendor_name = printer.vendor.vendor if getattr(printer, 'vendor', None) else None + + raw_supplies = service.getsuppliesbyip(comm.ipaddress) or [] + supplies = [ + _annotate_supply(s, vendor_name, printer.modelnumberid) for s in raw_supplies + ] return success_response({ 'ipaddress': comm.ipaddress, - 'supplies': supplies or [] + 'pingstatus': service.getpingstatus(comm.ipaddress), + 'supplies': supplies }) @@ -444,8 +481,32 @@ def get_printer_supplies(printer_id: int): # Low Supplies # ============================================================================= +def _annotate_supply(supply, vendor_name, modelnumberid): + """Add status, remaining percent, and part numbers to a raw supply dict. + + Waste cartridge direction depends on vendor, so classification lives in + the supply_parts helper. Part numbers come from the modelsupplies table. + """ + level = supply.get('level', 0) + name = supply.get('name', 'Unknown') + supplytype = derivesupplytype(name) + color = derivecolor(name, supply.get('color')) + cls = classifysupply(level, name, vendor_name) + return { + 'name': name, + 'level': level, + 'color': color, + 'supplytype': supplytype, + 'status': cls['status'], + 'remaining': cls['remaining'], + 'iswaste': cls['iswaste'], + 'isdrum': cls['isdrum'], + 'partnumbers': lookupsupplies(modelnumberid, color, supplytype), + } + + def _get_low_supplies_data(): - """Build low supplies data (cached for 10 minutes).""" + """Build low supplies data (cached for 5 minutes).""" cached = cache.get('printers_low_supplies') if cached is not None: return cached @@ -454,56 +515,51 @@ def _get_low_supplies_data(): if not service.isconfigured or not service.isreachable: return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}} - # All active printers with an IP address - printers = ( - db.session.query(Printer, Asset, Communication) + # active printers with an IP, with vendor and model for waste/part rules + rows = ( + db.session.query(Printer, Asset, Communication, Vendor, Model) .join(Asset, Asset.assetid == Printer.assetid) .join(Communication, Communication.assetid == Asset.assetid) + .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) + .outerjoin(Model, Model.modelnumberid == Printer.modelnumberid) .filter(Asset.isactive == True) .filter(Communication.ipaddress.isnot(None)) .filter(Communication.ipaddress != '') .all() ) - # Dedupe by printer id (may have multiple comms) + # dedupe by printer id (a printer may have several comms) seen = set() unique_printers = [] - for printer, asset, comm in printers: + for printer, asset, comm, vendor, model in rows: if printer.printerid not in seen: seen.add(printer.printerid) - unique_printers.append((printer, asset, comm)) + unique_printers.append((printer, asset, comm, vendor, model)) results = [] total_checked = 0 - for printer, asset, comm in unique_printers: + for printer, asset, comm, vendor, model in unique_printers: supplies = service.getsuppliesbyip_cached(comm.ipaddress) if supplies is None: continue total_checked += 1 - # Annotate each supply with status + vendor_name = vendor.vendor if vendor else None + model_number = model.modelnumber if model else None + modelnumberid = model.modelnumberid if model else None + annotated = [] has_low = False for s in supplies: - level = s.get('level', 0) - if level <= 5: - status = 'critical' + item = _annotate_supply(s, vendor_name, modelnumberid) + if item['status'] != 'ok': has_low = True - elif level <= 10: - status = 'low' - has_low = True - else: - status = 'ok' - annotated.append({ - 'name': s.get('name', 'Unknown'), - 'level': level, - 'status': status - }) + annotated.append(item) if has_low: - # Get location name + # location name for the report row location_name = None if asset.locationid: from shopdb.core.models import Location @@ -516,6 +572,8 @@ def _get_low_supplies_data(): 'printername': asset.name or printer.hostname or '', 'assetnumber': asset.assetnumber or '', 'ipaddress': comm.ipaddress, + 'vendor': vendor_name, + 'model': model_number, 'location': location_name, 'supplies': annotated }) @@ -539,7 +597,7 @@ def _get_low_supplies_data(): } } - cache.set('printers_low_supplies', data, timeout=600) + cache.set('printers_low_supplies', data, timeout=300) return data @@ -551,6 +609,62 @@ def low_supplies(): return success_response(data) +@printers_asset_bp.route('/lookup', methods=['GET']) +@jwt_required(optional=True) +def printer_lookup(): + """Find a printer by IP or FQDN. Parity with the classic printerlookup.asp. + + Zabbix uses this to jump straight to a printer record. Query with + ?ip=x.x.x.x or ?fqdn=hostname; returns the matching printer id. + """ + ip = (request.args.get('ip') or '').strip() + fqdn = (request.args.get('fqdn') or '').strip() + lookup_value = ip or fqdn + + if not lookup_value: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Provide ip or fqdn' + ) + + # match the IP against any active printer communication + row = ( + db.session.query(Printer, Asset) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .filter(Asset.isactive == True) + .filter(Communication.ipaddress == lookup_value) + .first() + ) + + if not row: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printer not found: {lookup_value}', + http_code=404 + ) + + printer, asset = row + return success_response({ + 'printerid': printer.printerid, + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'name': asset.name or printer.hostname, + }) + + +@printers_asset_bp.route('/supplies/refresh', methods=['POST']) +@jwt_required() +def refresh_supplies_cache(): + """Clear cached Zabbix supply data so the next read pulls fresh values. + + Backs the toner report Refresh button (parity with adminclearcache.asp + type=zabbix). + """ + ZabbixService().clearcache() + return success_response(message='Supply cache cleared') + + # ============================================================================= # Dashboard # ============================================================================= @@ -605,3 +719,213 @@ def dashboard_summary(): 'bytype': [{'type': t, 'count': c} for t, c in by_type], 'byvendor': [{'vendor': v, 'count': c} for v, c in by_vendor], }) + + +# ============================================================================= +# Model Supplies (data-driven toner/drum/waste part numbers) +# ============================================================================= + +def _validate_supply_payload(data): + """Return an error message if the supply payload is invalid, else None.""" + if not data: + return 'No data provided' + if not data.get('partnumber'): + return 'partnumber is required' + supplytype = data.get('supplytype', 'toner') + if supplytype not in SUPPLY_TYPES: + return f"supplytype must be one of {', '.join(SUPPLY_TYPES)}" + color = data.get('color', 'none') + if color not in SUPPLY_COLORS: + return f"color must be one of {', '.join(SUPPLY_COLORS)}" + capacitytier = data.get('capacitytier', 'standard') + if capacitytier not in CAPACITY_TIERS: + return f"capacitytier must be one of {', '.join(CAPACITY_TIERS)}" + return None + + +@printers_asset_bp.route('/supplies/meta', methods=['GET']) +@jwt_required(optional=True) +def supplies_meta(): + """Allowed values for supply type, color, and capacity tier (for the UI).""" + return success_response({ + 'supplytypes': list(SUPPLY_TYPES), + 'colors': list(SUPPLY_COLORS), + 'capacitytiers': list(CAPACITY_TIERS), + }) + + +@printers_asset_bp.route('/models', methods=['GET']) +@jwt_required(optional=True) +def list_supply_models(): + """List models with a supply count, for the supply-management picker. + + Query parameters: + - search: filter by model number + - vendor_id: filter by vendor + - withsupplies: 'true' to only return models that already have supplies + """ + page, per_page = get_pagination_params(request) + + supplycount = db.func.count(ModelSupply.modelsupplyid).label('supplycount') + query = ( + db.session.query(Model, Vendor.vendor, supplycount) + .outerjoin(Vendor, Vendor.vendorid == Model.vendorid) + .outerjoin(ModelSupply, db.and_( + ModelSupply.modelnumberid == Model.modelnumberid, + ModelSupply.isactive == True, + )) + .group_by(Model.modelnumberid, Vendor.vendor) + ) + + # Toner/drum/waste only apply to printers, so restrict the picker to + # printer models: those attached to a printer asset, or those that already + # carry supply mappings. Keeps machine/controller models out of the list. + printer_model_ids = ( + db.session.query(Printer.modelnumberid) + .filter(Printer.modelnumberid.isnot(None)) + ) + supply_model_ids = db.session.query(ModelSupply.modelnumberid) + query = query.filter(db.or_( + Model.modelnumberid.in_(printer_model_ids), + Model.modelnumberid.in_(supply_model_ids), + )) + + if search := request.args.get('search'): + query = query.filter(Model.modelnumber.ilike(f'%{search}%')) + if vendor_id := request.args.get('vendor_id'): + query = query.filter(Model.vendorid == int(vendor_id)) + if request.args.get('withsupplies', '').lower() == 'true': + query = query.having(supplycount > 0) + + query = query.order_by(Model.modelnumber) + + total = query.count() + rows = query.limit(per_page).offset((page - 1) * per_page).all() + + data = [{ + 'modelnumberid': model.modelnumberid, + 'modelnumber': model.modelnumber, + 'vendor': vendor, + 'vendorid': model.vendorid, + 'supplycount': count, + } for model, vendor, count in rows] + + return paginated_response(data, page, per_page, total) + + +@printers_asset_bp.route('/models//supplies', methods=['GET']) +@jwt_required(optional=True) +def list_model_supplies(modelnumberid: int): + """List all supplies mapped to a model.""" + model = Model.query.get(modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + supplies = ( + ModelSupply.query + .filter_by(modelnumberid=modelnumberid, isactive=True) + .order_by(ModelSupply.supplytype, ModelSupply.color, ModelSupply.capacitytier) + .all() + ) + return success_response({ + 'modelnumberid': modelnumberid, + 'modelnumber': model.modelnumber, + 'supplies': [s.to_dict() for s in supplies], + }) + + +@printers_asset_bp.route('/models//supplies', methods=['POST']) +@jwt_required() +def create_model_supply(modelnumberid: int): + """Add a supply to a model.""" + model = Model.query.get(modelnumberid) + if not model: + return error_response(ErrorCodes.NOT_FOUND, 'Model not found', http_code=404) + + data = request.get_json() + message = _validate_supply_payload(data) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + existing = ModelSupply.query.filter_by( + modelnumberid=modelnumberid, + partnumber=data['partnumber'], + ).first() + if existing: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + supply = ModelSupply( + modelnumberid=modelnumberid, + supplytype=data.get('supplytype', 'toner'), + color=data.get('color', 'none'), + capacitytier=data.get('capacitytier', 'standard'), + partnumber=data['partnumber'], + marketingname=data.get('marketingname'), + pageyield=data.get('pageyield'), + notes=data.get('notes'), + ) + db.session.add(supply) + db.session.commit() + + return success_response(supply.to_dict(), message='Supply added', http_code=201) + + +@printers_asset_bp.route('/supplies/', methods=['PUT']) +@jwt_required() +def update_model_supply(modelsupplyid: int): + """Update a model supply.""" + supply = ModelSupply.query.get(modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + data = request.get_json() + if not data: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') + + # validate only the fields present + merged = { + 'partnumber': data.get('partnumber', supply.partnumber), + 'supplytype': data.get('supplytype', supply.supplytype), + 'color': data.get('color', supply.color), + 'capacitytier': data.get('capacitytier', supply.capacitytier), + } + message = _validate_supply_payload(merged) + if message: + return error_response(ErrorCodes.VALIDATION_ERROR, message) + + if 'partnumber' in data and data['partnumber'] != supply.partnumber: + clash = ModelSupply.query.filter_by( + modelnumberid=supply.modelnumberid, + partnumber=data['partnumber'], + ).first() + if clash: + return error_response( + ErrorCodes.CONFLICT, + f"Part number '{data['partnumber']}' already mapped to this model", + http_code=409, + ) + + for field in ('supplytype', 'color', 'capacitytier', 'partnumber', + 'marketingname', 'pageyield', 'notes'): + if field in data: + setattr(supply, field, data[field]) + + db.session.commit() + return success_response(supply.to_dict(), message='Supply updated') + + +@printers_asset_bp.route('/supplies/', methods=['DELETE']) +@jwt_required() +def delete_model_supply(modelsupplyid: int): + """Delete a model supply.""" + supply = ModelSupply.query.get(modelsupplyid) + if not supply: + return error_response(ErrorCodes.NOT_FOUND, 'Supply not found', http_code=404) + + db.session.delete(supply) + db.session.commit() + return success_response(message='Supply deleted') diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 5cc9c06..198b8c3 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -13,7 +13,7 @@ from shopdb.extensions import db from shopdb.core.models.machine import MachineType from shopdb.core.models import AssetType -from .models import PrinterData, Printer, PrinterType +from .models import PrinterData, Printer, PrinterType, ModelSupply from .api import printers_bp, printers_asset_bp from .services import ZabbixService @@ -77,6 +77,7 @@ class PrintersPlugin(BasePlugin): PrinterData, # Legacy Machine-based Printer, # New Asset-based PrinterType, # New printer type classification + ModelSupply, # model -> toner/drum/waste part numbers ] def get_services(self) -> Dict[str, Type]: @@ -131,6 +132,7 @@ class PrintersPlugin(BasePlugin): ('Laser', 'Standard laser printer', 'printer'), ('Inkjet', 'Inkjet printer', 'printer'), ('Label', 'Label/barcode printer', 'barcode'), + ('Card', 'ID / card printer', 'id-card'), ('MFP', 'Multifunction printer with scan/copy/fax', 'printer'), ('Plotter', 'Large format plotter', 'drafting-compass'), ('Thermal', 'Thermal printer', 'temperature-high'), @@ -209,6 +211,19 @@ class PrintersPlugin(BasePlugin): for supply in supplies: click.echo(f" {supply['name']}: {supply['level']}%") + @printerscli.command('seed-supplies') + def seedsuppliescommand(): + """Seed corrected model->toner part numbers into modelsupplies.""" + from flask import current_app + from .services import seedsupplies + + with current_app.app_context(): + summary = seedsupplies() + click.echo( + f"Seeded supplies: {summary['suppliesadded']} added across " + f"{summary['modelstouched']} models." + ) + return [printerscli] def get_dashboard_widgets(self) -> List[Dict]: diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 47a0603..bb1332b 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -42,7 +42,7 @@ def seed_cli(): def seed_reference_data(): """Seed reference data (machine types, statuses, etc.).""" from shopdb.extensions import db - from shopdb.core.models import MachineType, MachineStatus, OperatingSystem + from shopdb.core.models import MachineType, MachineStatus, OperatingSystem, AssetStatus from shopdb.core.models.relationship import RelationshipType # Machine types @@ -82,6 +82,24 @@ def seed_reference_data(): s = MachineStatus(**s_data) db.session.add(s) + # Asset statuses (canonical set - the asset model is the contract) + asset_statuses = [ + {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, + {'status': 'Inventory', 'description': 'In inventory', 'color': '#17a2b8'}, + {'status': 'In Repair', 'description': 'Being repaired', 'color': '#ffc107'}, + {'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'}, + {'status': 'Returned', 'description': 'Returned to vendor or owner', 'color': '#fd7e14'}, + {'status': 'Warrantied', 'description': 'Under warranty service', 'color': '#20c997'}, + {'status': 'Lost', 'description': 'Lost or missing', 'color': '#dc3545'}, + ] + + for s_data in asset_statuses: + existing = AssetStatus.query.filter_by(status=s_data['status']).first() + if not existing: + db.session.add(AssetStatus(isactive=True, **s_data)) + elif existing.isactive is not True: + existing.isactive = True + # Operating systems os_list = [ {'osname': 'Windows 10', 'osversion': '10.0'}, diff --git a/shopdb/config.py b/shopdb/config.py index 27cce33..eb910d5 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -53,6 +53,7 @@ class Config: LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') + ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true' ZABBIX_URL = os.environ.get('ZABBIX_URL', '') ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 00af216..8f50494 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -153,6 +153,56 @@ def create_asset_status(): return success_response(s.to_dict(), message='Asset status created', http_code=201) +@assets_bp.route('/statuses/', methods=['PUT']) +@jwt_required() +def update_asset_status(status_id: int): + """Update an asset status.""" + s = AssetStatus.query.get(status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + data = request.get_json() or {} + + # Conflict check on rename + if 'status' in data and data['status'] != s.status: + if AssetStatus.query.filter_by(status=data['status']).first(): + return error_response( + ErrorCodes.CONFLICT, + f"Asset status '{data['status']}' already exists", + http_code=409 + ) + + for key in ('status', 'description', 'color', 'isactive'): + if key in data: + setattr(s, key, data[key]) + + db.session.commit() + return success_response(s.to_dict(), message='Asset status updated') + + +@assets_bp.route('/statuses/', methods=['DELETE']) +@jwt_required() +def delete_asset_status(status_id: int): + """Delete an asset status. Refused if any asset still uses it.""" + s = AssetStatus.query.get(status_id) + if not s: + return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found', + http_code=404) + + inuse = Asset.query.filter_by(statusid=status_id).count() + if inuse: + return error_response( + ErrorCodes.CONFLICT, + f"Cannot delete: {inuse} asset(s) still use this status", + http_code=409 + ) + + db.session.delete(s) + db.session.commit() + return success_response(message='Asset status deleted') + + # ============================================================================= # Assets # ============================================================================= diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index c73f8d5..2ff1214 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -35,7 +35,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'equipment': ('equipmenttypes', 'equipment'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), - 'printers': ('printertypes', 'printers', 'printerdata'), + 'printers': ('printertypes', 'printers', 'printerdata', 'modelsupplies'), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), } From e6315643778274de919665fa293037ca0526304f Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 08:35:32 -0400 Subject: [PATCH 04/32] Frontend: asset identifiers, statuses, printer types, UI fixes - Wire gauge/maintenance refs + identifier toggles into equipment pages. - Repoint status dropdowns and Settings>Statuses to asset statuses. - Printer type column/filter; require vendor before model; printer-model picker. - Per-type identifier labels; printer detail title fallback. - Fixes: truncate long description cells, opaque supply modal, readable dark-mode autofill, drum/waste default colorless. Co-Authored-By: Claude Opus 4.8 --- frontend/src/api/index.js | 74 ++- frontend/src/assets/style.css | 9 + frontend/src/router/routes/printers.js | 7 + frontend/src/views/machines/MachineDetail.vue | 10 + frontend/src/views/machines/MachineForm.vue | 40 +- frontend/src/views/machines/MachinesList.vue | 2 +- .../src/views/network/NetworkDevicesList.vue | 2 +- frontend/src/views/pcs/PCDetail.vue | 6 +- frontend/src/views/pcs/PCForm.vue | 28 +- frontend/src/views/pcs/PCsList.vue | 19 +- frontend/src/views/printers/PrinterDetail.vue | 79 +++- frontend/src/views/printers/PrinterForm.vue | 131 +++--- frontend/src/views/printers/PrintersList.vue | 30 +- .../src/views/settings/BusinessUnitsList.vue | 2 +- frontend/src/views/settings/LocationsList.vue | 2 +- .../src/views/settings/MachineTypesList.vue | 2 +- .../src/views/settings/ModelSuppliesList.vue | 437 ++++++++++++++++++ frontend/src/views/settings/PCTypesList.vue | 2 +- frontend/src/views/settings/SettingsIndex.vue | 8 +- frontend/src/views/settings/StatusesList.vue | 16 +- frontend/src/views/settings/UsersList.vue | 2 +- frontend/src/views/settings/VLANsList.vue | 2 +- 22 files changed, 786 insertions(+), 124 deletions(-) create mode 100644 frontend/src/views/settings/ModelSuppliesList.vue diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e884c03..5dad473 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -264,9 +264,26 @@ export const printersApi = { get(id) { return api.get(`/printers/${id}`) }, + // create/update write asset core + printer extension in one call (the + // printers plugin owns both). Use these instead of the legacy machinesApi. + create(data) { + return api.post('/printers', data) + }, + update(id, data) { + return api.put(`/printers/${id}`, data) + }, updateExtension(id, data) { return api.put(`/printers/${id}/printerdata`, data) }, + // printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...) + types: { + list(params = {}) { + return api.get('/printers/types', { params }) + }, + create(data) { + return api.post('/printers/types', data) + } + }, updateCommunication(id, data) { return api.put(`/printers/${id}/communication`, data) }, @@ -279,6 +296,12 @@ export const printersApi = { lowSupplies() { return api.get('/printers/lowsupplies') }, + refreshSupplies() { + return api.post('/printers/supplies/refresh') + }, + lookup({ ip, fqdn } = {}) { + return api.get('/printers/lookup', { params: { ip, fqdn } }) + }, dashboardSummary() { return api.get('/printers/dashboard/summary') }, @@ -303,6 +326,27 @@ export const printersApi = { create(data) { return api.post('/printers/supplytypes', data) } + }, + // model -> toner/drum/waste part-number management + modelSupplies: { + meta() { + return api.get('/printers/supplies/meta') + }, + listModels(params = {}) { + return api.get('/printers/models', { params }) + }, + list(modelnumberid) { + return api.get(`/printers/models/${modelnumberid}/supplies`) + }, + create(modelnumberid, data) { + return api.post(`/printers/models/${modelnumberid}/supplies`, data) + }, + update(modelsupplyid, data) { + return api.put(`/printers/supplies/${modelsupplyid}`, data) + }, + delete(modelsupplyid) { + return api.delete(`/printers/supplies/${modelsupplyid}`) + } } } @@ -321,6 +365,23 @@ export const modelsApi = { list(params = {}) { return api.get('/models', { params }) }, + // Backend caps perpage at 100, so page through every model. Returns the + // full array directly (not an axios response). Use in forms whose model + // dropdown must include the editing record's model regardless of page. + async listAll() { + const first = await api.get('/models', { params: { perpage: 100, page: 1 } }) + let items = first.data.data || [] + const totalpages = first.data.meta?.pagination?.totalpages || 1 + if (totalpages > 1) { + const rest = await Promise.all( + Array.from({ length: totalpages - 1 }, (_, i) => + api.get('/models', { params: { perpage: 100, page: i + 2 } }) + ) + ) + rest.forEach(r => { items = items.concat(r.data.data || []) }) + } + return items + }, get(id) { return api.get(`/models/${id}`) }, @@ -528,8 +589,17 @@ export const assetsApi = { } }, statuses: { - list() { - return api.get('/assets/statuses') + list(params = {}) { + return api.get('/assets/statuses', { params }) + }, + create(data) { + return api.post('/assets/statuses', data) + }, + update(id, data) { + return api.put(`/assets/statuses/${id}`, data) + }, + delete(id) { + return api.delete(`/assets/statuses/${id}`) } } } diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 148299e..681aeb3 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -372,6 +372,15 @@ th, td { border-top: 1px solid var(--border); } +/* Cap a long free-text column (e.g. Description) so it truncates with an + ellipsis instead of widening the row and pushing the Actions column out of + view. Pair with a title attribute to show the full text on hover. */ +td.cell-truncate { + max-width: 32rem; + overflow: hidden; + text-overflow: ellipsis; +} + th { font-weight: 600; font-size: 11px; diff --git a/frontend/src/router/routes/printers.js b/frontend/src/router/routes/printers.js index 0e50cfc..2dd83d8 100644 --- a/frontend/src/router/routes/printers.js +++ b/frontend/src/router/routes/printers.js @@ -23,5 +23,12 @@ export default [ name: 'printer-edit', component: () => import('../../views/printers/PrinterForm.vue'), meta: { requiresAuth: true } + }, + // printer-specific settings + { + path: 'settings/modelsupplies', + name: 'model-supplies', + component: () => import('../../views/settings/ModelSuppliesList.vue'), + meta: { requiresAuth: true } } ] diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue index 97cebef..e1c5585 100644 --- a/frontend/src/views/machines/MachineDetail.vue +++ b/frontend/src/views/machines/MachineDetail.vue @@ -68,6 +68,14 @@ Name {{ equipment.name }} +
+ Gauge Lab Reference + {{ equipment.gaugelabreference }} +
+
+ Maintenance Reference + {{ equipment.maintenancereference }} +
Serial Number {{ equipment.serialnumber }} @@ -229,8 +237,10 @@ import { ref, computed, onMounted } from 'vue' import { useRoute } from 'vue-router' import { equipmentApi, assetsApi } from '../../api' import LocationMapTooltip from '../../components/LocationMapTooltip.vue' +import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() +const identifierflags = useIdentifierFlags() const loading = ref(true) const equipment = ref(null) diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue index 5b332c7..b0ced1d 100644 --- a/frontend/src/views/machines/MachineForm.vue +++ b/frontend/src/views/machines/MachineForm.vue @@ -30,6 +30,31 @@ type="text" class="form-control" /> + Layperson-friendly label +
+ + +
+
+ + + Authoritative gauge lab asset reference (if tracked) +
+ +
+ + + Maintenance system asset reference (if tracked)
@@ -331,6 +356,9 @@ import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, co import ShopFloorMap from '../../components/ShopFloorMap.vue' import Modal from '../../components/Modal.vue' import { currentTheme } from '../../stores/theme' +import { useIdentifierFlags } from '../../composables/identifierSettings' + +const identifierflags = useIdentifierFlags() const route = useRoute() const router = useRouter() @@ -346,6 +374,8 @@ const tempMapPosition = ref(null) const form = ref({ assetnumber: '', name: '', + gaugelabreference: '', + maintenancereference: '', serialnumber: '', statusid: 1, equipmenttypeid: '', @@ -410,12 +440,12 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => { onMounted(async () => { try { // Load reference data in parallel - const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([ + const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([ equipmentApi.types.list(), assetsApi.statuses.list(), vendorsApi.list({ perpage: 500 }), locationsApi.list({ perpage: 500 }), - modelsApi.list({ perpage: 1000 }), + modelsApi.listAll(), // backend caps perpage at 100; page through all businessunitsApi.list({ perpage: 500 }), computersApi.list({ perpage: 500 }), assetsApi.types.list() // Used for relationship types, will fix below @@ -425,7 +455,7 @@ onMounted(async () => { statuses.value = statusRes.data.data || [] vendors.value = vendorRes.data.data || [] locations.value = locRes.data.data || [] - models.value = modelsRes.data.data || [] + models.value = allModels businessunits.value = buRes.data.data || [] pcs.value = pcsRes.data.data || [] @@ -456,6 +486,8 @@ onMounted(async () => { form.value = { assetnumber: data.assetnumber || '', name: data.name || '', + gaugelabreference: data.gaugelabreference || '', + maintenancereference: data.maintenancereference || '', serialnumber: data.serialnumber || '', statusid: data.statusid || 1, equipmenttypeid: data.equipment?.equipmenttypeid || '', @@ -526,6 +558,8 @@ async function saveEquipment() { const data = { assetnumber: form.value.assetnumber, name: form.value.name || null, + gaugelabreference: form.value.gaugelabreference || null, + maintenancereference: form.value.maintenancereference || null, serialnumber: form.value.serialnumber || null, statusid: form.value.statusid || 1, equipmenttypeid: form.value.equipmenttypeid || null, diff --git a/frontend/src/views/machines/MachinesList.vue b/frontend/src/views/machines/MachinesList.vue index bf04767..3bf93a2 100644 --- a/frontend/src/views/machines/MachinesList.vue +++ b/frontend/src/views/machines/MachinesList.vue @@ -24,7 +24,7 @@ - + diff --git a/frontend/src/views/network/NetworkDevicesList.vue b/frontend/src/views/network/NetworkDevicesList.vue index 1fb111d..53dbd15 100644 --- a/frontend/src/views/network/NetworkDevicesList.vue +++ b/frontend/src/views/network/NetworkDevicesList.vue @@ -54,7 +54,7 @@
Asset #Machine # Name Serial Number Type
- + diff --git a/frontend/src/views/pcs/PCDetail.vue b/frontend/src/views/pcs/PCDetail.vue index 3152ff1..b6aca38 100644 --- a/frontend/src/views/pcs/PCDetail.vue +++ b/frontend/src/views/pcs/PCDetail.vue @@ -16,7 +16,7 @@

{{ computer.assetnumber }}

- {{ computer.computer.hostname }} + {{ computer.computer.hostname }}
Computer @@ -57,7 +57,7 @@ Name {{ computer.name }}
-
+
Hostname {{ computer.computer.hostname }}
@@ -207,8 +207,10 @@ import { ref, onMounted, computed } from 'vue' import { useRoute } from 'vue-router' import { computersApi, applicationsApi, assetsApi } from '../../api' import LocationMapTooltip from '../../components/LocationMapTooltip.vue' +import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() +const identifierflags = useIdentifierFlags() const loading = ref(true) const computer = ref(null) diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue index 42f8568..d8292d9 100644 --- a/frontend/src/views/pcs/PCForm.vue +++ b/frontend/src/views/pcs/PCForm.vue @@ -32,7 +32,7 @@
-
+
import { ref, onMounted, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api' +import { machinesApi, machinetypesApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api' import ShopFloorMap from '../../components/ShopFloorMap.vue' import Modal from '../../components/Modal.vue' import { currentTheme } from '../../stores/theme' +import { useIdentifierFlags } from '../../composables/identifierSettings' + +const identifierflags = useIdentifierFlags() const route = useRoute() const router = useRouter() @@ -317,7 +320,9 @@ const filteredModels = computed(() => { if (form.value.vendorid && m.vendorid !== form.value.vendorid) { return false } - if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) { + // only exclude models that have a type set and it differs; most models + // have no machinetypeid, so a strict check hides the PC's own model + if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) { return false } return true @@ -327,19 +332,20 @@ const filteredModels = computed(() => { onMounted(async () => { try { // Load reference data - const [ptRes, statusRes, vendorRes, modelsRes, locRes, osRes] = await Promise.all([ - machinetypesApi.list({ category: 'PC' }), - statusesApi.list(), - vendorsApi.list(), - modelsApi.list(), - locationsApi.list(), - operatingsystemsApi.list() + // perpage 100 so dropdowns aren't truncated to the default 20-row page + const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([ + machinetypesApi.list({ category: 'PC', perpage: 100 }), + assetsApi.statuses.list(), + vendorsApi.list({ perpage: 100 }), + modelsApi.listAll(), // backend caps perpage at 100; page through all + locationsApi.list({ perpage: 100 }), + operatingsystemsApi.list({ perpage: 100 }) ]) pcTypes.value = ptRes.data.data || [] statuses.value = statusRes.data.data || [] vendors.value = vendorRes.data.data || [] - models.value = modelsRes.data.data || [] + models.value = allModels locations.value = locRes.data.data || [] operatingsystems.value = osRes.data.data || [] diff --git a/frontend/src/views/pcs/PCsList.vue b/frontend/src/views/pcs/PCsList.vue index 1c401d0..090759f 100644 --- a/frontend/src/views/pcs/PCsList.vue +++ b/frontend/src/views/pcs/PCsList.vue @@ -24,7 +24,7 @@
Asset #Asset Tag Hostname Serial Number Type
- + @@ -153,13 +153,15 @@ function getStatusClass(status) { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; } +/* keep the cell as a table-cell; flex on a - +
Asset #Asset Tag Hostname Serial Number Type strips table-cell layout and + offsets the row. lay tags out inline instead. */ .features { - display: flex; - gap: 0.375rem; + white-space: nowrap; } .feature-tag { display: inline-block; + margin-right: 0.375rem; padding: 0.3rem 0.625rem; font-size: 0.875rem; border-radius: 5px; @@ -167,6 +169,17 @@ function getStatusClass(status) { color: var(--text-light); } +.feature-tag:last-child { + margin-right: 0; +} + +/* the global .actions rule is inline-flex, which also breaks table-cell + alignment when applied straight on a ; pin it back to a cell here. */ +td.actions { + display: table-cell; + vertical-align: middle; +} + .feature-tag.active { background: #e3f2fd; color: #1976d2; diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue index 15a9789..da13633 100644 --- a/frontend/src/views/printers/PrinterDetail.vue +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -18,7 +18,7 @@
-

{{ printer.name || printer.assetnumber }}

+

{{ displayTitle }}

Printer @@ -63,7 +63,7 @@ Windows Name {{ printer.printer.windowsname }}
-
+
Hostname / FQDN {{ printer.printer.hostname }}
@@ -172,23 +172,30 @@
-
+
- {{ supply.supplyname }} - - {{ supply.currentlevel !== null ? `${supply.currentlevel}%` : 'N/A' }} + {{ supply.name }} + + {{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
- {{ supply.supplytypename }} - Part: {{ supply.partnumber }} + {{ formatSupplyType(supply.supplytype) }} + + {{ part.marketingname || part.partnumber }} +
@@ -238,14 +245,26 @@ import { ref, onMounted, computed } from 'vue' import { useRoute } from 'vue-router' import { printersApi } from '../../api' import LocationMapTooltip from '../../components/LocationMapTooltip.vue' +import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() +const identifierflags = useIdentifierFlags() const loading = ref(true) const printer = ref(null) const supplies = ref([]) const drivers = ref([]) +// Best display identifier for a printer. name is often the literal "NONE", +// so fall back to the Windows name, then hostname, then asset number. +const displayTitle = computed(() => { + const p = printer.value + if (!p) return '' + const name = (p.name || '').trim() + if (name && name.toUpperCase() !== 'NONE') return name + return p.printer?.windowsname || p.printer?.hostname || p.assetnumber +}) + // Get IP address from communications const ipAddress = computed(() => { if (!printer.value?.communications) return null @@ -262,7 +281,8 @@ onMounted(async () => { ]) printer.value = printerRes.data.data - supplies.value = suppliesRes.data.data || [] + // supplies endpoint returns {ipaddress, pingstatus, supplies:[...]} + supplies.value = suppliesRes.data.data?.supplies || [] drivers.value = driversRes.data.data || [] } catch (error) { console.error('Error loading printer:', error) @@ -280,11 +300,9 @@ function getStatusClass(status) { return 'badge-info' } -function getSupplyLevelClass(level) { - if (level === null || level === undefined) return '' - if (level <= 10) return 'critical' - if (level <= 25) return 'low' - return 'ok' +function formatSupplyType(supplytype) { + if (!supplytype) return '' + return supplytype.charAt(0).toUpperCase() + supplytype.slice(1) } function formatDate(dateStr) { @@ -360,4 +378,33 @@ function formatDate(dateStr) { color: var(--text-light); margin-top: 0.625rem; } + +.supply-parts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.supply-part { + cursor: help; + border-bottom: 1px dotted var(--text-light); + position: relative; +} + +/* instant CSS tooltip, no native title delay */ +.supply-part:hover::after { + content: attr(data-tip); + position: absolute; + bottom: 125%; + right: 0; + white-space: nowrap; + background: var(--text); + color: var(--bg-card); + padding: 0.35rem 0.6rem; + border-radius: 4px; + font-size: 0.85rem; + z-index: 10; + pointer-events: none; +} diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue index abf5c05..a22ee39 100644 --- a/frontend/src/views/printers/PrinterForm.vue +++ b/frontend/src/views/printers/PrinterForm.vue @@ -37,7 +37,7 @@
-
+
@@ -127,8 +127,9 @@ id="modelnumberid" v-model="form.modelnumberid" class="form-control" + :disabled="!form.vendorid" > - +
@@ -266,10 +267,13 @@ + + diff --git a/frontend/src/views/settings/PCTypesList.vue b/frontend/src/views/settings/PCTypesList.vue index d60ff3d..b2311d5 100644 --- a/frontend/src/views/settings/PCTypesList.vue +++ b/frontend/src/views/settings/PCTypesList.vue @@ -21,7 +21,7 @@
{{ pt.pctype }}{{ pt.description || '-' }}{{ pt.description || '-' }} diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue index 5bb3241..c816beb 100644 --- a/frontend/src/views/settings/SettingsIndex.vue +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -27,6 +27,12 @@

Manage equipment models by vendor

+ +
+

Model Toners and Supplies

+

Map toner, drum, and waste part numbers to printer models

+
+

Machine Types

@@ -85,7 +91,7 @@ diff --git a/frontend/src/views/settings/SettingsIndex.vue b/frontend/src/views/settings/SettingsIndex.vue index c816beb..1068928 100644 --- a/frontend/src/views/settings/SettingsIndex.vue +++ b/frontend/src/views/settings/SettingsIndex.vue @@ -75,6 +75,12 @@

Configure integrations and system options

+ +
+

Plugins

+

Enable or disable installed plugins

+
+

Audit Logs

@@ -91,7 +97,7 @@ diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 84eec0b..9f4f6c5 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -328,6 +328,8 @@ def create_computer(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=computer_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -424,7 +426,8 @@ def update_computer(computer_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 2a71493..40e1113 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -324,6 +324,8 @@ def create_network_device(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=network_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -406,7 +408,8 @@ def update_network_device(device_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 5e234e7..ef3b2fe 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -285,6 +285,8 @@ def create_printer(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=printer_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -364,8 +366,9 @@ def update_printer(printer_id: int): http_code=409 ) - # Update asset fields (gauge lab / maintenance refs are equipment-only) - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + # Update asset fields (optional identifiers gated per-type in Settings) + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 381b2c0..d67fa6a 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -361,6 +361,18 @@ def seed_settings(): }, ] + # Asset identifier toggles, per identifier AND per asset type (ADR-001). + from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES + for name, label in IDENTIFIER_LABELS.items(): + for assettype in IDENTIFIER_ASSETTYPES: + defaults.append({ + 'key': f'identifier_{name}_{assettype}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': f'Show the {label} identifier on {assettype} assets', + }) + created = 0 for d in defaults: if not Setting.query.filter_by(key=d['key']).first(): diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 9f058d0..3625e0d 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -17,6 +17,16 @@ SETTINGS_CACHE_TTL = 300 # 5 minutes # exposed in plaintext. Sending it back on update is treated as "unchanged". SECRET_MASK = '********' +# Optional asset identifiers and the asset types they can be toggled on. +# Drives per-type seed keys and the Settings matrix UI. The asset type names +# match the AssetType.assettype values seeded by each plugin. +IDENTIFIER_LABELS = { + 'gaugelabreference': 'Gauge Lab Reference', + 'maintenancereference': 'Maintenance Reference', + 'fqdn': 'FQDN / hostname', +} +IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device'] + def _is_secret(key: str) -> bool: return 'password' in key or 'token' in key or 'secret' in key @@ -155,29 +165,22 @@ def create_setting(): @jwt_required() def seed_default_settings(): """Seed default settings if they don't exist.""" - defaults = [ - # Asset identifier feature toggles (global, per identifier) + # Asset identifier feature toggles, per identifier AND per asset type. + # Key format: identifier___enabled (boolean). Admins pick + # which optional identifiers show on which asset types. See ADR-001. + identifierdefaults = [ { - 'key': 'identifier_gaugelabreference_enabled', + 'key': f'identifier_{name}_{assettype}_enabled', 'value': 'true', 'valuetype': 'boolean', 'category': 'identifiers', - 'description': 'Show the Gauge Lab Reference identifier on assets' - }, - { - 'key': 'identifier_maintenancereference_enabled', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'identifiers', - 'description': 'Show the Maintenance Reference identifier on assets' - }, - { - 'key': 'identifier_fqdn_enabled', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'identifiers', - 'description': 'Show the FQDN / hostname identifier on assets' - }, + 'description': f'Show the {label} identifier on {assettype} assets', + } + for name, label in IDENTIFIER_LABELS.items() + for assettype in IDENTIFIER_ASSETTYPES + ] + + defaults = identifierdefaults + [ # Zabbix integration { 'key': 'zabbix_enabled', From f663cc5bbea0d47ebb935e8703d0f7201d0702ef Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 16:45:06 -0400 Subject: [PATCH 26/32] Enforce plugin contract purity: single import surface via shopdb.api Plugins were reaching into internal core paths (shopdb.core.models.*, shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and violating the ADR-001 contract. Consolidate onto one versioned surface. - shopdb.api: expand from 2 helpers to the full plugin import surface - db, cache; BaseModel, AuditMixin; core models (Asset, AssetType, AssetStatus, Vendor, Model, Communication, CommunicationType, Location, Setting, AuditLog, Application, AppVersion, OperatingSystem); response + pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md. - Migrate all 22 plugin source files to import only from shopdb.api (plus shopdb.plugins.base for the ABC). - Drop the printers plugin's legacy MachineType dependency: remove _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup (Model.machinetypeid is nullable; printers carry type via PrinterType). - Guard test test_plugins_only_import_contract_surface scans plugin source and fails on any core import outside shopdb.api / shopdb.plugins.base. - Scaffold templates updated so generated plugins are contract-pure. - Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion; manifests pin <1.0.0 so they still satisfy). 145 tests pass, naming/style green, app factory boots all 6 plugins. Co-Authored-By: Claude Opus 4.8 --- docs/PLUGIN-HOOKS.md | 28 + plugins/computers/api/routes.py | 13 +- plugins/computers/models/computer.py | 407 ++++++------ plugins/computers/plugin.py | 611 +++++++++--------- plugins/equipment/api/routes.py | 12 +- plugins/equipment/models/equipment.py | 265 ++++---- plugins/equipment/plugin.py | 439 +++++++------ plugins/network/api/routes.py | 10 +- plugins/network/models/network_device.py | 241 ++++--- plugins/network/models/subnet.py | 291 +++++---- plugins/network/plugin.py | 433 +++++++------ plugins/notifications/api/routes.py | 10 +- plugins/notifications/models/notification.py | 314 ++++----- plugins/notifications/plugin.py | 408 ++++++------ plugins/printers/api/asset_routes.py | 12 +- plugins/printers/models/model_supply.py | 3 +- plugins/printers/models/printer.py | 243 ++++--- plugins/printers/plugin.py | 29 +- plugins/printers/services/seed_supplies.py | 10 +- plugins/printers/services/zabbix_service.py | 6 +- plugins/usb/api/routes.py | 10 +- plugins/usb/models/usb_device.py | 333 +++++----- plugins/usb/plugin.py | 2 +- shopdb/__init__.py | 5 +- shopdb/api/__init__.py | 77 ++- shopdb/plugins/templates/api/routes.py.tmpl | 5 +- shopdb/plugins/templates/models/model.py.tmpl | 3 +- shopdb/plugins/templates/plugin.py.tmpl | 3 +- tests/test_plugin_contract.py | 36 ++ 29 files changed, 2152 insertions(+), 2107 deletions(-) diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 87e5606..e38490a 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -233,6 +233,34 @@ These run when the plugin's installation state changes. All optional. | `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches | | `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues | +## The import surface (`shopdb.api`) + +`shopdb.api` is the ONLY core module a plugin may import from (besides +`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal +paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` +is a contract violation and fails the test +`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`. + +What `shopdb.api` exposes: + +- Infrastructure: `db`, `cache` +- Model bases: `BaseModel`, `AuditMixin` +- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`, + `Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`, + `Application`, `AppVersion`, `OperatingSystem` +- Responses: `success_response`, `error_response`, `paginated_response`, + `ErrorCodes` +- Pagination: `get_pagination_params`, `paginate_query` +- Helpers: `audit_log`, `resolve_asset_position` +- Legacy employee directory: `employee_connection` + +```python +from shopdb.api import db, Asset, AssetType, success_response, paginate_query +``` + +Adding a name to `shopdb.api` is an additive (minor) contract bump; removing +one is breaking (major). See ADR-002. + ## Helpers exposed to plugins The framework provides helper APIs in `shopdb.api` (the public namespace). diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 9f4f6c5..787cd7e 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -3,18 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import ( - Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, - Communication, CommunicationType, -) -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Computer, ComputerType, ComputerInstalledApp diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py index 27325b1..7c2e6f1 100644 --- a/plugins/computers/models/computer.py +++ b/plugins/computers/models/computer.py @@ -1,204 +1,203 @@ -"""Computer plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class ComputerType(BaseModel): - """ - Computer type classification. - - Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. - """ - __tablename__ = 'computertypes' - - computertypeid = db.Column(db.Integer, primary_key=True) - computertype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - icon = db.Column(db.String(50), comment='Icon name for UI') - - def __repr__(self): - return f"" - - -class Computer(BaseModel): - """ - Computer-specific extension data. - - Links to core Asset table via assetid. - Stores computer-specific fields like hostname, OS, logged in user, etc. - """ - __tablename__ = 'computers' - - computerid = db.Column(db.Integer, primary_key=True) - - # Link to core asset - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Computer classification - computertypeid = db.Column( - db.Integer, - db.ForeignKey('computertypes.computertypeid'), - nullable=True - ) - - # Network identity - hostname = db.Column( - db.String(100), - index=True, - comment='Network hostname' - ) - - # Operating system - osid = db.Column( - db.Integer, - db.ForeignKey('operatingsystems.osid'), - nullable=True - ) - - # Hardware make/model (PCs carry vendor + model like equipment) - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - modelnumberid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True - ) - - # Status tracking - loggedinuser = db.Column(db.String(100), nullable=True) - lastreporteddate = db.Column(db.DateTime, nullable=True) - lastboottime = db.Column(db.DateTime, nullable=True) - - # Remote access features - isvnc = db.Column( - db.Boolean, - default=False, - comment='VNC remote access enabled' - ) - iswinrm = db.Column( - db.Boolean, - default=False, - comment='WinRM enabled' - ) - - # Classification flags - isshopfloor = db.Column( - db.Boolean, - default=False, - comment='Shopfloor PC (vs office PC)' - ) - - # Relationships - asset = db.relationship( - 'Asset', - backref=db.backref('computer', uselist=False, lazy='joined') - ) - computertype = db.relationship('ComputerType', backref='computers') - operatingsystem = db.relationship('OperatingSystem', backref='computers') - vendor = db.relationship('Vendor') - model = db.relationship('Model') - - # Installed applications (one-to-many) - installedapps = db.relationship( - 'ComputerInstalledApp', - back_populates='computer', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - __table_args__ = ( - db.Index('idx_computer_type', 'computertypeid'), - db.Index('idx_computer_hostname', 'hostname'), - db.Index('idx_computer_os', 'osid'), - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary with related names.""" - result = super().to_dict() - - # Add related object names - if self.computertype: - result['computertypename'] = self.computertype.computertype - if self.operatingsystem: - result['osname'] = self.operatingsystem.osname - if self.vendor: - result['vendorname'] = self.vendor.vendor - if self.model: - result['modelname'] = self.model.modelnumber - - return result - - -class ComputerInstalledApp(db.Model): - """ - Junction table for applications installed on computers. - - Tracks which applications are installed on which computers, - including version information. - """ - __tablename__ = 'computerinstalledapps' - - id = db.Column(db.Integer, primary_key=True) - computerid = db.Column( - db.Integer, - db.ForeignKey('computers.computerid', ondelete='CASCADE'), - nullable=False - ) - appid = db.Column( - db.Integer, - db.ForeignKey('applications.appid'), - nullable=False - ) - appversionid = db.Column( - db.Integer, - db.ForeignKey('appversions.appversionid'), - nullable=True - ) - # Raw version string from automated collection (when no curated AppVersion) - installedversion = db.Column(db.String(100), nullable=True) - isactive = db.Column(db.Boolean, default=True, nullable=False) - installeddate = db.Column(db.DateTime, default=db.func.now()) - - # Relationships - computer = db.relationship('Computer', back_populates='installedapps') - application = db.relationship('Application') - appversion = db.relationship('AppVersion') - - __table_args__ = ( - db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), - db.Index('idx_compapp_computer', 'computerid'), - db.Index('idx_compapp_app', 'appid'), - ) - - def to_dict(self): - """Convert to dictionary.""" - return { - 'id': self.id, - 'computerid': self.computerid, - 'appid': self.appid, - 'appversionid': self.appversionid, - 'isactive': self.isactive, - 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, - 'application': { - 'appid': self.application.appid, - 'appname': self.application.appname, - 'appdescription': self.application.appdescription, - } if self.application else None, - 'version': self.appversion.version if self.appversion else None - } - - def __repr__(self): - return f"" +"""Computer plugin models.""" + +from shopdb.api import db, BaseModel + + +class ComputerType(BaseModel): + """ + Computer type classification. + + Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. + """ + __tablename__ = 'computertypes' + + computertypeid = db.Column(db.Integer, primary_key=True) + computertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Computer(BaseModel): + """ + Computer-specific extension data. + + Links to core Asset table via assetid. + Stores computer-specific fields like hostname, OS, logged in user, etc. + """ + __tablename__ = 'computers' + + computerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Computer classification + computertypeid = db.Column( + db.Integer, + db.ForeignKey('computertypes.computertypeid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Operating system + osid = db.Column( + db.Integer, + db.ForeignKey('operatingsystems.osid'), + nullable=True + ) + + # Hardware make/model (PCs carry vendor + model like equipment) + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Status tracking + loggedinuser = db.Column(db.String(100), nullable=True) + lastreporteddate = db.Column(db.DateTime, nullable=True) + lastboottime = db.Column(db.DateTime, nullable=True) + + # Remote access features + isvnc = db.Column( + db.Boolean, + default=False, + comment='VNC remote access enabled' + ) + iswinrm = db.Column( + db.Boolean, + default=False, + comment='WinRM enabled' + ) + + # Classification flags + isshopfloor = db.Column( + db.Boolean, + default=False, + comment='Shopfloor PC (vs office PC)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('computer', uselist=False, lazy='joined') + ) + computertype = db.relationship('ComputerType', backref='computers') + operatingsystem = db.relationship('OperatingSystem', backref='computers') + vendor = db.relationship('Vendor') + model = db.relationship('Model') + + # Installed applications (one-to-many) + installedapps = db.relationship( + 'ComputerInstalledApp', + back_populates='computer', + cascade='all, delete-orphan', + lazy='dynamic' + ) + + __table_args__ = ( + db.Index('idx_computer_type', 'computertypeid'), + db.Index('idx_computer_hostname', 'hostname'), + db.Index('idx_computer_os', 'osid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.computertype: + result['computertypename'] = self.computertype.computertype + if self.operatingsystem: + result['osname'] = self.operatingsystem.osname + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + + return result + + +class ComputerInstalledApp(db.Model): + """ + Junction table for applications installed on computers. + + Tracks which applications are installed on which computers, + including version information. + """ + __tablename__ = 'computerinstalledapps' + + id = db.Column(db.Integer, primary_key=True) + computerid = db.Column( + db.Integer, + db.ForeignKey('computers.computerid', ondelete='CASCADE'), + nullable=False + ) + appid = db.Column( + db.Integer, + db.ForeignKey('applications.appid'), + nullable=False + ) + appversionid = db.Column( + db.Integer, + db.ForeignKey('appversions.appversionid'), + nullable=True + ) + # Raw version string from automated collection (when no curated AppVersion) + installedversion = db.Column(db.String(100), nullable=True) + isactive = db.Column(db.Boolean, default=True, nullable=False) + installeddate = db.Column(db.DateTime, default=db.func.now()) + + # Relationships + computer = db.relationship('Computer', back_populates='installedapps') + application = db.relationship('Application') + appversion = db.relationship('AppVersion') + + __table_args__ = ( + db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), + db.Index('idx_compapp_computer', 'computerid'), + db.Index('idx_compapp_app', 'appid'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'id': self.id, + 'computerid': self.computerid, + 'appid': self.appid, + 'appversionid': self.appversionid, + 'isactive': self.isactive, + 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, + 'application': { + 'appid': self.application.appid, + 'appname': self.application.appname, + 'appdescription': self.application.appdescription, + } if self.application else None, + 'version': self.appversion.version if self.appversion else None + } + + def __repr__(self): + return f"" diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index fd42811..6f4e5c0 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1,307 +1,304 @@ -"""Computers plugin main class.""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint -import click - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db -from shopdb.core.models import AssetType, AssetStatus - -from .models import Computer, ComputerType, ComputerInstalledApp -from .api import computers_bp - -logger = logging.getLogger(__name__) - - -class ComputersPlugin(BasePlugin): - """ - Computers plugin - manages PC, server, and workstation assets. - - Computers include shopfloor PCs, engineer workstations, servers, etc. - Uses the new Asset architecture with Computer extension table. - """ - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - """Load plugin manifest from JSON file.""" - manifestpath = Path(__file__).parent / 'manifest.json' - if manifestpath.exists(): - with open(manifestpath, 'r') as f: - return json.load(f) - return {} - - @property - def meta(self) -> PluginMeta: - """Return plugin metadata.""" - return PluginMeta( - name=self._manifest.get('name', 'computers'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', - 'Computer management for PCs, servers, and workstations' - ), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/computers'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return computers_bp - - def get_models(self) -> List[Type]: - """Return list of SQLAlchemy model classes.""" - return [Computer, ComputerType, ComputerInstalledApp] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Computers plugin initialized (v{self.meta.version})") - - # -- ADR-006 collector contract ----------------------------------------- - - def get_collector_schema(self) -> Optional[Dict]: - """Schema for the PC collector payload (matched by hostname).""" - return { - 'identityfield': 'hostname', - 'fields': { - 'hostname': {'type': 'string', 'required': True}, - 'serialnumber': {'type': 'string'}, - 'currentuser': {'type': 'string'}, - 'lastboottime': {'type': 'string', 'format': 'date-time'}, - 'ipaddress': {'type': 'string'}, - 'installedsoftware': { - 'type': 'array', - 'items': {'name': 'string', 'version': 'string'}, - }, - }, - } - - def apply_collector_payload(self, payload: Dict) -> Dict: - """Idempotent upsert of a PC from a collector payload (by hostname).""" - from datetime import datetime - from shopdb.core.models import ( - Asset, AssetType, Application, Communication, CommunicationType, - ) - - warnings = [] - hostname = (payload.get('hostname') or '').strip() - if not hostname: - raise ValueError('hostname is required') - - comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() - if not comp: - comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid) - .filter(Asset.assetnumber.ilike(hostname)).first()) - - action = 'updated' - if not comp: - atype = AssetType.query.filter_by(assettype='computer').first() - asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, - statusid=1) - db.session.add(asset) - db.session.flush() - comp = Computer(assetid=asset.assetid, hostname=hostname) - db.session.add(comp) - db.session.flush() - action = 'created' - - comp.lastreporteddate = datetime.utcnow() - if payload.get('lastboottime'): - try: - comp.lastboottime = datetime.fromisoformat( - payload['lastboottime'].replace('Z', '+00:00')) - except (ValueError, AttributeError): - warnings.append('lastboottime not parseable') - if payload.get('currentuser'): - comp.loggedinuser = payload['currentuser'] - if payload.get('serialnumber') and comp.asset: - comp.asset.serialnumber = payload['serialnumber'] - - if payload.get('ipaddress'): - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - primary = Communication.query.filter_by( - assetid=comp.assetid, isprimary=True).first() - if primary: - primary.ipaddress = payload['ipaddress'] - elif ip_comtype: - db.session.add(Communication( - assetid=comp.assetid, comtypeid=ip_comtype.comtypeid, - ipaddress=payload['ipaddress'], isprimary=True)) - - for app_data in payload.get('installedsoftware', []) or []: - name = app_data.get('name') - if not name: - continue - app = Application.query.filter(Application.appname.ilike(name)).first() - if not app: - warnings.append(f'unknown application: {name}') - continue - installed = ComputerInstalledApp.query.filter_by( - computerid=comp.computerid, appid=app.appid).first() - version = app_data.get('version') - if installed: - installed.installedversion = version - installed.isactive = True - else: - db.session.add(ComputerInstalledApp( - computerid=comp.computerid, appid=app.appid, - installedversion=version)) - - db.session.commit() - return { - 'action': action, - 'assetid': comp.assetid, - 'identityvalue': hostname, - 'warnings': warnings, - } - - def on_install(self, app: Flask) -> None: - """Called when plugin is installed.""" - with app.app_context(): - self._ensure_asset_type() - self._ensure_computer_types() - logger.info("Computers plugin installed") - - def _ensure_asset_type(self) -> None: - """Ensure computer asset type exists.""" - existing = AssetType.query.filter_by(assettype='computer').first() - if not existing: - at = AssetType( - assettype='computer', - pluginname='computers', - tablename='computers', - description='PCs, servers, and workstations', - icon='desktop' - ) - db.session.add(at) - logger.debug("Created asset type: computer") - db.session.commit() - - def _ensure_computer_types(self) -> None: - """Ensure basic computer types exist.""" - computer_types = [ - ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), - ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), - ('CMM PC', 'PC dedicated to CMM operation', 'desktop'), - ('Server', 'Server system', 'server'), - ('Kiosk', 'Kiosk or info display PC', 'tv'), - ('Laptop', 'Laptop computer', 'laptop'), - ('Virtual Machine', 'Virtual machine', 'cloud'), - ('Other', 'Other computer type', 'desktop'), - ] - - for name, description, icon in computer_types: - existing = ComputerType.query.filter_by(computertype=name).first() - if not existing: - ct = ComputerType( - computertype=name, - description=description, - icon=icon - ) - db.session.add(ct) - logger.debug(f"Created computer type: {name}") - - db.session.commit() - - def on_uninstall(self, app: Flask) -> None: - """Called when plugin is uninstalled.""" - logger.info("Computers plugin uninstalled") - - def get_cli_commands(self) -> List: - """Return CLI commands for this plugin.""" - - @click.group('computers') - def computerscli(): - """Computers plugin commands.""" - pass - - @computerscli.command('list-types') - def list_types(): - """List all computer types.""" - from flask import current_app - - with current_app.app_context(): - types = ComputerType.query.filter_by(isactive=True).all() - if not types: - click.echo('No computer types found.') - return - - click.echo('Computer Types:') - for t in types: - click.echo(f" [{t.computertypeid}] {t.computertype}") - - @computerscli.command('stats') - def stats(): - """Show computer statistics.""" - from flask import current_app - from shopdb.core.models import Asset - - with current_app.app_context(): - total = db.session.query(Computer).join(Asset).filter( - Asset.isactive == True - ).count() - - click.echo(f"Total active computers: {total}") - - # Shopfloor count - shopfloor = db.session.query(Computer).join(Asset).filter( - Asset.isactive == True, - Computer.isshopfloor == True - ).count() - - click.echo(f" Shopfloor PCs: {shopfloor}") - click.echo(f" Other: {total - shopfloor}") - - @computerscli.command('find') - @click.argument('hostname') - def find_by_hostname(hostname): - """Find a computer by hostname.""" - from flask import current_app - - with current_app.app_context(): - comp = Computer.query.filter( - Computer.hostname.ilike(f'%{hostname}%') - ).first() - - if not comp: - click.echo(f'No computer found matching hostname: {hostname}') - return - - click.echo(f'Found: {comp.hostname}') - click.echo(f' Asset: {comp.asset.assetnumber}') - click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}') - click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}') - click.echo(f' Logged in: {comp.loggedinuser or "N/A"}') - - return [computerscli] - - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Computer Status', - 'component': 'ComputerStatusWidget', - 'endpoint': '/api/computers/dashboard/summary', - 'size': 'medium', - 'position': 6, - }, - ] - - def get_navigation_items(self) -> List[Dict]: - """Return navigation menu items.""" - return [ - { - 'name': 'PCs', - 'icon': 'desktop', - 'route': '/pcs', - 'position': 15, - }, - ] +"""Computers plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType, AssetStatus + +from .models import Computer, ComputerType, ComputerInstalledApp +from .api import computers_bp + +logger = logging.getLogger(__name__) + + +class ComputersPlugin(BasePlugin): + """ + Computers plugin - manages PC, server, and workstation assets. + + Computers include shopfloor PCs, engineer workstations, servers, etc. + Uses the new Asset architecture with Computer extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'computers'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Computer management for PCs, servers, and workstations' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/computers'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return computers_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Computer, ComputerType, ComputerInstalledApp] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Computers plugin initialized (v{self.meta.version})") + + # -- ADR-006 collector contract ----------------------------------------- + + def get_collector_schema(self) -> Optional[Dict]: + """Schema for the PC collector payload (matched by hostname).""" + return { + 'identityfield': 'hostname', + 'fields': { + 'hostname': {'type': 'string', 'required': True}, + 'serialnumber': {'type': 'string'}, + 'currentuser': {'type': 'string'}, + 'lastboottime': {'type': 'string', 'format': 'date-time'}, + 'ipaddress': {'type': 'string'}, + 'installedsoftware': { + 'type': 'array', + 'items': {'name': 'string', 'version': 'string'}, + }, + }, + } + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotent upsert of a PC from a collector payload (by hostname).""" + from datetime import datetime + from shopdb.api import Asset, AssetType, Application, Communication, CommunicationType + + warnings = [] + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + + comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() + if not comp: + comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.assetnumber.ilike(hostname)).first()) + + action = 'updated' + if not comp: + atype = AssetType.query.filter_by(assettype='computer').first() + asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, + statusid=1) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname=hostname) + db.session.add(comp) + db.session.flush() + action = 'created' + + comp.lastreporteddate = datetime.utcnow() + if payload.get('lastboottime'): + try: + comp.lastboottime = datetime.fromisoformat( + payload['lastboottime'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + warnings.append('lastboottime not parseable') + if payload.get('currentuser'): + comp.loggedinuser = payload['currentuser'] + if payload.get('serialnumber') and comp.asset: + comp.asset.serialnumber = payload['serialnumber'] + + if payload.get('ipaddress'): + ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() + primary = Communication.query.filter_by( + assetid=comp.assetid, isprimary=True).first() + if primary: + primary.ipaddress = payload['ipaddress'] + elif ip_comtype: + db.session.add(Communication( + assetid=comp.assetid, comtypeid=ip_comtype.comtypeid, + ipaddress=payload['ipaddress'], isprimary=True)) + + for app_data in payload.get('installedsoftware', []) or []: + name = app_data.get('name') + if not name: + continue + app = Application.query.filter(Application.appname.ilike(name)).first() + if not app: + warnings.append(f'unknown application: {name}') + continue + installed = ComputerInstalledApp.query.filter_by( + computerid=comp.computerid, appid=app.appid).first() + version = app_data.get('version') + if installed: + installed.installedversion = version + installed.isactive = True + else: + db.session.add(ComputerInstalledApp( + computerid=comp.computerid, appid=app.appid, + installedversion=version)) + + db.session.commit() + return { + 'action': action, + 'assetid': comp.assetid, + 'identityvalue': hostname, + 'warnings': warnings, + } + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_computer_types() + logger.info("Computers plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure computer asset type exists.""" + existing = AssetType.query.filter_by(assettype='computer').first() + if not existing: + at = AssetType( + assettype='computer', + pluginname='computers', + tablename='computers', + description='PCs, servers, and workstations', + icon='desktop' + ) + db.session.add(at) + logger.debug("Created asset type: computer") + db.session.commit() + + def _ensure_computer_types(self) -> None: + """Ensure basic computer types exist.""" + computer_types = [ + ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), + ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), + ('CMM PC', 'PC dedicated to CMM operation', 'desktop'), + ('Server', 'Server system', 'server'), + ('Kiosk', 'Kiosk or info display PC', 'tv'), + ('Laptop', 'Laptop computer', 'laptop'), + ('Virtual Machine', 'Virtual machine', 'cloud'), + ('Other', 'Other computer type', 'desktop'), + ] + + for name, description, icon in computer_types: + existing = ComputerType.query.filter_by(computertype=name).first() + if not existing: + ct = ComputerType( + computertype=name, + description=description, + icon=icon + ) + db.session.add(ct) + logger.debug(f"Created computer type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Computers plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('computers') + def computerscli(): + """Computers plugin commands.""" + pass + + @computerscli.command('list-types') + def list_types(): + """List all computer types.""" + from flask import current_app + + with current_app.app_context(): + types = ComputerType.query.filter_by(isactive=True).all() + if not types: + click.echo('No computer types found.') + return + + click.echo('Computer Types:') + for t in types: + click.echo(f" [{t.computertypeid}] {t.computertype}") + + @computerscli.command('stats') + def stats(): + """Show computer statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active computers: {total}") + + # Shopfloor count + shopfloor = db.session.query(Computer).join(Asset).filter( + Asset.isactive == True, + Computer.isshopfloor == True + ).count() + + click.echo(f" Shopfloor PCs: {shopfloor}") + click.echo(f" Other: {total - shopfloor}") + + @computerscli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a computer by hostname.""" + from flask import current_app + + with current_app.app_context(): + comp = Computer.query.filter( + Computer.hostname.ilike(f'%{hostname}%') + ).first() + + if not comp: + click.echo(f'No computer found matching hostname: {hostname}') + return + + click.echo(f'Found: {comp.hostname}') + click.echo(f' Asset: {comp.asset.assetnumber}') + click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}') + click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}') + click.echo(f' Logged in: {comp.loggedinuser or "N/A"}') + + return [computerscli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Computer Status', + 'component': 'ComputerStatusWidget', + 'endpoint': '/api/computers/dashboard/summary', + 'size': 'medium', + 'position': 6, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'PCs', + 'icon': 'desktop', + 'route': '/pcs', + 'position': 15, + }, + ] diff --git a/plugins/equipment/api/routes.py b/plugins/equipment/api/routes.py index ca9ef74..8ca5156 100644 --- a/plugins/equipment/api/routes.py +++ b/plugins/equipment/api/routes.py @@ -3,15 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Equipment, EquipmentType @@ -446,7 +438,7 @@ def dashboard_summary(): ).all() # Count by status - from shopdb.core.models import AssetStatus + from shopdb.api import AssetStatus by_status = db.session.query( AssetStatus.status, db.func.count(Equipment.equipmentid) diff --git a/plugins/equipment/models/equipment.py b/plugins/equipment/models/equipment.py index fe0d2c0..565ed64 100644 --- a/plugins/equipment/models/equipment.py +++ b/plugins/equipment/models/equipment.py @@ -1,133 +1,132 @@ -"""Equipment plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class EquipmentType(BaseModel): - """ - Equipment type classification. - - Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. - """ - __tablename__ = 'equipmenttypes' - - equipmenttypeid = db.Column(db.Integer, primary_key=True) - equipmenttype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - icon = db.Column(db.String(50), comment='Icon name for UI') - - def __repr__(self): - return f"" - - -class Equipment(BaseModel): - """ - Equipment-specific extension data. - - Links to core Asset table via assetid. - Stores equipment-specific fields like type, model, vendor, etc. - """ - __tablename__ = 'equipment' - - equipmentid = db.Column(db.Integer, primary_key=True) - - # Link to core asset - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Equipment classification - equipmenttypeid = db.Column( - db.Integer, - db.ForeignKey('equipmenttypes.equipmenttypeid'), - nullable=True - ) - - # Vendor and model - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - modelnumberid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True - ) - - # Equipment-specific fields - requiresmanualconfig = db.Column( - db.Boolean, - default=False, - comment='Multi-PC machine needs manual configuration' - ) - islocationonly = db.Column( - db.Boolean, - default=False, - comment='Virtual location marker (not actual equipment)' - ) - - # Maintenance tracking - lastmaintenancedate = db.Column(db.DateTime, nullable=True) - nextmaintenancedate = db.Column(db.DateTime, nullable=True) - maintenanceintervaldays = db.Column(db.Integer, nullable=True) - - # Controller info (for CNC machines) - controllervendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True, - comment='Controller vendor (e.g., FANUC)' - ) - controllermodelid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True, - comment='Controller model (e.g., 31B)' - ) - - # Relationships - asset = db.relationship( - 'Asset', - backref=db.backref('equipment', uselist=False, lazy='joined') - ) - equipmenttype = db.relationship('EquipmentType', backref='equipment') - vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') - model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') - controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') - controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models') - - __table_args__ = ( - db.Index('idx_equipment_type', 'equipmenttypeid'), - db.Index('idx_equipment_vendor', 'vendorid'), - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary with related names.""" - result = super().to_dict() - - # Add related object names - if self.equipmenttype: - result['equipmenttypename'] = self.equipmenttype.equipmenttype - if self.vendor: - result['vendorname'] = self.vendor.vendor - if self.model: - result['modelname'] = self.model.modelnumber - if self.model.imageurl: - result['imageurl'] = self.model.imageurl - - # Add controller info - if self.controllervendor: - result['controllervendorname'] = self.controllervendor.vendor - if self.controllermodel: - result['controllermodelname'] = self.controllermodel.modelnumber - - return result +"""Equipment plugin models.""" + +from shopdb.api import db, BaseModel + + +class EquipmentType(BaseModel): + """ + Equipment type classification. + + Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. + """ + __tablename__ = 'equipmenttypes' + + equipmenttypeid = db.Column(db.Integer, primary_key=True) + equipmenttype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Equipment(BaseModel): + """ + Equipment-specific extension data. + + Links to core Asset table via assetid. + Stores equipment-specific fields like type, model, vendor, etc. + """ + __tablename__ = 'equipment' + + equipmentid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Equipment classification + equipmenttypeid = db.Column( + db.Integer, + db.ForeignKey('equipmenttypes.equipmenttypeid'), + nullable=True + ) + + # Vendor and model + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Equipment-specific fields + requiresmanualconfig = db.Column( + db.Boolean, + default=False, + comment='Multi-PC machine needs manual configuration' + ) + islocationonly = db.Column( + db.Boolean, + default=False, + comment='Virtual location marker (not actual equipment)' + ) + + # Maintenance tracking + lastmaintenancedate = db.Column(db.DateTime, nullable=True) + nextmaintenancedate = db.Column(db.DateTime, nullable=True) + maintenanceintervaldays = db.Column(db.Integer, nullable=True) + + # Controller info (for CNC machines) + controllervendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True, + comment='Controller vendor (e.g., FANUC)' + ) + controllermodelid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True, + comment='Controller model (e.g., 31B)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('equipment', uselist=False, lazy='joined') + ) + equipmenttype = db.relationship('EquipmentType', backref='equipment') + vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') + model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') + controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') + controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models') + + __table_args__ = ( + db.Index('idx_equipment_type', 'equipmenttypeid'), + db.Index('idx_equipment_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.equipmenttype: + result['equipmenttypename'] = self.equipmenttype.equipmenttype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + if self.model.imageurl: + result['imageurl'] = self.model.imageurl + + # Add controller info + if self.controllervendor: + result['controllervendorname'] = self.controllervendor.vendor + if self.controllermodel: + result['controllermodelname'] = self.controllermodel.modelnumber + + return result diff --git a/plugins/equipment/plugin.py b/plugins/equipment/plugin.py index bbe5b06..dc576a8 100644 --- a/plugins/equipment/plugin.py +++ b/plugins/equipment/plugin.py @@ -1,220 +1,219 @@ -"""Equipment plugin main class.""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint -import click - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db -from shopdb.core.models import AssetType, AssetStatus - -from .models import Equipment, EquipmentType -from .api import equipment_bp - -logger = logging.getLogger(__name__) - - -class EquipmentPlugin(BasePlugin): - """ - Equipment plugin - manages manufacturing equipment assets. - - Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. - Uses the new Asset architecture with Equipment extension table. - """ - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - """Load plugin manifest from JSON file.""" - manifestpath = Path(__file__).parent / 'manifest.json' - if manifestpath.exists(): - with open(manifestpath, 'r') as f: - return json.load(f) - return {} - - @property - def meta(self) -> PluginMeta: - """Return plugin metadata.""" - return PluginMeta( - name=self._manifest.get('name', 'equipment'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', - 'Equipment management for manufacturing assets' - ), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/equipment'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return equipment_bp - - def get_models(self) -> List[Type]: - """Return list of SQLAlchemy model classes.""" - return [Equipment, EquipmentType] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Equipment plugin initialized (v{self.meta.version})") - - def on_install(self, app: Flask) -> None: - """Called when plugin is installed.""" - with app.app_context(): - self._ensure_asset_type() - self._ensure_asset_statuses() - self._ensure_equipment_types() - logger.info("Equipment plugin installed") - - def _ensure_asset_type(self) -> None: - """Ensure equipment asset type exists.""" - existing = AssetType.query.filter_by(assettype='equipment').first() - if not existing: - at = AssetType( - assettype='equipment', - pluginname='equipment', - tablename='equipment', - description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', - icon='cog' - ) - db.session.add(at) - logger.debug("Created asset type: equipment") - db.session.commit() - - def _ensure_asset_statuses(self) -> None: - """Ensure standard asset statuses exist.""" - statuses = [ - ('In Use', 'Asset is currently in use', '#28a745'), - ('Spare', 'Spare/backup asset', '#17a2b8'), - ('Retired', 'Asset has been retired', '#6c757d'), - ('Maintenance', 'Asset is under maintenance', '#ffc107'), - ('Decommissioned', 'Asset has been decommissioned', '#dc3545'), - ] - - for name, description, color in statuses: - existing = AssetStatus.query.filter_by(status=name).first() - if not existing: - s = AssetStatus( - status=name, - description=description, - color=color - ) - db.session.add(s) - logger.debug(f"Created asset status: {name}") - - db.session.commit() - - def _ensure_equipment_types(self) -> None: - """Ensure basic equipment types exist.""" - equipment_types = [ - ('CNC', 'Computer Numerical Control machine', 'cnc'), - ('CMM', 'Coordinate Measuring Machine', 'cmm'), - ('Lathe', 'Lathe machine', 'lathe'), - ('Grinder', 'Grinding machine', 'grinder'), - ('EDM', 'Electrical Discharge Machine', 'edm'), - ('Part Marker', 'Part marking/engraving equipment', 'marker'), - ('Mill', 'Milling machine', 'mill'), - ('Press', 'Press machine', 'press'), - ('Robot', 'Industrial robot', 'robot'), - ('Other', 'Other equipment type', 'cog'), - ] - - for name, description, icon in equipment_types: - existing = EquipmentType.query.filter_by(equipmenttype=name).first() - if not existing: - et = EquipmentType( - equipmenttype=name, - description=description, - icon=icon - ) - db.session.add(et) - logger.debug(f"Created equipment type: {name}") - - db.session.commit() - - def on_uninstall(self, app: Flask) -> None: - """Called when plugin is uninstalled.""" - logger.info("Equipment plugin uninstalled") - - def get_cli_commands(self) -> List: - """Return CLI commands for this plugin.""" - - @click.group('equipment') - def equipmentcli(): - """Equipment plugin commands.""" - pass - - @equipmentcli.command('list-types') - def list_types(): - """List all equipment types.""" - from flask import current_app - - with current_app.app_context(): - types = EquipmentType.query.filter_by(isactive=True).all() - if not types: - click.echo('No equipment types found.') - return - - click.echo('Equipment Types:') - for t in types: - click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}") - - @equipmentcli.command('stats') - def stats(): - """Show equipment statistics.""" - from flask import current_app - from shopdb.core.models import Asset - - with current_app.app_context(): - total = db.session.query(Equipment).join(Asset).filter( - Asset.isactive == True - ).count() - - click.echo(f"Total active equipment: {total}") - - # By type - by_type = db.session.query( - EquipmentType.equipmenttype, - db.func.count(Equipment.equipmentid) - ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid - ).join(Asset, Asset.assetid == Equipment.assetid - ).filter(Asset.isactive == True - ).group_by(EquipmentType.equipmenttype - ).all() - - if by_type: - click.echo("\nBy Type:") - for t, c in by_type: - click.echo(f" {t}: {c}") - - return [equipmentcli] - - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Equipment Status', - 'component': 'EquipmentStatusWidget', - 'endpoint': '/api/equipment/dashboard/summary', - 'size': 'medium', - 'position': 5, - }, - ] - - def get_navigation_items(self) -> List[Dict]: - """Return navigation menu items.""" - return [ - { - 'name': 'Equipment', - 'icon': 'cog', - 'route': '/machines', - 'position': 10, - }, - ] +"""Equipment plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType, AssetStatus + +from .models import Equipment, EquipmentType +from .api import equipment_bp + +logger = logging.getLogger(__name__) + + +class EquipmentPlugin(BasePlugin): + """ + Equipment plugin - manages manufacturing equipment assets. + + Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. + Uses the new Asset architecture with Equipment extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'equipment'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Equipment management for manufacturing assets' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/equipment'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return equipment_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Equipment, EquipmentType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Equipment plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_asset_statuses() + self._ensure_equipment_types() + logger.info("Equipment plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure equipment asset type exists.""" + existing = AssetType.query.filter_by(assettype='equipment').first() + if not existing: + at = AssetType( + assettype='equipment', + pluginname='equipment', + tablename='equipment', + description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', + icon='cog' + ) + db.session.add(at) + logger.debug("Created asset type: equipment") + db.session.commit() + + def _ensure_asset_statuses(self) -> None: + """Ensure standard asset statuses exist.""" + statuses = [ + ('In Use', 'Asset is currently in use', '#28a745'), + ('Spare', 'Spare/backup asset', '#17a2b8'), + ('Retired', 'Asset has been retired', '#6c757d'), + ('Maintenance', 'Asset is under maintenance', '#ffc107'), + ('Decommissioned', 'Asset has been decommissioned', '#dc3545'), + ] + + for name, description, color in statuses: + existing = AssetStatus.query.filter_by(status=name).first() + if not existing: + s = AssetStatus( + status=name, + description=description, + color=color + ) + db.session.add(s) + logger.debug(f"Created asset status: {name}") + + db.session.commit() + + def _ensure_equipment_types(self) -> None: + """Ensure basic equipment types exist.""" + equipment_types = [ + ('CNC', 'Computer Numerical Control machine', 'cnc'), + ('CMM', 'Coordinate Measuring Machine', 'cmm'), + ('Lathe', 'Lathe machine', 'lathe'), + ('Grinder', 'Grinding machine', 'grinder'), + ('EDM', 'Electrical Discharge Machine', 'edm'), + ('Part Marker', 'Part marking/engraving equipment', 'marker'), + ('Mill', 'Milling machine', 'mill'), + ('Press', 'Press machine', 'press'), + ('Robot', 'Industrial robot', 'robot'), + ('Other', 'Other equipment type', 'cog'), + ] + + for name, description, icon in equipment_types: + existing = EquipmentType.query.filter_by(equipmenttype=name).first() + if not existing: + et = EquipmentType( + equipmenttype=name, + description=description, + icon=icon + ) + db.session.add(et) + logger.debug(f"Created equipment type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Equipment plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('equipment') + def equipmentcli(): + """Equipment plugin commands.""" + pass + + @equipmentcli.command('list-types') + def list_types(): + """List all equipment types.""" + from flask import current_app + + with current_app.app_context(): + types = EquipmentType.query.filter_by(isactive=True).all() + if not types: + click.echo('No equipment types found.') + return + + click.echo('Equipment Types:') + for t in types: + click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}") + + @equipmentcli.command('stats') + def stats(): + """Show equipment statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(Equipment).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active equipment: {total}") + + # By type + by_type = db.session.query( + EquipmentType.equipmenttype, + db.func.count(Equipment.equipmentid) + ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid + ).join(Asset, Asset.assetid == Equipment.assetid + ).filter(Asset.isactive == True + ).group_by(EquipmentType.equipmenttype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + return [equipmentcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Equipment Status', + 'component': 'EquipmentStatusWidget', + 'endpoint': '/api/equipment/dashboard/summary', + 'size': 'medium', + 'position': 5, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Equipment', + 'icon': 'cog', + 'route': '/machines', + 'position': 10, + }, + ] diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 40e1113..649d0ee 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -3,15 +3,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.core.models import Asset, AssetType, Vendor, AuditLog -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py index da8ac04..c4a8490 100644 --- a/plugins/network/models/network_device.py +++ b/plugins/network/models/network_device.py @@ -1,121 +1,120 @@ -"""Network device plugin models.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class NetworkDeviceType(BaseModel): - """ - Network device type classification. - - Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. - """ - __tablename__ = 'networkdevicetypes' - - networkdevicetypeid = db.Column(db.Integer, primary_key=True) - networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - icon = db.Column(db.String(50), comment='Icon name for UI') - - def __repr__(self): - return f"" - - -class NetworkDevice(BaseModel): - """ - Network device-specific extension data. - - Links to core Asset table via assetid. - Stores network device-specific fields like hostname, firmware, ports, etc. - """ - __tablename__ = 'networkdevices' - - networkdeviceid = db.Column(db.Integer, primary_key=True) - - # Link to core asset - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Network device classification - networkdevicetypeid = db.Column( - db.Integer, - db.ForeignKey('networkdevicetypes.networkdevicetypeid'), - nullable=True - ) - - # Vendor - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - - # Network identity - hostname = db.Column( - db.String(100), - index=True, - comment='Network hostname' - ) - - # Firmware/software version - firmwareversion = db.Column(db.String(100), nullable=True) - - # Physical characteristics - portcount = db.Column( - db.Integer, - nullable=True, - comment='Number of ports (for switches)' - ) - - # Features - ispoe = db.Column( - db.Boolean, - default=False, - comment='Power over Ethernet capable' - ) - ismanaged = db.Column( - db.Boolean, - default=False, - comment='Managed device (SNMP, web interface, etc.)' - ) - - # For IDF/closet locations - rackunit = db.Column( - db.String(20), - nullable=True, - comment='Rack unit position (e.g., U1, U5)' - ) - - # Relationships - asset = db.relationship( - 'Asset', - backref=db.backref('network_device', uselist=False, lazy='joined') - ) - networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') - vendor = db.relationship('Vendor', backref='network_devices') - - __table_args__ = ( - db.Index('idx_netdev_type', 'networkdevicetypeid'), - db.Index('idx_netdev_hostname', 'hostname'), - db.Index('idx_netdev_vendor', 'vendorid'), - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary with related names.""" - result = super().to_dict() - - # Add related object names - if self.networkdevicetype: - result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype - if self.vendor: - result['vendorname'] = self.vendor.vendor - - return result +"""Network device plugin models.""" + +from shopdb.api import db, BaseModel + + +class NetworkDeviceType(BaseModel): + """ + Network device type classification. + + Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. + """ + __tablename__ = 'networkdevicetypes' + + networkdevicetypeid = db.Column(db.Integer, primary_key=True) + networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class NetworkDevice(BaseModel): + """ + Network device-specific extension data. + + Links to core Asset table via assetid. + Stores network device-specific fields like hostname, firmware, ports, etc. + """ + __tablename__ = 'networkdevices' + + networkdeviceid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Network device classification + networkdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('networkdevicetypes.networkdevicetypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Firmware/software version + firmwareversion = db.Column(db.String(100), nullable=True) + + # Physical characteristics + portcount = db.Column( + db.Integer, + nullable=True, + comment='Number of ports (for switches)' + ) + + # Features + ispoe = db.Column( + db.Boolean, + default=False, + comment='Power over Ethernet capable' + ) + ismanaged = db.Column( + db.Boolean, + default=False, + comment='Managed device (SNMP, web interface, etc.)' + ) + + # For IDF/closet locations + rackunit = db.Column( + db.String(20), + nullable=True, + comment='Rack unit position (e.g., U1, U5)' + ) + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('network_device', uselist=False, lazy='joined') + ) + networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') + vendor = db.relationship('Vendor', backref='network_devices') + + __table_args__ = ( + db.Index('idx_netdev_type', 'networkdevicetypeid'), + db.Index('idx_netdev_hostname', 'hostname'), + db.Index('idx_netdev_vendor', 'vendorid'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.networkdevicetype: + result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype + if self.vendor: + result['vendorname'] = self.vendor.vendor + + return result diff --git a/plugins/network/models/subnet.py b/plugins/network/models/subnet.py index e2332f2..6a52b9f 100644 --- a/plugins/network/models/subnet.py +++ b/plugins/network/models/subnet.py @@ -1,146 +1,145 @@ -"""Subnet and VLAN models for network plugin.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class VLAN(BaseModel): - """ - VLAN definition. - - Represents a virtual LAN for network segmentation. - """ - __tablename__ = 'vlans' - - vlanid = db.Column(db.Integer, primary_key=True) - vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') - name = db.Column(db.String(100), nullable=False, comment='VLAN name') - description = db.Column(db.Text, nullable=True) - - # Optional classification - vlantype = db.Column( - db.String(50), - nullable=True, - comment='Type: data, voice, management, guest, etc.' - ) - - # Relationships - subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic') - - __table_args__ = ( - db.Index('idx_vlan_number', 'vlannumber'), - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary.""" - result = super().to_dict() - result['subnetcount'] = self.subnets.count() if self.subnets else 0 - return result - - -class Subnet(BaseModel): - """ - Subnet/IP network definition. - - Represents an IP subnet with optional VLAN association. - """ - __tablename__ = 'subnets' - - subnetid = db.Column(db.Integer, primary_key=True) - - # Network definition - cidr = db.Column( - db.String(18), - unique=True, - nullable=False, - comment='CIDR notation (e.g., 10.1.1.0/24)' - ) - name = db.Column(db.String(100), nullable=False, comment='Subnet name') - description = db.Column(db.Text, nullable=True) - - # Network details - gatewayip = db.Column( - db.String(15), - nullable=True, - comment='Default gateway IP address' - ) - subnetmask = db.Column( - db.String(15), - nullable=True, - comment='Subnet mask (e.g., 255.255.255.0)' - ) - networkaddress = db.Column( - db.String(15), - nullable=True, - comment='Network address (e.g., 10.1.1.0)' - ) - broadcastaddress = db.Column( - db.String(15), - nullable=True, - comment='Broadcast address (e.g., 10.1.1.255)' - ) - - # VLAN association - vlanid = db.Column( - db.Integer, - db.ForeignKey('vlans.vlanid'), - nullable=True - ) - - # Classification - subnettype = db.Column( - db.String(50), - nullable=True, - comment='Type: production, development, management, dmz, etc.' - ) - - # Location association - locationid = db.Column( - db.Integer, - db.ForeignKey('locations.locationid'), - nullable=True - ) - - # DHCP settings - dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') - dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') - dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP') - - # DNS settings - dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') - dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server') - - # Relationships - location = db.relationship('Location', backref='subnets') - - __table_args__ = ( - db.Index('idx_subnet_cidr', 'cidr'), - db.Index('idx_subnet_vlan', 'vlanid'), - db.Index('idx_subnet_location', 'locationid'), - ) - - def __repr__(self): - return f"" - - @property - def vlan_number(self): - """Get the VLAN number.""" - return self.vlan.vlannumber if self.vlan else None - - def to_dict(self): - """Convert to dictionary with related data.""" - result = super().to_dict() - - # Add VLAN info - if self.vlan: - result['vlannumber'] = self.vlan.vlannumber - result['vlanname'] = self.vlan.name - - # Add location info - if self.location: - result['locationname'] = self.location.locationname - - return result +"""Subnet and VLAN models for network plugin.""" + +from shopdb.api import db, BaseModel + + +class VLAN(BaseModel): + """ + VLAN definition. + + Represents a virtual LAN for network segmentation. + """ + __tablename__ = 'vlans' + + vlanid = db.Column(db.Integer, primary_key=True) + vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') + name = db.Column(db.String(100), nullable=False, comment='VLAN name') + description = db.Column(db.Text, nullable=True) + + # Optional classification + vlantype = db.Column( + db.String(50), + nullable=True, + comment='Type: data, voice, management, guest, etc.' + ) + + # Relationships + subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic') + + __table_args__ = ( + db.Index('idx_vlan_number', 'vlannumber'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary.""" + result = super().to_dict() + result['subnetcount'] = self.subnets.count() if self.subnets else 0 + return result + + +class Subnet(BaseModel): + """ + Subnet/IP network definition. + + Represents an IP subnet with optional VLAN association. + """ + __tablename__ = 'subnets' + + subnetid = db.Column(db.Integer, primary_key=True) + + # Network definition + cidr = db.Column( + db.String(18), + unique=True, + nullable=False, + comment='CIDR notation (e.g., 10.1.1.0/24)' + ) + name = db.Column(db.String(100), nullable=False, comment='Subnet name') + description = db.Column(db.Text, nullable=True) + + # Network details + gatewayip = db.Column( + db.String(15), + nullable=True, + comment='Default gateway IP address' + ) + subnetmask = db.Column( + db.String(15), + nullable=True, + comment='Subnet mask (e.g., 255.255.255.0)' + ) + networkaddress = db.Column( + db.String(15), + nullable=True, + comment='Network address (e.g., 10.1.1.0)' + ) + broadcastaddress = db.Column( + db.String(15), + nullable=True, + comment='Broadcast address (e.g., 10.1.1.255)' + ) + + # VLAN association + vlanid = db.Column( + db.Integer, + db.ForeignKey('vlans.vlanid'), + nullable=True + ) + + # Classification + subnettype = db.Column( + db.String(50), + nullable=True, + comment='Type: production, development, management, dmz, etc.' + ) + + # Location association + locationid = db.Column( + db.Integer, + db.ForeignKey('locations.locationid'), + nullable=True + ) + + # DHCP settings + dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') + dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') + dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP') + + # DNS settings + dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') + dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server') + + # Relationships + location = db.relationship('Location', backref='subnets') + + __table_args__ = ( + db.Index('idx_subnet_cidr', 'cidr'), + db.Index('idx_subnet_vlan', 'vlanid'), + db.Index('idx_subnet_location', 'locationid'), + ) + + def __repr__(self): + return f"" + + @property + def vlan_number(self): + """Get the VLAN number.""" + return self.vlan.vlannumber if self.vlan else None + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add VLAN info + if self.vlan: + result['vlannumber'] = self.vlan.vlannumber + result['vlanname'] = self.vlan.name + + # Add location info + if self.location: + result['locationname'] = self.location.locationname + + return result diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py index 1ca063d..4694ecd 100644 --- a/plugins/network/plugin.py +++ b/plugins/network/plugin.py @@ -1,217 +1,216 @@ -"""Network plugin main class.""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint -import click - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db -from shopdb.core.models import AssetType - -from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN -from .api import network_bp - -logger = logging.getLogger(__name__) - - -class NetworkPlugin(BasePlugin): - """ - Network plugin - manages network device assets. - - Network devices include switches, routers, access points, cameras, IDFs, etc. - Uses the new Asset architecture with NetworkDevice extension table. - """ - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - """Load plugin manifest from JSON file.""" - manifestpath = Path(__file__).parent / 'manifest.json' - if manifestpath.exists(): - with open(manifestpath, 'r') as f: - return json.load(f) - return {} - - @property - def meta(self) -> PluginMeta: - """Return plugin metadata.""" - return PluginMeta( - name=self._manifest.get('name', 'network'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', - 'Network device management for switches, APs, and cameras' - ), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/network'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return network_bp - - def get_models(self) -> List[Type]: - """Return list of SQLAlchemy model classes.""" - return [NetworkDevice, NetworkDeviceType, Subnet, VLAN] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Network plugin initialized (v{self.meta.version})") - - def on_install(self, app: Flask) -> None: - """Called when plugin is installed.""" - with app.app_context(): - self._ensure_asset_type() - self._ensure_network_device_types() - logger.info("Network plugin installed") - - def _ensure_asset_type(self) -> None: - """Ensure network_device asset type exists.""" - existing = AssetType.query.filter_by(assettype='network_device').first() - if not existing: - at = AssetType( - assettype='network_device', - pluginname='network', - tablename='networkdevices', - description='Network infrastructure devices (switches, APs, cameras, etc.)', - icon='network-wired' - ) - db.session.add(at) - logger.debug("Created asset type: network_device") - db.session.commit() - - def _ensure_network_device_types(self) -> None: - """Ensure basic network device types exist.""" - device_types = [ - ('Switch', 'Network switch', 'network-wired'), - ('Router', 'Network router', 'router'), - ('Access Point', 'Wireless access point', 'wifi'), - ('Firewall', 'Network firewall', 'shield'), - ('Camera', 'IP camera', 'video'), - ('IDF', 'Intermediate Distribution Frame/closet', 'box'), - ('MDF', 'Main Distribution Frame', 'building'), - ('Patch Panel', 'Patch panel', 'th'), - ('UPS', 'Uninterruptible power supply', 'battery'), - ('Other', 'Other network device', 'network-wired'), - ] - - for name, description, icon in device_types: - existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() - if not existing: - ndt = NetworkDeviceType( - networkdevicetype=name, - description=description, - icon=icon - ) - db.session.add(ndt) - logger.debug(f"Created network device type: {name}") - - db.session.commit() - - def on_uninstall(self, app: Flask) -> None: - """Called when plugin is uninstalled.""" - logger.info("Network plugin uninstalled") - - def get_cli_commands(self) -> List: - """Return CLI commands for this plugin.""" - - @click.group('network') - def networkcli(): - """Network plugin commands.""" - pass - - @networkcli.command('list-types') - def list_types(): - """List all network device types.""" - from flask import current_app - - with current_app.app_context(): - types = NetworkDeviceType.query.filter_by(isactive=True).all() - if not types: - click.echo('No network device types found.') - return - - click.echo('Network Device Types:') - for t in types: - click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}") - - @networkcli.command('stats') - def stats(): - """Show network device statistics.""" - from flask import current_app - from shopdb.core.models import Asset - - with current_app.app_context(): - total = db.session.query(NetworkDevice).join(Asset).filter( - Asset.isactive == True - ).count() - - click.echo(f"Total active network devices: {total}") - - # By type - by_type = db.session.query( - NetworkDeviceType.networkdevicetype, - db.func.count(NetworkDevice.networkdeviceid) - ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid - ).join(Asset, Asset.assetid == NetworkDevice.assetid - ).filter(Asset.isactive == True - ).group_by(NetworkDeviceType.networkdevicetype - ).all() - - if by_type: - click.echo("\nBy Type:") - for t, c in by_type: - click.echo(f" {t}: {c}") - - @networkcli.command('find') - @click.argument('hostname') - def find_by_hostname(hostname): - """Find a network device by hostname.""" - from flask import current_app - - with current_app.app_context(): - netdev = NetworkDevice.query.filter( - NetworkDevice.hostname.ilike(f'%{hostname}%') - ).first() - - if not netdev: - click.echo(f'No network device found matching hostname: {hostname}') - return - - click.echo(f'Found: {netdev.hostname}') - click.echo(f' Asset: {netdev.asset.assetnumber}') - click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}') - click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}') - click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}') - - return [networkcli] - - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Network Status', - 'component': 'NetworkStatusWidget', - 'endpoint': '/api/network/dashboard/summary', - 'size': 'medium', - 'position': 7, - }, - ] - - def get_navigation_items(self) -> List[Dict]: - """Return navigation menu items.""" - return [ - { - 'name': 'Network', - 'icon': 'network-wired', - 'route': '/network', - 'position': 18, - }, - ] +"""Network plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, AssetType + +from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN +from .api import network_bp + +logger = logging.getLogger(__name__) + + +class NetworkPlugin(BasePlugin): + """ + Network plugin - manages network device assets. + + Network devices include switches, routers, access points, cameras, IDFs, etc. + Uses the new Asset architecture with NetworkDevice extension table. + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifestpath = Path(__file__).parent / 'manifest.json' + if manifestpath.exists(): + with open(manifestpath, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'network'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Network device management for switches, APs, and cameras' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/network'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return network_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [NetworkDevice, NetworkDeviceType, Subnet, VLAN] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Network plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_asset_type() + self._ensure_network_device_types() + logger.info("Network plugin installed") + + def _ensure_asset_type(self) -> None: + """Ensure network_device asset type exists.""" + existing = AssetType.query.filter_by(assettype='network_device').first() + if not existing: + at = AssetType( + assettype='network_device', + pluginname='network', + tablename='networkdevices', + description='Network infrastructure devices (switches, APs, cameras, etc.)', + icon='network-wired' + ) + db.session.add(at) + logger.debug("Created asset type: network_device") + db.session.commit() + + def _ensure_network_device_types(self) -> None: + """Ensure basic network device types exist.""" + device_types = [ + ('Switch', 'Network switch', 'network-wired'), + ('Router', 'Network router', 'router'), + ('Access Point', 'Wireless access point', 'wifi'), + ('Firewall', 'Network firewall', 'shield'), + ('Camera', 'IP camera', 'video'), + ('IDF', 'Intermediate Distribution Frame/closet', 'box'), + ('MDF', 'Main Distribution Frame', 'building'), + ('Patch Panel', 'Patch panel', 'th'), + ('UPS', 'Uninterruptible power supply', 'battery'), + ('Other', 'Other network device', 'network-wired'), + ] + + for name, description, icon in device_types: + existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() + if not existing: + ndt = NetworkDeviceType( + networkdevicetype=name, + description=description, + icon=icon + ) + db.session.add(ndt) + logger.debug(f"Created network device type: {name}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Network plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('network') + def networkcli(): + """Network plugin commands.""" + pass + + @networkcli.command('list-types') + def list_types(): + """List all network device types.""" + from flask import current_app + + with current_app.app_context(): + types = NetworkDeviceType.query.filter_by(isactive=True).all() + if not types: + click.echo('No network device types found.') + return + + click.echo('Network Device Types:') + for t in types: + click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}") + + @networkcli.command('stats') + def stats(): + """Show network device statistics.""" + from flask import current_app + from shopdb.api import Asset + + with current_app.app_context(): + total = db.session.query(NetworkDevice).join(Asset).filter( + Asset.isactive == True + ).count() + + click.echo(f"Total active network devices: {total}") + + # By type + by_type = db.session.query( + NetworkDeviceType.networkdevicetype, + db.func.count(NetworkDevice.networkdeviceid) + ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).join(Asset, Asset.assetid == NetworkDevice.assetid + ).filter(Asset.isactive == True + ).group_by(NetworkDeviceType.networkdevicetype + ).all() + + if by_type: + click.echo("\nBy Type:") + for t, c in by_type: + click.echo(f" {t}: {c}") + + @networkcli.command('find') + @click.argument('hostname') + def find_by_hostname(hostname): + """Find a network device by hostname.""" + from flask import current_app + + with current_app.app_context(): + netdev = NetworkDevice.query.filter( + NetworkDevice.hostname.ilike(f'%{hostname}%') + ).first() + + if not netdev: + click.echo(f'No network device found matching hostname: {hostname}') + return + + click.echo(f'Found: {netdev.hostname}') + click.echo(f' Asset: {netdev.asset.assetnumber}') + click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}') + click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}') + click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}') + + return [networkcli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Network Status', + 'component': 'NetworkStatusWidget', + 'endpoint': '/api/network/dashboard/summary', + 'size': 'medium', + 'position': 7, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Network', + 'icon': 'network-wired', + 'route': '/network', + 'position': 18, + }, + ] diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index 4b738b6..8d43013 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -4,15 +4,7 @@ from datetime import datetime from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query -from shopdb.utils.employee_db import employee_connection +from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection from ..models import Notification, NotificationType diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index bcccd38..214bdc5 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -1,157 +1,157 @@ -"""Notifications plugin models - adapted to existing database schema.""" - -from datetime import datetime -from shopdb.extensions import db - - -class NotificationType(db.Model): - """ - Notification type classification. - Matches existing notificationtypes table. - """ - __tablename__ = 'notificationtypes' - - notificationtypeid = db.Column(db.Integer, primary_key=True) - typename = db.Column(db.String(50), nullable=False) - typedescription = db.Column(db.Text) - typecolor = db.Column(db.String(20), default='#17a2b8') - isactive = db.Column(db.Boolean, default=True) - - def __repr__(self): - return f"" - - def to_dict(self): - return { - 'notificationtypeid': self.notificationtypeid, - 'typename': self.typename, - 'typedescription': self.typedescription, - 'typecolor': self.typecolor, - 'isactive': self.isactive - } - - -class Notification(db.Model): - """ - Notification/announcement model. - Matches existing notifications table schema. - """ - __tablename__ = 'notifications' - - notificationid = db.Column(db.Integer, primary_key=True) - notificationtypeid = db.Column( - db.Integer, - db.ForeignKey('notificationtypes.notificationtypeid'), - nullable=True - ) - businessunitid = db.Column(db.Integer, nullable=True) - appid = db.Column(db.Integer, nullable=True) - notification = db.Column(db.Text, nullable=False, comment='The message content') - starttime = db.Column(db.DateTime, nullable=True) - endtime = db.Column(db.DateTime, nullable=True) - ticketnumber = db.Column(db.String(50), nullable=True) - link = db.Column(db.String(500), nullable=True) - isactive = db.Column(db.Boolean, default=True) - isshopfloor = db.Column(db.Boolean, default=False) - employeesso = db.Column(db.String(100), nullable=True) - employeename = db.Column(db.String(100), nullable=True) - - # Relationships - notificationtype = db.relationship('NotificationType', backref='notifications') - - def __repr__(self): - return f"" - - @property - def is_current(self): - """Check if notification is currently active based on dates.""" - now = datetime.utcnow() - if not self.isactive: - return False - if self.starttime and now < self.starttime: - return False - if self.endtime and now > self.endtime: - return False - return True - - @property - def title(self): - """Get title - first line or first 100 chars of notification.""" - if not self.notification: - return '' - lines = self.notification.split('\n') - return lines[0][:100] if lines else self.notification[:100] - - def to_dict(self): - """Convert to dictionary with related data.""" - result = { - 'notificationid': self.notificationid, - 'notificationtypeid': self.notificationtypeid, - 'businessunitid': self.businessunitid, - 'appid': self.appid, - 'notification': self.notification, - 'title': self.title, - 'message': self.notification, - 'starttime': self.starttime.isoformat() if self.starttime else None, - 'endtime': self.endtime.isoformat() if self.endtime else None, - 'startdate': self.starttime.isoformat() if self.starttime else None, - 'enddate': self.endtime.isoformat() if self.endtime else None, - 'ticketnumber': self.ticketnumber, - 'link': self.link, - 'linkurl': self.link, - 'isactive': bool(self.isactive) if self.isactive is not None else True, - 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, - 'employeesso': self.employeesso, - 'employeename': self.employeename, - 'iscurrent': self.is_current - } - - # Add type info - if self.notificationtype: - result['typename'] = self.notificationtype.typename - result['typecolor'] = self.notificationtype.typecolor - - return result - - def to_calendar_event(self): - """Convert to FullCalendar event format.""" - # Map Bootstrap color names to hex colors - color_map = { - 'success': '#04b962', - 'warning': '#ff8800', - 'danger': '#f5365c', - 'info': '#14abef', - 'primary': '#7934f3', - 'secondary': '#94614f', - 'recognition': '#14abef', # Blue for recognition - } - - raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' - # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) - color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') - - # For recognition notifications, include employee name (or SSO as fallback) in title - title = self.title - if raw_color == 'recognition': - employee_display = self.employeename or self.employeesso - if employee_display: - title = f"{employee_display}: {title}" - - return { - 'id': self.notificationid, - 'title': title, - 'start': self.starttime.isoformat() if self.starttime else None, - 'end': self.endtime.isoformat() if self.endtime else None, - 'allDay': True, - 'backgroundColor': color, - 'borderColor': color, - 'extendedProps': { - 'notificationid': self.notificationid, - 'message': self.notification, - 'typename': self.notificationtype.typename if self.notificationtype else None, - 'typecolor': raw_color, - 'linkurl': self.link, - 'ticketnumber': self.ticketnumber, - 'employeename': self.employeename, - 'employeesso': self.employeesso, - } - } +"""Notifications plugin models - adapted to existing database schema.""" + +from datetime import datetime +from shopdb.api import db + + +class NotificationType(db.Model): + """ + Notification type classification. + Matches existing notificationtypes table. + """ + __tablename__ = 'notificationtypes' + + notificationtypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), nullable=False) + typedescription = db.Column(db.Text) + typecolor = db.Column(db.String(20), default='#17a2b8') + isactive = db.Column(db.Boolean, default=True) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'notificationtypeid': self.notificationtypeid, + 'typename': self.typename, + 'typedescription': self.typedescription, + 'typecolor': self.typecolor, + 'isactive': self.isactive + } + + +class Notification(db.Model): + """ + Notification/announcement model. + Matches existing notifications table schema. + """ + __tablename__ = 'notifications' + + notificationid = db.Column(db.Integer, primary_key=True) + notificationtypeid = db.Column( + db.Integer, + db.ForeignKey('notificationtypes.notificationtypeid'), + nullable=True + ) + businessunitid = db.Column(db.Integer, nullable=True) + appid = db.Column(db.Integer, nullable=True) + notification = db.Column(db.Text, nullable=False, comment='The message content') + starttime = db.Column(db.DateTime, nullable=True) + endtime = db.Column(db.DateTime, nullable=True) + ticketnumber = db.Column(db.String(50), nullable=True) + link = db.Column(db.String(500), nullable=True) + isactive = db.Column(db.Boolean, default=True) + isshopfloor = db.Column(db.Boolean, default=False) + employeesso = db.Column(db.String(100), nullable=True) + employeename = db.Column(db.String(100), nullable=True) + + # Relationships + notificationtype = db.relationship('NotificationType', backref='notifications') + + def __repr__(self): + return f"" + + @property + def is_current(self): + """Check if notification is currently active based on dates.""" + now = datetime.utcnow() + if not self.isactive: + return False + if self.starttime and now < self.starttime: + return False + if self.endtime and now > self.endtime: + return False + return True + + @property + def title(self): + """Get title - first line or first 100 chars of notification.""" + if not self.notification: + return '' + lines = self.notification.split('\n') + return lines[0][:100] if lines else self.notification[:100] + + def to_dict(self): + """Convert to dictionary with related data.""" + result = { + 'notificationid': self.notificationid, + 'notificationtypeid': self.notificationtypeid, + 'businessunitid': self.businessunitid, + 'appid': self.appid, + 'notification': self.notification, + 'title': self.title, + 'message': self.notification, + 'starttime': self.starttime.isoformat() if self.starttime else None, + 'endtime': self.endtime.isoformat() if self.endtime else None, + 'startdate': self.starttime.isoformat() if self.starttime else None, + 'enddate': self.endtime.isoformat() if self.endtime else None, + 'ticketnumber': self.ticketnumber, + 'link': self.link, + 'linkurl': self.link, + 'isactive': bool(self.isactive) if self.isactive is not None else True, + 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'iscurrent': self.is_current + } + + # Add type info + if self.notificationtype: + result['typename'] = self.notificationtype.typename + result['typecolor'] = self.notificationtype.typecolor + + return result + + def to_calendar_event(self): + """Convert to FullCalendar event format.""" + # Map Bootstrap color names to hex colors + color_map = { + 'success': '#04b962', + 'warning': '#ff8800', + 'danger': '#f5365c', + 'info': '#14abef', + 'primary': '#7934f3', + 'secondary': '#94614f', + 'recognition': '#14abef', # Blue for recognition + } + + raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' + # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) + color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') + + # For recognition notifications, include employee name (or SSO as fallback) in title + title = self.title + if raw_color == 'recognition': + employee_display = self.employeename or self.employeesso + if employee_display: + title = f"{employee_display}: {title}" + + return { + 'id': self.notificationid, + 'title': title, + 'start': self.starttime.isoformat() if self.starttime else None, + 'end': self.endtime.isoformat() if self.endtime else None, + 'allDay': True, + 'backgroundColor': color, + 'borderColor': color, + 'extendedProps': { + 'notificationid': self.notificationid, + 'message': self.notification, + 'typename': self.notificationtype.typename if self.notificationtype else None, + 'typecolor': raw_color, + 'linkurl': self.link, + 'ticketnumber': self.ticketnumber, + 'employeename': self.employeename, + 'employeesso': self.employeesso, + } + } diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py index 495a91a..cd7ef0a 100644 --- a/plugins/notifications/plugin.py +++ b/plugins/notifications/plugin.py @@ -1,204 +1,204 @@ -"""Notifications plugin main class.""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint -import click - -from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db - -from .models import Notification, NotificationType -from .api import notifications_bp - -logger = logging.getLogger(__name__) - - -class NotificationsPlugin(BasePlugin): - """ - Notifications plugin - manages announcements and notifications. - - Provides functionality for: - - Creating and managing notifications/announcements - - Displaying banner notifications - - Calendar view of notifications - """ - - def __init__(self): - self._manifest = self._load_manifest() - - def _load_manifest(self) -> Dict: - """Load plugin manifest from JSON file.""" - manifest_path = Path(__file__).parent / 'manifest.json' - if manifest_path.exists(): - with open(manifest_path, 'r') as f: - return json.load(f) - return {} - - @property - def meta(self) -> PluginMeta: - """Return plugin metadata.""" - return PluginMeta( - name=self._manifest.get('name', 'notifications'), - version=self._manifest.get('version', '1.0.0'), - description=self._manifest.get( - 'description', - 'Notifications and announcements management' - ), - author=self._manifest.get('author', 'ShopDB Team'), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=1.0.0'), - api_prefix=self._manifest.get('api_prefix', '/api/notifications'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - """Return Flask Blueprint with API routes.""" - return notifications_bp - - def get_models(self) -> List[Type]: - """Return list of SQLAlchemy model classes.""" - return [Notification, NotificationType] - - def init_app(self, app: Flask, db_instance) -> None: - """Initialize plugin with Flask app.""" - logger.info(f"Notifications plugin initialized (v{self.meta.version})") - - def on_install(self, app: Flask) -> None: - """Called when plugin is installed.""" - with app.app_context(): - self._ensure_notification_types() - logger.info("Notifications plugin installed") - - def _ensure_notification_types(self) -> None: - """Ensure default notification types exist.""" - default_types = [ - ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), - ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), - ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), - ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), - ('General', 'General announcement', '#28a745', 'bullhorn'), - ] - - for typename, description, color, icon in default_types: - existing = NotificationType.query.filter_by(typename=typename).first() - if not existing: - t = NotificationType( - typename=typename, - description=description, - color=color, - icon=icon - ) - db.session.add(t) - logger.debug(f"Created notification type: {typename}") - - db.session.commit() - - def on_uninstall(self, app: Flask) -> None: - """Called when plugin is uninstalled.""" - logger.info("Notifications plugin uninstalled") - - def get_cli_commands(self) -> List: - """Return CLI commands for this plugin.""" - - @click.group('notifications') - def notifications_cli(): - """Notifications plugin commands.""" - pass - - @notifications_cli.command('list-types') - def list_types(): - """List all notification types.""" - from flask import current_app - - with current_app.app_context(): - types = NotificationType.query.filter_by(isactive=True).all() - if not types: - click.echo('No notification types found.') - return - - click.echo('Notification Types:') - for t in types: - click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") - - @notifications_cli.command('stats') - def stats(): - """Show notification statistics.""" - from flask import current_app - from datetime import datetime - - with current_app.app_context(): - now = datetime.utcnow() - - total = Notification.query.filter( - Notification.isactive == True - ).count() - - active = Notification.query.filter( - Notification.isactive == True, - Notification.startdate <= now, - db.or_( - Notification.enddate.is_(None), - Notification.enddate >= now - ) - ).count() - - click.echo(f"Total notifications: {total}") - click.echo(f"Currently active: {active}") - - @notifications_cli.command('create') - @click.option('--title', required=True, help='Notification title') - @click.option('--message', required=True, help='Notification message') - @click.option('--type', 'type_name', default='General', help='Notification type') - def create_notification(title, message, type_name): - """Create a new notification.""" - from flask import current_app - - with current_app.app_context(): - ntype = NotificationType.query.filter_by(typename=type_name).first() - if not ntype: - click.echo(f"Error: Notification type '{type_name}' not found.") - return - - n = Notification( - title=title, - message=message, - notificationtypeid=ntype.notificationtypeid - ) - db.session.add(n) - db.session.commit() - - click.echo(f"Created notification #{n.notificationid}: {title}") - - return [notifications_cli] - - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Active Notifications', - 'component': 'NotificationsWidget', - 'endpoint': '/api/notifications/dashboard/summary', - 'size': 'small', - 'position': 1, - }, - ] - - def get_navigation_items(self) -> List[Dict]: - """Return navigation menu items.""" - return [ - { - 'name': 'Notifications', - 'icon': 'bell', - 'route': '/notifications', - 'position': 5, - }, - { - 'name': 'Calendar', - 'icon': 'calendar', - 'route': '/calendar', - 'position': 6, - }, - ] +"""Notifications plugin main class.""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint +import click + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db + +from .models import Notification, NotificationType +from .api import notifications_bp + +logger = logging.getLogger(__name__) + + +class NotificationsPlugin(BasePlugin): + """ + Notifications plugin - manages announcements and notifications. + + Provides functionality for: + - Creating and managing notifications/announcements + - Displaying banner notifications + - Calendar view of notifications + """ + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + """Load plugin manifest from JSON file.""" + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + """Return plugin metadata.""" + return PluginMeta( + name=self._manifest.get('name', 'notifications'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get( + 'description', + 'Notifications and announcements management' + ), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/notifications'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + """Return Flask Blueprint with API routes.""" + return notifications_bp + + def get_models(self) -> List[Type]: + """Return list of SQLAlchemy model classes.""" + return [Notification, NotificationType] + + def init_app(self, app: Flask, db_instance) -> None: + """Initialize plugin with Flask app.""" + logger.info(f"Notifications plugin initialized (v{self.meta.version})") + + def on_install(self, app: Flask) -> None: + """Called when plugin is installed.""" + with app.app_context(): + self._ensure_notification_types() + logger.info("Notifications plugin installed") + + def _ensure_notification_types(self) -> None: + """Ensure default notification types exist.""" + default_types = [ + ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), + ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), + ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), + ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), + ('General', 'General announcement', '#28a745', 'bullhorn'), + ] + + for typename, description, color, icon in default_types: + existing = NotificationType.query.filter_by(typename=typename).first() + if not existing: + t = NotificationType( + typename=typename, + description=description, + color=color, + icon=icon + ) + db.session.add(t) + logger.debug(f"Created notification type: {typename}") + + db.session.commit() + + def on_uninstall(self, app: Flask) -> None: + """Called when plugin is uninstalled.""" + logger.info("Notifications plugin uninstalled") + + def get_cli_commands(self) -> List: + """Return CLI commands for this plugin.""" + + @click.group('notifications') + def notifications_cli(): + """Notifications plugin commands.""" + pass + + @notifications_cli.command('list-types') + def list_types(): + """List all notification types.""" + from flask import current_app + + with current_app.app_context(): + types = NotificationType.query.filter_by(isactive=True).all() + if not types: + click.echo('No notification types found.') + return + + click.echo('Notification Types:') + for t in types: + click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") + + @notifications_cli.command('stats') + def stats(): + """Show notification statistics.""" + from flask import current_app + from datetime import datetime + + with current_app.app_context(): + now = datetime.utcnow() + + total = Notification.query.filter( + Notification.isactive == True + ).count() + + active = Notification.query.filter( + Notification.isactive == True, + Notification.startdate <= now, + db.or_( + Notification.enddate.is_(None), + Notification.enddate >= now + ) + ).count() + + click.echo(f"Total notifications: {total}") + click.echo(f"Currently active: {active}") + + @notifications_cli.command('create') + @click.option('--title', required=True, help='Notification title') + @click.option('--message', required=True, help='Notification message') + @click.option('--type', 'type_name', default='General', help='Notification type') + def create_notification(title, message, type_name): + """Create a new notification.""" + from flask import current_app + + with current_app.app_context(): + ntype = NotificationType.query.filter_by(typename=type_name).first() + if not ntype: + click.echo(f"Error: Notification type '{type_name}' not found.") + return + + n = Notification( + title=title, + message=message, + notificationtypeid=ntype.notificationtypeid + ) + db.session.add(n) + db.session.commit() + + click.echo(f"Created notification #{n.notificationid}: {title}") + + return [notifications_cli] + + def get_dashboard_widgets(self) -> List[Dict]: + """Return dashboard widget definitions.""" + return [ + { + 'name': 'Active Notifications', + 'component': 'NotificationsWidget', + 'endpoint': '/api/notifications/dashboard/summary', + 'size': 'small', + 'position': 1, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Return navigation menu items.""" + return [ + { + 'name': 'Notifications', + 'icon': 'bell', + 'route': '/notifications', + 'position': 5, + }, + { + 'name': 'Calendar', + 'icon': 'calendar', + 'route': '/calendar', + 'position': 6, + }, + ] diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index ef3b2fe..d5f7c91 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -5,15 +5,7 @@ import logging from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.extensions import db, cache -from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Printer, PrinterType, ModelSupply from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS @@ -565,7 +557,7 @@ def _get_low_supplies_data(): # location name for the report row location_name = None if asset.locationid: - from shopdb.core.models import Location + from shopdb.api import Location loc = Location.query.get(asset.locationid) if loc: location_name = loc.locationname diff --git a/plugins/printers/models/model_supply.py b/plugins/printers/models/model_supply.py index 398cc93..05d7575 100644 --- a/plugins/printers/models/model_supply.py +++ b/plugins/printers/models/model_supply.py @@ -6,8 +6,7 @@ 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 +from shopdb.api import db, BaseModel # allowed values, surfaced to the UI via the /supplies/meta endpoint diff --git a/plugins/printers/models/printer.py b/plugins/printers/models/printer.py index 8698e9c..beab8d8 100644 --- a/plugins/printers/models/printer.py +++ b/plugins/printers/models/printer.py @@ -1,122 +1,121 @@ -"""Printer plugin models - new Asset-based architecture.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class PrinterType(BaseModel): - """ - Printer type classification. - - Examples: Laser, Inkjet, Label, MFP, Plotter, etc. - """ - __tablename__ = 'printertypes' - - printertypeid = db.Column(db.Integer, primary_key=True) - printertype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - icon = db.Column(db.String(50), comment='Icon name for UI') - - def __repr__(self): - return f"" - - -class Printer(BaseModel): - """ - Printer-specific extension data (new Asset architecture). - - Links to core Asset table via assetid. - Stores printer-specific fields like type, Windows name, share name, etc. - """ - __tablename__ = 'printers' - - printerid = db.Column(db.Integer, primary_key=True) - - # Link to core asset - assetid = db.Column( - db.Integer, - db.ForeignKey('assets.assetid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Printer classification - printertypeid = db.Column( - db.Integer, - db.ForeignKey('printertypes.printertypeid'), - nullable=True - ) - - # Vendor - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - modelnumberid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True - ) - - # Network identity - hostname = db.Column( - db.String(100), - index=True, - comment='Network hostname' - ) - - # Windows/Network naming - windowsname = db.Column( - db.String(255), - comment='Windows printer name (e.g., \\\\server\\printer)' - ) - sharename = db.Column( - db.String(100), - comment='CSF/share name' - ) - - # Installation - iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') - installpath = db.Column(db.String(255), comment='Driver install path') - - # Printer PIN (for secure print) - pin = db.Column(db.String(20)) - - # Features - iscolor = db.Column(db.Boolean, default=False, comment='Color capable') - isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') - isnetwork = db.Column(db.Boolean, default=True, comment='Network connected') - - # Relationships - asset = db.relationship( - 'Asset', - backref=db.backref('printer', uselist=False, lazy='joined') - ) - printertype = db.relationship('PrinterType', backref='printers') - vendor = db.relationship('Vendor', backref='printer_items') - model = db.relationship('Model', backref='printer_items') - - __table_args__ = ( - db.Index('idx_printer_type', 'printertypeid'), - db.Index('idx_printer_hostname', 'hostname'), - db.Index('idx_printer_windowsname', 'windowsname'), - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary with related names.""" - result = super().to_dict() - - # Add related object names - if self.printertype: - result['printertypename'] = self.printertype.printertype - if self.vendor: - result['vendorname'] = self.vendor.vendor - if self.model: - result['modelname'] = self.model.modelnumber - - return result +"""Printer plugin models - new Asset-based architecture.""" + +from shopdb.api import db, BaseModel + + +class PrinterType(BaseModel): + """ + Printer type classification. + + Examples: Laser, Inkjet, Label, MFP, Plotter, etc. + """ + __tablename__ = 'printertypes' + + printertypeid = db.Column(db.Integer, primary_key=True) + printertype = db.Column(db.String(100), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class Printer(BaseModel): + """ + Printer-specific extension data (new Asset architecture). + + Links to core Asset table via assetid. + Stores printer-specific fields like type, Windows name, share name, etc. + """ + __tablename__ = 'printers' + + printerid = db.Column(db.Integer, primary_key=True) + + # Link to core asset + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True + ) + + # Printer classification + printertypeid = db.Column( + db.Integer, + db.ForeignKey('printertypes.printertypeid'), + nullable=True + ) + + # Vendor + vendorid = db.Column( + db.Integer, + db.ForeignKey('vendors.vendorid'), + nullable=True + ) + modelnumberid = db.Column( + db.Integer, + db.ForeignKey('models.modelnumberid'), + nullable=True + ) + + # Network identity + hostname = db.Column( + db.String(100), + index=True, + comment='Network hostname' + ) + + # Windows/Network naming + windowsname = db.Column( + db.String(255), + comment='Windows printer name (e.g., \\\\server\\printer)' + ) + sharename = db.Column( + db.String(100), + comment='CSF/share name' + ) + + # Installation + iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') + installpath = db.Column(db.String(255), comment='Driver install path') + + # Printer PIN (for secure print) + pin = db.Column(db.String(20)) + + # Features + iscolor = db.Column(db.Boolean, default=False, comment='Color capable') + isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') + isnetwork = db.Column(db.Boolean, default=True, comment='Network connected') + + # Relationships + asset = db.relationship( + 'Asset', + backref=db.backref('printer', uselist=False, lazy='joined') + ) + printertype = db.relationship('PrinterType', backref='printers') + vendor = db.relationship('Vendor', backref='printer_items') + model = db.relationship('Model', backref='printer_items') + + __table_args__ = ( + db.Index('idx_printer_type', 'printertypeid'), + db.Index('idx_printer_hostname', 'hostname'), + db.Index('idx_printer_windowsname', 'windowsname'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary with related names.""" + result = super().to_dict() + + # Add related object names + if self.printertype: + result['printertypename'] = self.printertype.printertype + if self.vendor: + result['vendorname'] = self.vendor.vendor + if self.model: + result['modelname'] = self.model.modelnumber + + return result diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index ac59f96..d647497 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -9,9 +9,7 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db -from shopdb.core.models.machine import MachineType -from shopdb.core.models import AssetType +from shopdb.api import db, AssetType from .models import Printer, PrinterType, ModelSupply from .api import printers_asset_bp @@ -104,7 +102,6 @@ class PrintersPlugin(BasePlugin): with app.app_context(): self._ensure_asset_type() self._ensure_printer_types() - self._ensure_legacy_machine_types() logger.info("Printers plugin installed") def _ensure_asset_type(self) -> None: @@ -149,30 +146,6 @@ class PrintersPlugin(BasePlugin): db.session.commit() - def _ensure_legacy_machine_types(self) -> None: - """Ensure basic printer machine types exist (legacy architecture).""" - printertypes = [ - ('Laser Printer', 'Printer', 'Standard laser printer'), - ('Inkjet Printer', 'Printer', 'Inkjet printer'), - ('Label Printer', 'Printer', 'Label/barcode printer'), - ('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'), - ('Plotter', 'Printer', 'Large format plotter'), - ] - - for name, category, description in printertypes: - existing = MachineType.query.filter_by(machinetype=name).first() - if not existing: - mt = MachineType( - machinetype=name, - category=category, - description=description, - icon='printer' - ) - db.session.add(mt) - logger.debug(f"Created machine type: {name}") - - db.session.commit() - def on_uninstall(self, app: Flask) -> None: """Called when plugin is uninstalled.""" logger.info("Printers plugin uninstalled") diff --git a/plugins/printers/services/seed_supplies.py b/plugins/printers/services/seed_supplies.py index 9bfcb67..82e69b7 100644 --- a/plugins/printers/services/seed_supplies.py +++ b/plugins/printers/services/seed_supplies.py @@ -19,9 +19,7 @@ Key facts encoded here: import logging -from shopdb.extensions import db -from shopdb.core.models import Vendor, Model -from shopdb.core.models.machine import MachineType +from shopdb.api import db, Vendor, Model from ..models import ModelSupply @@ -311,9 +309,6 @@ def seedsupplies(): 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 @@ -322,10 +317,11 @@ def seedsupplies(): targets = _matching_models(family['matchkeys'], vendor.vendorid) if not targets: + # machinetypeid is a legacy Model column (nullable); printers are + # asset-based now and carry their type via PrinterType, not here. model = Model( modelnumber=family['canonical'], vendorid=vendor.vendorid, - machinetypeid=printertypeid, ) db.session.add(model) db.session.flush() diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py index d2465bb..ddd8e60 100644 --- a/plugins/printers/services/zabbix_service.py +++ b/plugins/printers/services/zabbix_service.py @@ -24,7 +24,7 @@ from typing import Dict, List, Optional import requests from flask import current_app -from shopdb.extensions import cache +from shopdb.api import cache logger = logging.getLogger(__name__) @@ -55,7 +55,7 @@ class ZabbixService: @property def isenabled(self) -> bool: """Whether the integration is switched on.""" - from shopdb.core.models import Setting + from shopdb.api import Setting db_enabled = Setting.get('zabbix_enabled') if db_enabled is not None: return bool(db_enabled) @@ -66,7 +66,7 @@ class ZabbixService: """Enabled, and a URL plus token are present.""" if not self.isenabled: return False - from shopdb.core.models import Setting + from shopdb.api import Setting self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL') self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN') return bool(self._url and self._token) diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index a990ca5..d17a664 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -4,15 +4,7 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required, get_jwt_identity from datetime import datetime -from shopdb.extensions import db -from shopdb.core.models import AuditLog -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query +from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import USBDevice, USBDeviceType, USBCheckout diff --git a/plugins/usb/models/usb_device.py b/plugins/usb/models/usb_device.py index ab43681..f37490b 100644 --- a/plugins/usb/models/usb_device.py +++ b/plugins/usb/models/usb_device.py @@ -1,167 +1,166 @@ -"""USB device plugin models.""" - -from datetime import datetime -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel, AuditMixin - - -class USBDeviceType(BaseModel): - """ - USB device type classification. - - Examples: Flash Drive, External HDD, External SSD, Card Reader - """ - __tablename__ = 'usbdevicetypes' - - usbdevicetypeid = db.Column(db.Integer, primary_key=True) - typename = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.Text) - icon = db.Column(db.String(50), default='usb', comment='Icon name for UI') - - def __repr__(self): - return f"" - - -class USBDevice(BaseModel, AuditMixin): - """ - USB device model. - - Tracks USB storage devices that can be checked out by users. - """ - __tablename__ = 'usbdevices' - - usbdeviceid = db.Column(db.Integer, primary_key=True) - - # Identification - serialnumber = db.Column(db.String(100), unique=True, nullable=False) - label = db.Column(db.String(100), nullable=True, comment='Human-readable label') - assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag') - - # Classification - usbdevicetypeid = db.Column( - db.Integer, - db.ForeignKey('usbdevicetypes.usbdevicetypeid'), - nullable=True - ) - - # Specifications - capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') - vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') - productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') - manufacturer = db.Column(db.String(100), nullable=True) - productname = db.Column(db.String(100), nullable=True) - - # Current status - ischeckedout = db.Column(db.Boolean, default=False) - currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') - currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') - currentcheckoutdate = db.Column(db.DateTime, nullable=True) - - # Location - storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') - - # Security - pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices') - - # Notes - notes = db.Column(db.Text, nullable=True) - - # Relationships - devicetype = db.relationship('USBDeviceType', backref='devices') - - # Indexes - __table_args__ = ( - db.Index('idx_usb_serial', 'serialnumber'), - db.Index('idx_usb_checkedout', 'ischeckedout'), - db.Index('idx_usb_type', 'usbdevicetypeid'), - db.Index('idx_usb_currentuser', 'currentuserid'), - ) - - def __repr__(self): - return f"" - - @property - def display_name(self): - """Get display name (label if set, otherwise serial number).""" - return self.label or self.serialnumber - - def to_dict(self): - """Convert to dictionary with related data.""" - result = super().to_dict() - - # Add type info - if self.devicetype: - result['typename'] = self.devicetype.typename - result['typeicon'] = self.devicetype.icon - - # Add computed property - result['displayname'] = self.display_name - - return result - - -class USBCheckout(BaseModel): - """ - USB device checkout history. - - Tracks when devices are checked out and returned. - Maps to existing usbcheckouts table from classic ShopDB. - """ - __tablename__ = 'usbcheckouts' - - checkoutid = db.Column(db.Integer, primary_key=True) - - # Device reference (new column linking to usbdevices table) - usbdeviceid = db.Column( - db.Integer, - db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), - nullable=True - ) - - # Legacy reference to machines table (kept for backward compatibility) - machineid = db.Column(db.Integer, nullable=False) - - # User info - sso = db.Column(db.String(20), nullable=False, comment='SSO of user') - checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user') - - # Checkout details - checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) - checkintime = db.Column(db.DateTime, nullable=True) - - # Metadata - checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') - checkinnotes = db.Column(db.Text, nullable=True) - waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return') - - # Relationships - device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) - - def __repr__(self): - return f"" - - @property - def is_active(self): - """Check if this checkout is currently active (not returned).""" - return self.checkintime is None - - @property - def duration_days(self): - """Get duration of checkout in days.""" - end = self.checkintime or datetime.utcnow() - delta = end - self.checkouttime - return delta.days - - def to_dict(self): - """Convert to dictionary with computed fields.""" - result = super().to_dict() - - result['isactivecheckout'] = self.is_active - result['durationdays'] = self.duration_days - - # Add device info if loaded - if self.device: - result['devicelabel'] = self.device.label - result['deviceserialnumber'] = self.device.serialnumber - - return result +"""USB device plugin models.""" + +from datetime import datetime +from shopdb.api import db, BaseModel, AuditMixin + + +class USBDeviceType(BaseModel): + """ + USB device type classification. + + Examples: Flash Drive, External HDD, External SSD, Card Reader + """ + __tablename__ = 'usbdevicetypes' + + usbdevicetypeid = db.Column(db.Integer, primary_key=True) + typename = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.Text) + icon = db.Column(db.String(50), default='usb', comment='Icon name for UI') + + def __repr__(self): + return f"" + + +class USBDevice(BaseModel, AuditMixin): + """ + USB device model. + + Tracks USB storage devices that can be checked out by users. + """ + __tablename__ = 'usbdevices' + + usbdeviceid = db.Column(db.Integer, primary_key=True) + + # Identification + serialnumber = db.Column(db.String(100), unique=True, nullable=False) + label = db.Column(db.String(100), nullable=True, comment='Human-readable label') + assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag') + + # Classification + usbdevicetypeid = db.Column( + db.Integer, + db.ForeignKey('usbdevicetypes.usbdevicetypeid'), + nullable=True + ) + + # Specifications + capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') + vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') + productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') + manufacturer = db.Column(db.String(100), nullable=True) + productname = db.Column(db.String(100), nullable=True) + + # Current status + ischeckedout = db.Column(db.Boolean, default=False) + currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') + currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') + currentcheckoutdate = db.Column(db.DateTime, nullable=True) + + # Location + storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out') + + # Security + pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices') + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Relationships + devicetype = db.relationship('USBDeviceType', backref='devices') + + # Indexes + __table_args__ = ( + db.Index('idx_usb_serial', 'serialnumber'), + db.Index('idx_usb_checkedout', 'ischeckedout'), + db.Index('idx_usb_type', 'usbdevicetypeid'), + db.Index('idx_usb_currentuser', 'currentuserid'), + ) + + def __repr__(self): + return f"" + + @property + def display_name(self): + """Get display name (label if set, otherwise serial number).""" + return self.label or self.serialnumber + + def to_dict(self): + """Convert to dictionary with related data.""" + result = super().to_dict() + + # Add type info + if self.devicetype: + result['typename'] = self.devicetype.typename + result['typeicon'] = self.devicetype.icon + + # Add computed property + result['displayname'] = self.display_name + + return result + + +class USBCheckout(BaseModel): + """ + USB device checkout history. + + Tracks when devices are checked out and returned. + Maps to existing usbcheckouts table from classic ShopDB. + """ + __tablename__ = 'usbcheckouts' + + checkoutid = db.Column(db.Integer, primary_key=True) + + # Device reference (new column linking to usbdevices table) + usbdeviceid = db.Column( + db.Integer, + db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), + nullable=True + ) + + # Legacy reference to machines table (kept for backward compatibility) + machineid = db.Column(db.Integer, nullable=False) + + # User info + sso = db.Column(db.String(20), nullable=False, comment='SSO of user') + checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user') + + # Checkout details + checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + checkintime = db.Column(db.DateTime, nullable=True) + + # Metadata + checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') + checkinnotes = db.Column(db.Text, nullable=True) + waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return') + + # Relationships + device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic')) + + def __repr__(self): + return f"" + + @property + def is_active(self): + """Check if this checkout is currently active (not returned).""" + return self.checkintime is None + + @property + def duration_days(self): + """Get duration of checkout in days.""" + end = self.checkintime or datetime.utcnow() + delta = end - self.checkouttime + return delta.days + + def to_dict(self): + """Convert to dictionary with computed fields.""" + result = super().to_dict() + + result['isactivecheckout'] = self.is_active + result['durationdays'] = self.duration_days + + # Add device info if loaded + if self.device: + result['devicelabel'] = self.device.label + result['deviceserialnumber'] = self.device.serialnumber + + return result diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py index 0952a71..a1ed234 100644 --- a/plugins/usb/plugin.py +++ b/plugins/usb/plugin.py @@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.extensions import db +from shopdb.api import db from .models import USBDevice, USBDeviceType, USBCheckout from .api import usb_bp diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 7eab542..cfd7ee8 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -12,7 +12,10 @@ from .plugins import plugin_manager # ADR-002 for the bump rules. Plugins declare a compatible range in # their manifest.json `core_version` field. Pre-1.0 (0.x) means the # contract is still settling; sister sites should pin tight ranges. -__contract_version__ = '0.2.0' +# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache, +# model bases, core models, response + pagination helpers, employee_connection) +# so plugins no longer import internal core paths. Additive, hence minor bump. +__contract_version__ = '0.3.0' def create_app(config_name: str = None) -> Flask: diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 6439f09..0a03eb9 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -14,7 +14,47 @@ Setting helpers are exposed via BasePlugin instance methods from typing import Any, Dict, Optional -from shopdb.core.models import AuditLog +# -- Plugin contract surface (ADR-001, versioned per ADR-002) ---------------- +# Everything a plugin is allowed to import from the core lives here. Plugins +# import these from `shopdb.api`, never from internal paths like +# `shopdb.core.models.*` or `shopdb.extensions`. The contract test +# (tests/test_plugin_contract.py) enforces this. Adding a name here is an +# additive (minor) contract change; removing one is breaking (major). + +# Infrastructure +from shopdb.extensions import db, cache + +# Model base classes for declaring plugin tables +from shopdb.core.models.base import BaseModel, AuditMixin + +# Core domain models plugins legitimately reference (the asset contract) +from shopdb.core.models import ( + Asset, + AssetType, + AssetStatus, + Vendor, + Model, + Communication, + CommunicationType, + Location, + Setting, + AuditLog, + Application, + AppVersion, + OperatingSystem, +) + +# Response + pagination helpers for plugin API blueprints +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes, +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +# Legacy employee directory lookup (read-only) used by notifications +from shopdb.utils.employee_db import employee_connection def audit_log( @@ -155,4 +195,37 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]: return None -__all__ = ['audit_log', 'resolve_asset_position'] +__all__ = [ + # Helpers + 'audit_log', + 'resolve_asset_position', + # Infrastructure + 'db', + 'cache', + # Model bases + 'BaseModel', + 'AuditMixin', + # Core models + 'Asset', + 'AssetType', + 'AssetStatus', + 'Vendor', + 'Model', + 'Communication', + 'CommunicationType', + 'Location', + 'Setting', + 'AuditLog', + 'Application', + 'AppVersion', + 'OperatingSystem', + # Response + pagination helpers + 'success_response', + 'error_response', + 'paginated_response', + 'ErrorCodes', + 'get_pagination_params', + 'paginate_query', + # Legacy employee directory + 'employee_connection', +] diff --git a/shopdb/plugins/templates/api/routes.py.tmpl b/shopdb/plugins/templates/api/routes.py.tmpl index bfe0d28..7460022 100644 --- a/shopdb/plugins/templates/api/routes.py.tmpl +++ b/shopdb/plugins/templates/api/routes.py.tmpl @@ -3,13 +3,14 @@ from flask import Blueprint, request from flask_jwt_extended import jwt_required -from shopdb.utils.responses import ( +from shopdb.api import ( success_response, error_response, paginated_response, ErrorCodes, + get_pagination_params, + paginate_query, ) -from shopdb.utils.pagination import get_pagination_params, paginate_query from ..models import $Name diff --git a/shopdb/plugins/templates/models/model.py.tmpl b/shopdb/plugins/templates/models/model.py.tmpl index 5d3d42f..5669baf 100644 --- a/shopdb/plugins/templates/models/model.py.tmpl +++ b/shopdb/plugins/templates/models/model.py.tmpl @@ -6,8 +6,7 @@ this table holds the $name-specific fields. Replace the example fields below with your domain model. """ -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel +from shopdb.api import db, BaseModel class $Name(BaseModel): diff --git a/shopdb/plugins/templates/plugin.py.tmpl b/shopdb/plugins/templates/plugin.py.tmpl index ceb474d..2daf77c 100644 --- a/shopdb/plugins/templates/plugin.py.tmpl +++ b/shopdb/plugins/templates/plugin.py.tmpl @@ -11,8 +11,7 @@ from typing import List, Dict, Optional, Type from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.core.models import AssetType -from shopdb.extensions import db +from shopdb.api import db, AssetType from .models import $Name from .api import ${name}_bp diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index a4aa91c..9d9a031 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -6,6 +6,7 @@ plugin's plugin.py / manifest.json. """ import json +import re from pathlib import Path import pytest @@ -143,3 +144,38 @@ def test_baseplugin_does_not_have_event_handlers_hook(): def test_baseplugin_has_collector_schema_hook(): """The collector schema hook is on the contract surface.""" assert hasattr(BasePlugin, 'get_collector_schema') + + +# Imports a plugin may make from the core. shopdb.api is the contract surface; +# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*, +# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001. +ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base') + +_PLUGIN_IMPORT_RE = re.compile( + r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE +) + + +def _plugin_source_files(): + root = Path(__file__).resolve().parent.parent / 'plugins' + return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts] + + +def test_plugins_only_import_contract_surface(): + """Plugins must import core code only via shopdb.api / shopdb.plugins.base.""" + violations = [] + for path in _plugin_source_files(): + text = path.read_text() + for match in _PLUGIN_IMPORT_RE.finditer(text): + module = match.group(1) or match.group(2) + if not module.startswith('shopdb'): + continue + if any(module == a or module.startswith(a + '.') + for a in ALLOWED_CORE_IMPORTS): + continue + line = text[:match.start()].count('\n') + 1 + violations.append(f'{path.name}:{line} imports {module}') + assert not violations, ( + 'Plugins must import core only via shopdb.api or shopdb.plugins.base. ' + 'Violations:\n' + '\n'.join(violations) + ) From 5fa5160420142c91b91c3ad54564264a0539a6da Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 19:25:52 -0400 Subject: [PATCH 27/32] Apply skill-driven review fixes: security, hook isolation, tests, docs Addresses findings from a 6-lens review against the project skills (defining-asset-contract, enforcing-plugin-contract, hardening-flask-config, integrating-plugin-hooks, pinning-flask-behavior, simplifying-python). Security (hardening-flask-config): - Load per-plugin COLLECTOR_API_KEY_ from env in create_app. from_object only copies class attributes, so per-plugin keys (ADR-006) were dead in real deploys and silently fell back to the shared key. - EMPLOYEE_DB_USER/PASSWORD no longer default to root/rootpassword (no safe default for a secret; unset fails loud). Documented in .env.example + DEPLOY.md. - COLLECTOR_API_KEY + per-plugin + EMPLOYEE_DB_* added to .env.example/DEPLOY.md. Hook isolation (integrating-plugin-hooks): - collector _collector_plugins and dashboard get_navigation now re-raise in dev/test and log+isolate in prod, instead of silently swallowing a broken plugin hook. Plugin loader (enforcing-plugin-contract): - enable_plugin/install_plugin read dependencies+version from the manifest instead of instantiating the plugin class. - _register_plugin_components rejects a second plugin claiming an already-used api_prefix (reset per app in init_app). Tests (pinning-flask-behavior): - test_identifiers.py: gauge/maintenance round-trip on computer/printer/network create+update; per-type seed yields the 12 identifier keys. - contract tests for apply_collector_payload presence + schema-declarers-implement. - security tests for per-plugin key env loading + no employee-db password default. Docs/contract sync (defining-asset-contract): - PLUGIN-HOOKS.md documents apply_collector_payload; stale 0.2.0 -> 0.3.0. - ADR-006 documents apply_collector_payload + single-dispatch rationale. - ADR-001 enumerates the expanded shopdb.api import surface. Simplify (simplifying-python): - De-duplicate the 21-entry settings defaults: shared build_default_settings() used by both the /settings/seed route and the CLI (were drifting copies). - Remove dead AssetStatus import + redundant AssetType local import in computers plugin; comment the statusid=1 collector default. 153 tests pass (was 145), naming/style green. Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 + docs/DEPLOY.md | 3 + docs/PLUGIN-HOOKS.md | 27 ++- .../adr/ADR-001-asset-as-platform-contract.md | 21 ++- docs/adr/ADR-006-collector-contract.md | 23 ++- plugins/computers/plugin.py | 6 +- shopdb/__init__.py | 8 + shopdb/cli/__init__.py | 160 +----------------- shopdb/config.py | 6 +- shopdb/core/api/collector.py | 8 +- shopdb/core/api/dashboard.py | 10 +- shopdb/core/api/settings.py | 19 ++- shopdb/plugins/__init__.py | 57 ++++--- tests/test_core/test_identifiers.py | 77 +++++++++ tests/test_plugin_contract.py | 17 ++ tests/test_security_config.py | 20 +++ 16 files changed, 273 insertions(+), 198 deletions(-) create mode 100644 tests/test_core/test_identifiers.py diff --git a/.env.example b/.env.example index ce2de8f..0fa2371 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,12 @@ ZABBIX_TOKEN= # COLLECTOR_API_KEY_ first, then COLLECTOR_API_KEY as fallback. # COLLECTOR_API_KEY= # COLLECTOR_API_KEY_COMPUTERS= + +# ---- Employee directory database (optional, read-only) ---- +# Separate HR/employee lookup DB consumed by the notifications plugin and the +# public shopfloor kiosks. Leave unset if the feature is not used; there is no +# safe default for the password, so an unset password fails loud. +# EMPLOYEE_DB_HOST= +# EMPLOYEE_DB_USER= +# EMPLOYEE_DB_PASSWORD= +# EMPLOYEE_DB_NAME=wjf_employees diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7e9e74e..e1f5190 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -32,6 +32,9 @@ Edit `.env`: | `API_PORT` | No | Default 5001 | | `LOG_LEVEL` | No | Default INFO | | `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix | +| `COLLECTOR_API_KEY` | No | Shared key for `/api/collector/*` ingest. Required only if unattended collectors push data. Endpoint fails closed (denies) when unset. | +| `COLLECTOR_API_KEY_` | No | Per-plugin override (e.g. `COLLECTOR_API_KEY_COMPUTERS`), checked before the shared key (ADR-006) | +| `EMPLOYEE_DB_HOST/USER/PASSWORD/NAME` | No | Read-only HR directory for notifications + kiosks. No safe default for the password. | ## Step 2: Bring up the stack diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index e38490a..422789a 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.2.0' +__contract_version__ = '0.3.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -222,6 +222,31 @@ class ComputersPlugin(BasePlugin): If the hook returns `None` (the default), no collector endpoint is registered. +### `apply_collector_payload(payload: Dict) -> Dict` + +Companion to `get_collector_schema` (ADR-006). The generic +`/api/collector/` endpoint calls this after the payload passes +identity validation, to idempotently upsert an asset. Return a dict with at +least `action` (`created` | `updated` | `noop`), `assetid`, and `warnings` +(list). + +This is a CONDITIONAL hook: it is only required when `get_collector_schema` +returns non-None. The BasePlugin default raises `NotImplementedError` (the +dispatcher turns that into a 500), so a plugin that declares a schema but +forgets the upsert fails loud. Plugins with no collector schema never need it. +The `test_schema_declaring_plugins_implement_apply` contract test enforces the +pairing. + +```python +def apply_collector_payload(self, payload): + host = payload['hostname'] + comp = Computer.query.filter(Computer.hostname.ilike(host)).first() + action = 'updated' if comp else 'created' + # ... create-or-update Asset + extension ... + db.session.commit() + return {'action': action, 'assetid': comp.assetid, 'warnings': []} +``` + ## Lifecycle hooks These run when the plugin's installation state changes. All optional. diff --git a/docs/adr/ADR-001-asset-as-platform-contract.md b/docs/adr/ADR-001-asset-as-platform-contract.md index ff703f8..ffa5d27 100644 --- a/docs/adr/ADR-001-asset-as-platform-contract.md +++ b/docs/adr/ADR-001-asset-as-platform-contract.md @@ -36,10 +36,29 @@ The following are the public, versioned surface. Plugin authors may depend on th - `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema - `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model +- `resolve_asset_position(asset)` for the documented position-resolution algorithm + +#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0 + +`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base` +for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or +`shopdb.utils.*` are contract violations enforced by the +`test_plugins_only_import_contract_surface` test. The surface re-exports: + +- Infrastructure: `db`, `cache` +- Model bases: `BaseModel`, `AuditMixin` +- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`, + `Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`, + `Application`, `AppVersion`, `OperatingSystem` +- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes` +- Pagination: `get_pagination_params`, `paginate_query` +- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection` + +Adding a name here is a minor (additive) contract change; removing one is major. #### Plugin contract -- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema) +- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema + `apply_collector_payload`) #### Excluded from the contract for v1 diff --git a/docs/adr/ADR-006-collector-contract.md b/docs/adr/ADR-006-collector-contract.md index c18b477..e277f5a 100644 --- a/docs/adr/ADR-006-collector-contract.md +++ b/docs/adr/ADR-006-collector-contract.md @@ -15,7 +15,7 @@ This calls for a generalizable contract: any plugin that wants to accept externa ## Decision -`BasePlugin` gets one new optional hook: +`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0): ```python def get_collector_schema(self) -> Optional[dict]: @@ -29,9 +29,26 @@ def get_collector_schema(self) -> Optional[dict]: - 'fields': JSON Schema definitions for the rest of the payload. """ return None + +def apply_collector_payload(self, payload: dict) -> dict: + """Idempotently upsert an asset from a validated collector payload. + + Called by /api/collector/ after identity validation. + CONDITIONAL hook: required only when get_collector_schema returns + non-None. Default raises NotImplementedError (the dispatcher returns + 500) so a schema-without-upsert fails loud. Returns a dict with at + least 'action' ('created'|'updated'|'noop'), 'assetid', 'warnings'. + """ + raise NotImplementedError ``` -Plugin loader auto-registers an endpoint at `/api/collector/` for each plugin returning a schema. Auth is API-key, separate from JWT. Per-plugin keys via env vars: +The pairing (schema present => apply implemented) is enforced by the +`test_schema_declaring_plugins_implement_apply` contract test. + +A single dynamic dispatch route `/api/collector/` serves every +plugin that returns a schema (rather than registering a blueprint per plugin), +because Flask forbids `register_blueprint` after the first request and plugins +can be enabled at runtime. Auth is API-key, separate from JWT. Per-plugin keys via env vars: - `COLLECTOR_API_KEY_` (preferred, plugin-specific) - `COLLECTOR_API_KEY` (fallback, shared) @@ -125,7 +142,7 @@ Migration path: ## References - `shopdb/core/api/collector.py` (legacy endpoint to be removed) -- `shopdb/plugins/base.py` (`get_collector_schema` hook to be added) +- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks) - ADR-001 (asset model the collectors target) - ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps) - The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 6f4e5c0..8e0508c 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -9,7 +9,7 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, AssetType, AssetStatus +from shopdb.api import db, AssetType from .models import Computer, ComputerType, ComputerInstalledApp from .api import computers_bp @@ -86,7 +86,7 @@ class ComputersPlugin(BasePlugin): def apply_collector_payload(self, payload: Dict) -> Dict: """Idempotent upsert of a PC from a collector payload (by hostname).""" from datetime import datetime - from shopdb.api import Asset, AssetType, Application, Communication, CommunicationType + from shopdb.api import Asset, Application, Communication, CommunicationType warnings = [] hostname = (payload.get('hostname') or '').strip() @@ -101,6 +101,8 @@ class ComputersPlugin(BasePlugin): action = 'updated' if not comp: atype = AssetType.query.filter_by(assettype='computer').first() + # statusid=1 is the first seeded asset status ("In Use"); a + # collector-discovered PC is by definition in use. asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, statusid=1) db.session.add(asset) diff --git a/shopdb/__init__.py b/shopdb/__init__.py index cfd7ee8..7dab0d3 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -44,6 +44,14 @@ def create_app(config_name: str = None) -> Flask: # Load instance config if exists app.config.from_pyfile('config.py', silent=True) + # Per-plugin collector keys (ADR-006) are dynamic env-vars + # (COLLECTOR_API_KEY_) that from_object cannot pick up because + # they are not class attributes. Copy them in explicitly so per-plugin + # credential isolation works in real deploys, not just tests. + for envname, envvalue in os.environ.items(): + if envname.startswith('COLLECTOR_API_KEY_') and envvalue: + app.config[envname] = envvalue + # Ensure instance folder exists os.makedirs(app.instance_path, exist_ok=True) diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index d67fa6a..1ee37de 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -213,165 +213,9 @@ def seed_settings(): """Seed default system settings.""" from shopdb.extensions import db from shopdb.core.models import Setting + from shopdb.core.api.settings import build_default_settings - defaults = [ - # Zabbix integration - { - 'key': 'zabbix_enabled', - 'value': 'false', - 'valuetype': 'boolean', - 'category': 'integrations', - 'description': 'Enable Zabbix integration for printer supply monitoring' - }, - { - 'key': 'zabbix_url', - 'value': '', - 'valuetype': 'string', - 'category': 'integrations', - 'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)' - }, - { - 'key': 'zabbix_token', - 'value': '', - 'valuetype': 'string', - 'category': 'integrations', - 'description': 'Zabbix API authentication token' - }, - # Email/SMTP settings - { - 'key': 'smtp_enabled', - 'value': 'false', - 'valuetype': 'boolean', - 'category': 'email', - 'description': 'Enable email notifications and alerts' - }, - { - 'key': 'smtp_host', - 'value': '', - 'valuetype': 'string', - 'category': 'email', - 'description': 'SMTP server hostname' - }, - { - 'key': 'smtp_port', - 'value': '587', - 'valuetype': 'integer', - 'category': 'email', - 'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)' - }, - { - 'key': 'smtp_username', - 'value': '', - 'valuetype': 'string', - 'category': 'email', - 'description': 'SMTP authentication username' - }, - { - 'key': 'smtp_password', - 'value': '', - 'valuetype': 'string', - 'category': 'email', - 'description': 'SMTP authentication password' - }, - { - 'key': 'smtp_use_tls', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'email', - 'description': 'Use TLS encryption for SMTP connection' - }, - { - 'key': 'smtp_from_address', - 'value': '', - 'valuetype': 'string', - 'category': 'email', - 'description': 'From address for outgoing emails' - }, - { - 'key': 'smtp_from_name', - 'value': 'ShopDB', - 'valuetype': 'string', - 'category': 'email', - 'description': 'From name for outgoing emails' - }, - { - 'key': 'alert_recipients', - 'value': '', - 'valuetype': 'string', - 'category': 'email', - 'description': 'Default email recipients for alerts (comma-separated)' - }, - # Audit log settings - { - 'key': 'audit_retention_days', - 'value': '90', - 'valuetype': 'integer', - 'category': 'audit', - 'description': 'Number of days to retain audit logs (0 = keep forever)' - }, - # Authentication settings - { - 'key': 'saml_enabled', - 'value': 'false', - 'valuetype': 'boolean', - 'category': 'auth', - 'description': 'Enable SAML SSO authentication' - }, - { - 'key': 'saml_idp_metadata_url', - 'value': '', - 'valuetype': 'string', - 'category': 'auth', - 'description': 'SAML Identity Provider metadata URL' - }, - { - 'key': 'saml_entity_id', - 'value': '', - 'valuetype': 'string', - 'category': 'auth', - 'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)' - }, - { - 'key': 'saml_acs_url', - 'value': '', - 'valuetype': 'string', - 'category': 'auth', - 'description': 'SAML Assertion Consumer Service URL' - }, - { - 'key': 'saml_allow_local_login', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'auth', - 'description': 'Allow local username/password login when SAML is enabled' - }, - { - 'key': 'saml_auto_create_users', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'auth', - 'description': 'Automatically create users on first SAML login' - }, - { - 'key': 'saml_admin_group', - 'value': '', - 'valuetype': 'string', - 'category': 'auth', - 'description': 'SAML group name that grants admin role' - }, - ] - - # Asset identifier toggles, per identifier AND per asset type (ADR-001). - from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES - for name, label in IDENTIFIER_LABELS.items(): - for assettype in IDENTIFIER_ASSETTYPES: - defaults.append({ - 'key': f'identifier_{name}_{assettype}_enabled', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'identifiers', - 'description': f'Show the {label} identifier on {assettype} assets', - }) + defaults = build_default_settings() created = 0 for d in defaults: diff --git a/shopdb/config.py b/shopdb/config.py index deecffa..708afbc 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -62,9 +62,11 @@ class Config: # Read-only HR/employee directory database (separate from the app DB). # Credentials come from the environment; never hardcode them in source. + # No safe default for the password: unset means empty, the connection + # fails loud rather than silently trying a guessed credential. EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost') - EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', 'root') - EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', 'rootpassword') + EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '') + EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '') EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees') CACHE_TYPE = 'SimpleCache' diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index 2bfa4c9..d98f87f 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -81,7 +81,13 @@ def _collector_plugins(): try: schema = plugin.get_collector_schema() except Exception: - schema = None + # Fail loud in dev/test so a broken hook is visible; isolate the + # misbehaving plugin in prod and keep serving the healthy ones. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_collector_schema failed', name) + continue if schema: result[name] = (plugin, schema) return result diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py index 7ed5610..a4371ea 100644 --- a/shopdb/core/api/dashboard.py +++ b/shopdb/core/api/dashboard.py @@ -125,15 +125,19 @@ def get_navigation(): # away (its routes stay registered until the next restart - Flask cannot # unregister a blueprint at runtime). for name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(name): + continue try: - if not pm.registry.is_enabled(name): - continue items = plugin.get_navigation_items() for item in items: item['plugin'] = name all_items.extend(items) except Exception: - pass + # Fail loud in dev/test; isolate a broken plugin in prod. + if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): + raise + current_app.logger.exception( + 'Plugin %s get_navigation_items failed', name) # Add core information section items all_items.extend([ diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 3625e0d..5533f66 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -161,10 +161,12 @@ def create_setting(): return success_response(setting.to_dict(), message='Setting created', http_code=201) -@settings_bp.route('/seed', methods=['POST']) -@jwt_required() -def seed_default_settings(): - """Seed default settings if they don't exist.""" +def build_default_settings(): + """Return the full default-settings list (identifier toggles + static). + + Shared by the /settings/seed route and the `flask seed settings` CLI so + the two definitions never drift. + """ # Asset identifier feature toggles, per identifier AND per asset type. # Key format: identifier___enabled (boolean). Admins pick # which optional identifiers show on which asset types. See ADR-001. @@ -327,8 +329,15 @@ def seed_default_settings(): }, ] + return defaults + + +@settings_bp.route('/seed', methods=['POST']) +@jwt_required() +def seed_default_settings(): + """Seed default settings if they don't exist.""" created = 0 - for d in defaults: + for d in build_default_settings(): if not Setting.query.filter_by(key=d['key']).first(): setting = Setting(**d) db.session.add(setting) diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py index 9b1d74e..e9dee4c 100644 --- a/shopdb/plugins/__init__.py +++ b/shopdb/plugins/__init__.py @@ -40,11 +40,18 @@ class PluginManager: self.migration_manager: Optional[PluginMigrationManager] = None self._app: Optional[Flask] = None self._db = None + # API prefixes already claimed by a registered plugin blueprint, to + # detect two plugins overlapping on the same /api/... namespace. + self._registered_prefixes: set = set() def init_app(self, app: Flask, db) -> None: """Initialize plugin manager with Flask app.""" self._app = app self._db = db + # Reset per-app so the prefix-uniqueness guard tracks only this app's + # registrations (the manager is a process-wide singleton; tests build + # multiple apps from it). + self._registered_prefixes = set() # Setup paths instance_path = Path(app.instance_path) @@ -104,11 +111,18 @@ class PluginManager: # Register blueprint blueprint = plugin.get_blueprint() if blueprint: - self._app.register_blueprint( - blueprint, - url_prefix=plugin.meta.api_prefix - ) - logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}") + prefix = plugin.meta.api_prefix + # Guard against two plugins claiming the same API prefix; Flask only + # rejects duplicate blueprint names, not overlapping url_prefixes, so + # an overlap would silently shadow routes. + if prefix in self._registered_prefixes: + raise ValueError( + f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already " + f"claimed by another blueprint" + ) + self._app.register_blueprint(blueprint, url_prefix=prefix) + self._registered_prefixes.add(prefix) + logger.debug(f"Registered blueprint: {prefix}") # Register CLI commands for cmd in plugin.get_cli_commands(): @@ -160,17 +174,16 @@ class PluginManager: logger.warning(f"Plugin {name} is already installed") return False - # Load plugin class - plugin_class = self.loader.load_plugin_class(name) - if not plugin_class: + # Read metadata from the manifest (single source of truth) instead of + # instantiating the plugin class just to inspect deps/version. + manifest = self.loader.load_manifest(name) + if not manifest: logger.error(f"Plugin {name} not found") return False - - temp_plugin = plugin_class() - meta = temp_plugin.meta + manifest_version = manifest.get('version') # Check dependencies - for dep in meta.dependencies: + for dep in manifest.get('dependencies', []): if not self.registry.is_installed(dep): logger.error( f"Plugin {name} requires {dep} to be installed first" @@ -185,7 +198,7 @@ class PluginManager: return False # Register plugin - self.registry.register(name, meta.version) + self.registry.register(name, manifest_version) # Load the plugin plugin = self.loader.load_plugin(name, self._app, self._db) @@ -193,7 +206,7 @@ class PluginManager: self._register_plugin_components(plugin) plugin.on_install(self._app) - logger.info(f"Installed plugin: {name} v{meta.version}") + logger.info(f"Installed plugin: {name} v{manifest_version}") return True def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool: @@ -246,14 +259,14 @@ class PluginManager: logger.info(f"Plugin {name} is already enabled") return True - # Check dependencies are enabled - plugin_class = self.loader.load_plugin_class(name) - if plugin_class: - temp = plugin_class() - for dep in temp.meta.dependencies: - if not self.registry.is_enabled(dep): - logger.error(f"Cannot enable {name}: {dep} is not enabled") - return False + # Check dependencies are enabled. Read deps from the manifest, not by + # instantiating the plugin class (manifest is the single source of + # truth; instantiating fires __init__ side effects unnecessarily). + manifest = self.loader.load_manifest(name) + for dep in manifest.get('dependencies', []): + if not self.registry.is_enabled(dep): + logger.error(f"Cannot enable {name}: {dep} is not enabled") + return False self.registry.enable(name) diff --git a/tests/test_core/test_identifiers.py b/tests/test_core/test_identifiers.py new file mode 100644 index 0000000..48b06e4 --- /dev/null +++ b/tests/test_core/test_identifiers.py @@ -0,0 +1,77 @@ +"""Tests for per-asset-type optional identifiers (gauge/maintenance refs). + +Pins two behaviors that shipped without coverage: +1. gaugelabreference + maintenancereference round-trip through each plugin's + asset create AND update endpoints (computer, printer, network). +2. The per-type identifier seed produces one setting per + (identifier x asset type) pair. +""" + +import pytest + +from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES + + +@pytest.fixture +def asset_types(db): + """Seed the asset types the create endpoints look up by name.""" + from shopdb.core.models import AssetType + + for name in ('computer', 'printer', 'network_device'): + db.session.add(AssetType(assettype=name, pluginname=name, + tablename=name, description=name)) + db.session.commit() + + +# (endpoint prefix, extension key in the response, extra create fields) +PLUGIN_CASES = [ + ('/api/computers', 'computer', {}), + ('/api/printers', 'printer', {}), + ('/api/network', 'networkdevice', {}), +] + + +@pytest.mark.parametrize('prefix,extkey,extra', PLUGIN_CASES) +def test_gauge_maintenance_roundtrip(client, db, auth_headers, asset_types, + prefix, extkey, extra): + """Gauge/maintenance refs persist on create and update for each type.""" + payload = { + 'assetnumber': f'AST-{extkey}', + 'gaugelabreference': 'GL-1', + 'maintenancereference': 'MNT-1', + **extra, + } + created = client.post(prefix, json=payload, headers=auth_headers) + assert created.status_code == 201, created.get_json() + body = created.get_json()['data'] + assert body['gaugelabreference'] == 'GL-1' + assert body['maintenancereference'] == 'MNT-1' + + extid = body[extkey][f'{extkey}id'] if extkey != 'networkdevice' \ + else body[extkey]['networkdeviceid'] + + updated = client.put(f'{prefix}/{extid}', + json={'gaugelabreference': 'GL-2', + 'maintenancereference': 'MNT-2'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + + fetched = client.get(f'{prefix}/{extid}', headers=auth_headers).get_json()['data'] + assert fetched['gaugelabreference'] == 'GL-2' + assert fetched['maintenancereference'] == 'MNT-2' + + +def test_per_type_identifier_seed_count(client, db, auth_headers): + """Seeding produces one boolean setting per identifier x asset type.""" + response = client.post('/api/settings/seed', headers=auth_headers) + assert response.status_code == 200 + + from shopdb.core.models import Setting + keys = {s.key for s in Setting.query.filter_by(category='identifiers').all()} + expected = { + f'identifier_{name}_{assettype}_enabled' + for name in IDENTIFIER_LABELS + for assettype in IDENTIFIER_ASSETTYPES + } + assert expected <= keys + assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES) diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 9d9a031..05e5ba8 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -146,6 +146,23 @@ def test_baseplugin_has_collector_schema_hook(): assert hasattr(BasePlugin, 'get_collector_schema') +def test_baseplugin_has_apply_collector_payload_hook(): + """The collector upsert hook is on the contract surface (ADR-006).""" + assert hasattr(BasePlugin, 'apply_collector_payload') + + +def test_schema_declaring_plugins_implement_apply(plugin_instances): + """Any plugin returning a collector schema must implement the upsert hook.""" + for name, plugin in plugin_instances.items(): + if plugin.get_collector_schema() is not None: + overridden = type(plugin).apply_collector_payload \ + is not BasePlugin.apply_collector_payload + assert overridden, ( + f'Plugin {name} declares a collector schema but does not ' + f'override apply_collector_payload' + ) + + # Imports a plugin may make from the core. shopdb.api is the contract surface; # shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*, # shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001. diff --git a/tests/test_security_config.py b/tests/test_security_config.py index 2f35aff..0cd1e17 100644 --- a/tests/test_security_config.py +++ b/tests/test_security_config.py @@ -68,3 +68,23 @@ def test_production_validate_passes_with_complete_config(clean_env): clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb') clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com') ProductionConfig.validate() + + +def test_per_plugin_collector_key_loaded_from_env(monkeypatch): + """COLLECTOR_API_KEY_ is a dynamic env var; create_app must load it. + + from_object only copies class attributes, so per-plugin keys (ADR-006) + would be invisible without the explicit env scan in create_app. + """ + from shopdb import create_app + + monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret') + app = create_app('testing') + assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret' + + +def test_employee_db_password_has_no_default(): + """No safe default for the employee-DB password: unset means empty.""" + if 'EMPLOYEE_DB_PASSWORD' not in os.environ: + from shopdb.config import Config + assert Config.EMPLOYEE_DB_PASSWORD == '' From ccead771e6bc04837eee56495b1507d8e02d9b0a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 19:43:22 -0400 Subject: [PATCH 28/32] Fix stale identifier flags: refresh composable on Settings toggle The identifier-flags composable fetched settings once and cached them in a module singleton, so toggling an identifier in Settings (gauge/maintenance/FQDN per asset type) did not take effect on already-open asset views until a full page reload. Disabled identifiers kept showing. - identifierSettings.js: extract applySetting/fetchFlags; export reloadIdentifierFlags() and setIdentifierFlag(name, assettype, enabled) to mutate the shared reactive state. - SystemSettings.vue: push each successful matrix toggle into the shared state via setIdentifierFlag so dependent views react immediately. Co-Authored-By: Claude Opus 4.8 --- .../src/composables/identifierSettings.js | 58 +++++++++++++------ .../src/views/settings/SystemSettings.vue | 4 ++ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js index 752c669..a9c72b4 100644 --- a/frontend/src/composables/identifierSettings.js +++ b/frontend/src/composables/identifierSettings.js @@ -14,28 +14,48 @@ const state = reactive({ let inflight = null -function loadFlags() { - if (!state.loaded && !inflight) { - inflight = settingsApi.list() - .then(({ data }) => { - ;(data.data || []).forEach(s => { - const match = /^identifier_(.+?)(?:_(equipment|computer|printer|network_device))?_enabled$/.exec(s.key) - if (!match) return - const name = match[1] - const assettype = match[2] - if (assettype) { - if (!state.scope[name]) state.scope[name] = {} - state.scope[name][assettype] = s.value !== false - } else { - state.legacy[name] = s.value !== false - } - }) - state.loaded = true - }) - .catch(() => { state.loaded = true }) +const KEY_RE = /^identifier_(.+?)(?:_(equipment|computer|printer|network_device))?_enabled$/ + +function applySetting(key, value) { + const match = KEY_RE.exec(key) + if (!match) return + const name = match[1] + const assettype = match[2] + if (assettype) { + if (!state.scope[name]) state.scope[name] = {} + state.scope[name][assettype] = value !== false + } else { + state.legacy[name] = value !== false } } +function fetchFlags() { + inflight = settingsApi.list() + .then(({ data }) => { + ;(data.data || []).forEach(s => applySetting(s.key, s.value)) + state.loaded = true + }) + .catch(() => { state.loaded = true }) + .finally(() => { inflight = null }) + return inflight +} + +function loadFlags() { + if (!state.loaded && !inflight) fetchFlags() +} + +// Re-read flags from the server. Call after an identifier setting changes so +// other open views pick it up without a full page reload. +export function reloadIdentifierFlags() { + return fetchFlags() +} + +// Optimistically update one flag in the shared state (e.g. right after a +// Settings toggle) so dependent views react immediately. +export function setIdentifierFlag(name, assettype, enabled) { + applySetting(`identifier_${name}_${assettype}_enabled`, enabled) +} + // True when identifier `name` should show on `assettype`. Per-type flag wins, // then the legacy global flag, then default-on. function isEnabled(name, assettype) { diff --git a/frontend/src/views/settings/SystemSettings.vue b/frontend/src/views/settings/SystemSettings.vue index 52044db..57c7194 100644 --- a/frontend/src/views/settings/SystemSettings.vue +++ b/frontend/src/views/settings/SystemSettings.vue @@ -423,6 +423,7 @@