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

@@ -0,0 +1,165 @@
"""Low-toner alert tests.
Invariants: the alert tier boundaries (5% warning, 0% critical), the
fire-once-per-crossing state machine (re-arm on refill), toner-only scope,
and recipient routing to a support team's webhook.
"""
import pytest
from unittest.mock import patch
from shopdb.api import db
from shopdb.core.models import AssetType, Setting, SupportTeam
from plugins.printers.models import PrinterSupplyAlert
from plugins.printers.services.supply_parts import alerttier
from plugins.printers.services.supply_alerts import check_supplies
class FakeZabbix:
"""Stand-in Zabbix service: canned supplies keyed by IP."""
def __init__(self, byip):
self._byip = byip
self.isconfigured = True
def getsuppliesbyip(self, ip):
return self._byip.get(ip, [])
@pytest.fixture
def printer_assettype(db):
at = AssetType(assettype='printer', pluginname='printers',
tablename='printers', description='Printers')
db.session.add(at)
db.session.commit()
return at
@pytest.fixture
def ip_comtype(db):
from shopdb.core.models import CommunicationType
row = CommunicationType.query.filter_by(comtype='IP').first()
if not row:
row = CommunicationType(comtype='IP', description='IP address')
db.session.add(row)
db.session.commit()
return row
@pytest.fixture
def network_printer(client, auth_headers, printer_assettype, ip_comtype):
"""A network printer at 10.0.0.5 with a black toner, via the real API."""
resp = client.post('/api/printers', json={
'assetnumber': 'CSF01-LaserJet',
'hostname': 'wjprn05',
'ipaddress': '10.0.0.5',
}, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return '10.0.0.5'
def test_alerttier_boundaries():
assert alerttier(50) == 'ok'
assert alerttier(6) == 'ok'
assert alerttier(5) == 'warning'
assert alerttier(1) == 'warning'
assert alerttier(0) == 'critical'
def _toner(level, color='black'):
return {'name': f'{color} toner', 'level': level, 'color': color}
def test_warning_crossing_fires_once_and_rearms(app, db, network_printer):
ip = network_printer
with app.app_context():
# 20% -> ok, no alert
with patch('shopdb.api.send_webhook') as hook1, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(20)]}))
assert not hook1.called
# 4% -> warning, alert fires
with patch('shopdb.api.send_webhook') as hook2, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(4)]}))
assert hook2.called
# still 3% -> already warning, no repeat
with patch('shopdb.api.send_webhook') as hook3, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(3)]}))
assert not hook3.called
# refill to 80% -> re-arm (state back to ok), no alert
with patch('shopdb.api.send_webhook') as hook4, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(80)]}))
assert not hook4.called
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
assert row.lasttier == 'ok'
# drop to 2% again -> warning fires again after the re-arm
with patch('shopdb.api.send_webhook') as hook5, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(2)]}))
assert hook5.called
def test_direct_to_critical_fires_critical(app, db, network_printer):
ip = network_printer
with app.app_context():
with patch('shopdb.api.send_webhook') as hook, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(0)]}))
assert hook.called
subject = hook.call_args.args[0]
assert 'CRITICAL' in subject
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
assert row.lasttier == 'critical'
def test_alerttier_respects_custom_thresholds():
# warning at <=20, critical at <=8
assert alerttier(25, warning=20, critical=8) == 'ok'
assert alerttier(20, warning=20, critical=8) == 'warning'
assert alerttier(8, warning=20, critical=8) == 'critical'
def test_custom_warning_threshold_setting_fires(app, db, network_printer):
ip = network_printer
with app.app_context():
Setting.set('printers_alert_warning_threshold', '20')
# 15% would be 'ok' under the default 5% warning, but fires at 20%
with patch('shopdb.api.send_webhook') as hook, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(15)]}))
assert hook.called
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
assert row.lasttier == 'warning'
def test_non_toner_supply_ignored(app, db, network_printer):
ip = network_printer
with app.app_context():
waste = {'name': 'waste toner box', 'level': 1, 'color': 'none'}
with patch('shopdb.api.send_email') as email, \
patch('shopdb.api.send_webhook'):
check_supplies(FakeZabbix({ip: [waste]}))
assert not email.called
def test_routes_to_support_team_webhook(app, db, network_printer):
ip = network_printer
with app.app_context():
team = SupportTeam(teamname='Print Team',
webhookurl='https://teams.example/print')
db.session.add(team)
db.session.commit()
Setting.set('printers_alert_supportteamid', str(team.supportteamid))
with patch('shopdb.api.send_webhook') as webhook, \
patch('shopdb.api.send_email'):
check_supplies(FakeZabbix({ip: [_toner(3)]}))
assert webhook.called
assert webhook.call_args.kwargs['url'] == 'https://teams.example/print'