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
166 lines
5.8 KiB
Python
166 lines
5.8 KiB
Python
"""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'
|