Files
shopdb-flask/plugins/printers/plugin.py
cproudlock d109314123 Forecast when a printer runs out, and count what it has been through
The toner report says what is empty now. It could not say what to order, and
nothing recorded how fast anything drains - every level read was cached for
five minutes and then discarded.

Zabbix has been keeping the history all along; we simply never asked. One
history.get gives both answers, because a cartridge only goes DOWN while it is
in use: a rise is a replacement. Count the rises and you have how many
cartridges a printer has been through; fit a slope to the readings SINCE the
last rise and you have days-to-empty. Fitting across a replacement averages a
spent cartridge with a fresh one and describes neither.

Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day
needs ordering before one sitting at 8% that has not moved in months, and a
level-sorted list ranks those backwards.

It refuses to guess. Too few readings, a level that has not moved enough - many
printers report in 10% steps and sit on a plateau for a fortnight - or a recent
replacement each produce no estimate and say which. Those printers are listed
separately rather than sorted in as 0 or as 999, since a printer without an
estimate is neither urgent nor safe. Estimates show what they rest on, because
"9 days from 21 days of readings" and "9 days from 2 readings" are not the same
claim.

A separate report card, not an extension of the toner report: that one is an
exceptions list a tech acts on today, this is an ordering view read monthly,
and the history query is heavier than the live read it would have slowed down.

The analysis is pure arithmetic over a list of readings, so the 14 tests cover
the noise wobble, the plateau, the swap, junk rows and division by zero without
needing Zabbix. Zabbix being unreachable is reported as such rather than
rendering an empty table that reads as "nothing is due".
2026-08-12 11:45:40 -04:00

382 lines
14 KiB
Python

