printers: low-toner alerts with configurable thresholds + support-team routing

Poll Zabbix for toner levels on a schedule and email/webhook on a downward
crossing. Warning fires at or below the warning threshold (default 5%),
critical at the critical threshold (default 0%); both thresholds are settings.
State lives in printersupplyalerts so an alert fires once per crossing and
re-arms after a refill.

Recipients mirror the printedparts pattern: plugin-scoped shopdb users +
roles + free-text emails (falling back to the site alert_recipients), and a
chosen support team's webhook (falling back to the site alert_webhook_url).

- PrinterSupplyAlert model + migration printers0002supplyalerts
- alerttier(remaining, warning, critical) + check_supplies poller
- flask printers check-toner-alerts CLI (run via scheduled task/cron)
- printers alert settings + Low-Toner Alerts settings page
- 7 tests: tier boundaries, once-per-crossing + re-arm, toner-only scope,
  custom thresholds, support-team webhook routing
This commit is contained in:
cproudlock
2026-07-22 14:47:59 -04:00
parent fb188fd302
commit b211e817d5
10 changed files with 739 additions and 3 deletions

View File

@@ -6,7 +6,9 @@ from .supply_parts import (
derivesupplytype,
derivecolor,
lookupsupplies,
alerttier,
)
from .supply_alerts import check_supplies
from .seed_supplies import seedsupplies
__all__ = [
@@ -15,5 +17,7 @@ __all__ = [
'derivesupplytype',
'derivecolor',
'lookupsupplies',
'alerttier',
'check_supplies',
'seedsupplies',
]

View File

@@ -0,0 +1,190 @@
"""Low-toner email/webhook alerting.
The report shows live levels; this fires an alert when a toner crosses a
threshold on its way DOWN. A warning email fires at or below 5 percent, a
critical email at 0 percent (empty). State lives in printersupplyalerts so an
alert fires once per crossing and re-arms only after a refill.
Recipients mirror the printedparts pattern: a plugin-scoped set of shopdb
users + roles + a free-text email list, falling back to the site-wide
alert_recipients; the webhook routes to a chosen support team's webhook,
falling back to the site-wide alert_webhook_url.
"""
import logging
from shopdb.api import db, Setting
from ..models import Printer, PrinterSupplyAlert
from .supply_parts import derivesupplytype, derivecolor, alerttier, TIER_RANK
logger = logging.getLogger(__name__)
def alert_recipients():
"""Merge selected shopdb users' account emails with the free-text list.
Empty result means fall back to the site-wide alert_recipients."""
from shopdb.api import User, Role
recipients = []
userids = (Setting.get('printers_alert_userids') or '').strip()
for rawid in userids.split(','):
rawid = rawid.strip()
if not rawid.isdigit():
continue
user = db.session.get(User, int(rawid))
if user and user.isactive and user.email:
recipients.append(user.email)
roleids = (Setting.get('printers_alert_roleids') or '').strip()
for rawid in roleids.split(','):
rawid = rawid.strip()
if not rawid.isdigit():
continue
role = db.session.get(Role, int(rawid))
if role:
for user in role.users:
if user.isactive and user.email:
recipients.append(user.email)
freetext = Setting.get('printers_alert_email') or ''
for addr in freetext.replace(';', ',').split(','):
addr = addr.strip()
if addr:
recipients.append(addr)
# de-dupe, preserve order
seen = set()
unique = []
for addr in recipients:
low = addr.lower()
if low not in seen:
seen.add(low)
unique.append(addr)
return unique
def _threshold(key, default):
"""Read a numeric threshold setting, tolerating blank/garbage values."""
try:
raw = Setting.get(key)
return float(raw) if raw not in (None, '') else float(default)
except (ValueError, TypeError):
return float(default)
def site_alert_recipients():
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
raw = Setting.get('alert_recipients') or ''
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
def alert_team_webhook():
"""Webhook URL of the support team chosen for toner alerts, or None.
printers_alert_supportteamid selects a SupportTeam; alerts route to that
team's webhook. None -> send_webhook uses the site-wide default."""
team_id = Setting.get('printers_alert_supportteamid')
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None
return (team.webhookurl or None) if team else None
def _printer_ipaddress(printer):
"""Primary (else any) communication IP for a printer's asset, or None."""
from shopdb.api import Communication
asset = printer.asset
if not asset:
return None
comm = Communication.query.filter_by(
assetid=asset.assetid, isprimary=True).first() \
or Communication.query.filter_by(assetid=asset.assetid).first()
return comm.ipaddress if comm else None
def _fire_alert(printer, color, remaining, tier):
"""Best-effort email + webhook for one toner crossing. Never raises."""
from shopdb.api import send_email, send_webhook
name = printer.asset.name or printer.asset.assetnumber if printer.asset \
else f'Printer {printer.printerid}'
label = 'CRITICAL - empty' if tier == 'critical' else 'Low'
subject = f'{label} toner: {name} ({color}) - {remaining:.0f}% remaining'
base = (Setting.get('site_base_url') or '').rstrip('/')
link = f'{base}/printers/{printer.printerid}' if base else ''
link_html = f'<p><a href="{link}">View {name}</a></p>' if link else ''
html = (f'<p><strong>{name}</strong> {color} toner is at '
f'<strong>{remaining:.0f}%</strong> ({tier}).</p>{link_html}')
webhook_text = f'{name} {color} toner at {remaining:.0f}% ({tier})'
try:
send_webhook(subject, webhook_text, url=alert_team_webhook())
recipients = alert_recipients() or site_alert_recipients()
if recipients:
send_email(recipients, subject, html)
except Exception:
logger.exception('Toner alert failed for printer %s', printer.printerid)
def check_supplies(service=None):
"""Poll Zabbix for all active printers' toner and alert on crossings.
Returns a summary dict. Intended to run on a schedule (a scheduled task or
cron calling `flask printers check-toner-alerts`)."""
from shopdb.api import Asset
from .zabbix_service import ZabbixService
service = service or ZabbixService()
summary = {'printers': 0, 'polled': 0, 'alerts': 0, 'rearmed': 0}
if not service.isconfigured:
logger.warning('Zabbix not configured; skipping toner alert poll')
return summary
warning_threshold = _threshold('printers_alert_warning_threshold', 5)
critical_threshold = _threshold('printers_alert_critical_threshold', 0)
printers = db.session.query(Printer).join(Asset).filter(
Asset.isactive == True).all() # noqa: E712
for printer in printers:
summary['printers'] += 1
ipaddress = _printer_ipaddress(printer)
if not ipaddress or ipaddress == 'USB':
continue
supplies = service.getsuppliesbyip(ipaddress)
if not supplies:
continue
summary['polled'] += 1
for supply in supplies:
supplyname = supply.get('name', '')
if derivesupplytype(supplyname) != 'toner':
continue
try:
remaining = float(supply.get('level', 0))
except (TypeError, ValueError):
continue
color = supply.get('color') or derivecolor(supplyname)
supplykey = color if color and color != 'none' else supplyname
supplykey = supplykey[:64]
tier = alerttier(remaining, warning_threshold, critical_threshold)
row = PrinterSupplyAlert.query.filter_by(
printerid=printer.printerid, supplykey=supplykey).first()
prior = row.lasttier if row else 'ok'
if tier == prior:
continue
if row is None:
row = PrinterSupplyAlert(
printerid=printer.printerid, supplykey=supplykey,
lasttier=tier)
db.session.add(row)
else:
row.lasttier = tier
if TIER_RANK[tier] > TIER_RANK[prior]:
_fire_alert(printer, color, remaining, tier)
summary['alerts'] += 1
else:
summary['rearmed'] += 1
db.session.commit()
return summary

View File

@@ -10,10 +10,34 @@ and reading the matching part numbers out of the database.
from typing import Dict, List, Optional
# alert thresholds (percent remaining)
# display thresholds (percent remaining) for the ok/low/critical report badge
CRITICAL_THRESHOLD = 5
LOW_THRESHOLD = 10
# toner email-alert tiers (percent remaining). Distinct from the display
# badge above: a warning email fires at or below TONER_WARNING_THRESHOLD, a
# critical email at or below TONER_CRITICAL_THRESHOLD (empty).
TONER_WARNING_THRESHOLD = 5
TONER_CRITICAL_THRESHOLD = 0
def alerttier(remaining: float, warning: float = TONER_WARNING_THRESHOLD,
critical: float = TONER_CRITICAL_THRESHOLD) -> str:
"""Map a toner percent-remaining to an email-alert tier.
critical at or below the critical threshold (default 0 = empty), warning at
or below the warning threshold (default 5), else ok. Both are configurable
(printers_alert_critical_threshold / printers_alert_warning_threshold)."""
if remaining <= critical:
return 'critical'
if remaining <= warning:
return 'warning'
return 'ok'
# tier severity rank; an alert fires only when the rank increases (worsens)
TIER_RANK = {'ok': 0, 'warning': 1, 'critical': 2}
def derivesupplytype(name: str) -> str:
"""Map a Zabbix item name to a supply type."""