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:
@@ -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',
|
||||
]
|
||||
|
||||
190
plugins/printers/services/supply_alerts.py
Normal file
190
plugins/printers/services/supply_alerts.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user