"""Printers plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import (
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
)
from .api import printers_asset_bp
from .services import ZabbixService
logger = logging.getLogger(__name__)
class PrintersPlugin(BasePlugin):
"""
Printers plugin - manages printer assets.
Supports both legacy Machine-based architecture and new Asset-based architecture:
- Legacy: PrinterData table linked to machines
- New: Printer table linked to assets
Features:
- PrinterType classification
- Windows/network naming
- Zabbix integration for real-time supply level lookups
"""
def __init__(self):
self._manifest = self._load_manifest()
self._zabbixservice = None
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'printers'),
version=self._manifest.get('version', '2.0.0'),
description=self._manifest.get(
'description',
'Printer management with Zabbix integration'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/printers'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""
Return Flask Blueprint with API routes.
Returns the new Asset-based blueprint.
Legacy Machine-based blueprint is registered separately in init_app.
"""
return printers_asset_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [
Printer, # Asset-based
PrinterType, # printer type classification
PrinterDriver, # driver links (SMB/HTTP)
ModelSupply, # model -> toner/drum/waste part numbers
PrinterSupplyAlert, # per-printer toner alert crossing state
]
def get_services(self) -> Dict[str, Type]:
"""Return plugin services."""
return {
'zabbix': ZabbixService,
}
@property
def zabbixservice(self) -> ZabbixService:
"""Get Zabbix service instance."""
if self._zabbixservice is None:
self._zabbixservice = ZabbixService()
return self._zabbixservice
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
app.config.setdefault('ZABBIX_URL', '')
app.config.setdefault('ZABBIX_TOKEN', '')
logger.info(f"Printers plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_printer_types()
logger.info("Printers plugin installed")
def get_settings_defaults(self) -> List[dict]:
"""Low-toner alert settings.
The framework seeds these at install, at enable, and on every
`flask plugin upgrade-all`, so a key added in a later version reaches a
site that installed an earlier one.
"""
return [
{
'key': 'printers_alert_email',
'value': '',
'valuetype': 'string',
'category': 'printers',
'description': 'Comma-separated low-toner alert recipients; '
'empty uses the site alert_recipients',
},
{
'key': 'printers_alert_userids',
'value': '',
'valuetype': 'string',
'category': 'printers',
'description': 'Comma-separated shopdb user ids whose account '
'emails receive low-toner alerts',
},
{
'key': 'printers_alert_roleids',
'value': '',
'valuetype': 'string',
'category': 'printers',
'description': 'Comma-separated role ids; every active member '
'of these roles receives low-toner alerts',
},
{
'key': 'printers_alert_supportteamid',
'value': '',
'valuetype': 'string',
'category': 'printers',
'description': 'Support team whose webhook receives low-toner '
'alerts; empty uses the site alert_webhook_url',
},
{
'key': 'printers_alert_warning_threshold',
'value': '5',
'valuetype': 'integer',
'category': 'printers',
'description': 'Toner percent remaining at or below which a '
'warning email fires',
},
{
'key': 'printers_alert_critical_threshold',
'value': '0',
'valuetype': 'integer',
'category': 'printers',
'description': 'Toner percent remaining at or below which a '
'critical email fires',
},
]
def _ensure_asset_type(self) -> None:
"""Ensure printer asset type exists."""
existing = AssetType.query.filter_by(assettype='printer').first()
if not existing:
at = AssetType(
assettype='printer',
pluginname='printers',
tablename='printers',
description='Printers (laser, inkjet, label, MFP, plotter)',
icon='printer'
)
db.session.add(at)
logger.debug("Created asset type: printer")
db.session.commit()
def _ensure_printer_types(self) -> None:
"""Ensure basic printer types exist (new architecture)."""
printer_types = [
('Laser', 'Standard laser printer', 'printer'),
('Inkjet', 'Inkjet printer', 'printer'),
('Label', 'Label/barcode printer', 'barcode'),
('Card', 'ID / card printer', 'id-card'),
('MFP', 'Multifunction printer with scan/copy/fax', 'printer'),
('Plotter', 'Large format plotter', 'drafting-compass'),
('Thermal', 'Thermal printer', 'temperature-high'),
('Dot Matrix', 'Dot matrix printer', 'th'),
('Other', 'Other printer type', 'printer'),
]
for name, description, icon in printer_types:
existing = PrinterType.query.filter_by(printertype=name).first()
if not existing:
pt = PrinterType(
printertype=name,
description=description,
icon=icon
)
db.session.add(pt)
logger.debug(f"Created printer type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Printers plugin uninstalled")
def get_settings_cards(self) -> List[dict]:
return [
{
'group': 'Printers',
'to': '/settings/printer-alerts',
'icon': 'bell',
'title': 'Low-Toner Alerts',
'description': 'Who gets warning (5%) and critical (0%) toner '
'emails, and which support team webhook',
'position': 48,
},
]
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('printers')
def printerscli():
"""Printers plugin commands."""
pass
@printerscli.command('check-toner-alerts')
def checktoneralerts():
"""Poll Zabbix for all printers and email/webhook low-toner crossings.
Run on a schedule (scheduled task / cron). Fires a warning at or
below 5 percent and a critical at 0 percent, once per crossing."""
from flask import current_app
from .services import check_supplies
with current_app.app_context():
summary = check_supplies()
click.echo(
f"Toner poll: {summary['polled']}/{summary['printers']} "
f"printers reachable, {summary['alerts']} alert(s) sent, "
f"{summary['rearmed']} re-armed.")
@printerscli.command('check-supplies')
@click.argument('ip')
def checksupplies(ip):
"""Check supply levels for a printer by IP (via Zabbix)."""
from flask import current_app
with current_app.app_context():
service = ZabbixService()
if not service.isconfigured:
click.echo('Error: Zabbix not configured. Set ZABBIX_URL and ZABBIX_TOKEN.')
return
supplies = service.getsuppliesbyip(ip)
if not supplies:
click.echo(f'No supply data found for {ip}')
return
click.echo(f'Supply levels for {ip}:')
for supply in supplies:
click.echo(f" {supply['name']}: {supply['level']}%")
@printerscli.command('seed-supplies')
def seedsuppliescommand():
"""Seed corrected model->toner part numbers into modelsupplies."""
from flask import current_app
from .services import seedsupplies
with current_app.app_context():
summary = seedsupplies()
click.echo(
f"Seeded supplies: {summary['suppliesadded']} added across "
f"{summary['modelstouched']} models."
)
return [printerscli]
def get_settings_defaults(self) -> List[Dict]:
"""Settings this plugin owns for the dashboard card."""
return [
{
'key': 'printers_dashboardpercent',
'value': '5',
'valuetype': 'integer',
'category': 'printers',
'description': 'Supply percentage at or below which a printer '
'appears on the dashboard. Tighter than the '
'low-supplies report, which is for planning an '
'order rather than walking out to change one.',
},
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: printers needing a cartridge.
Replaces a declaration naming a component nobody wrote. Reuses the
low-supplies query and its cache - a Zabbix round-trip per printer on
every dashboard load would make this the slowest page in the app.
"""
return [
{
'id': 'printers-supplies',
'viewall': '/reports/toner',
'title': 'Printer supplies',
'endpoint': '/api/printers/dashboard/supplies',
'render': 'exceptions',
'severity': 'warning',
'permission': 'printers.view',
'empty': 'hide',
'position': 40,
'map': {
'title': 'printername',
# Location on hover rather than on the line: it is context
# for "where do I walk", not part of the finding, and it
# was the text pushing rows past the card edge.
'titletooltip': 'location',
# Hovering the name shows the floor-plan preview, the
# same component the printer's own page uses: a
# location name tells you the room, the map tells you
# where to walk.
'maphover': {'x': 'mapx', 'y': 'mapy',
'label': 'printername'},
'chips': 'supplies',
'link': '/printers/{printerid}',
},
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Printers',
'icon': 'printer',
'route': '/printers',
'position': 20,
},
]
def get_reports(self) -> List[Dict]:
"""Return report card definitions for the Reports hub."""
return [
{
'id': 'toner',
'name': 'Toner Report',
'description': 'Printers with low or critical toner/supply levels',
'category': 'printers',
'route': '/reports/toner',
},
{
# Separate from the toner report on purpose: that one is an
# exceptions list a tech acts on today, this is an ordering
# view read monthly, and it runs a heavier history query.
'id': 'tonerforecast',
'name': 'Toner Forecast',
'description': 'Estimated days until each printer runs out, '
'and how many cartridges it has been through',
'category': 'printers',
'route': '/reports/toner-forecast',
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('printers.view', 'View printers', 'printers'),
('printers.create', 'Create printers', 'printers'),
('printers.edit', 'Edit printers', 'printers'),
('printers.delete', 'Delete printers', 'printers'),
]