diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index 5a8796c..c89165a 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -20,6 +20,12 @@ export default [ component: () => import('./views/ZabbixSettings.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } }, + { + path: 'settings/printer-alerts', + name: 'printer-alerts', + component: () => import('./views/PrinterAlertsSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' } + }, { path: 'printers', name: 'printers', diff --git a/plugins/printers/frontend/views/PrinterAlertsSettings.vue b/plugins/printers/frontend/views/PrinterAlertsSettings.vue new file mode 100644 index 0000000..c4b6de9 --- /dev/null +++ b/plugins/printers/frontend/views/PrinterAlertsSettings.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/plugins/printers/migrations/versions/0002_printersupplyalerts.py b/plugins/printers/migrations/versions/0002_printersupplyalerts.py new file mode 100644 index 0000000..e563e2c --- /dev/null +++ b/plugins/printers/migrations/versions/0002_printersupplyalerts.py @@ -0,0 +1,47 @@ +"""printers: printersupplyalerts table (toner alert crossing state). + +Stores the last tier alerted per (printer, supply) so the poller fires once +on a downward crossing and re-arms after a refill. Idempotent create. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printers0002supplyalerts' +down_revision = 'printers0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'printersupplyalerts' in inspector.get_table_names(): + return + op.create_table( + 'printersupplyalerts', + sa.Column('printersupplyalertid', sa.Integer(), primary_key=True), + sa.Column('printerid', sa.Integer(), nullable=False), + sa.Column('supplykey', sa.String(length=64), nullable=False), + sa.Column('lasttier', sa.String(length=16), nullable=False, + server_default='ok'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.ForeignKeyConstraint(['printerid'], ['printers.printerid'], + ondelete='CASCADE'), + sa.UniqueConstraint('printerid', 'supplykey', + name='uq_printersupplyalert_printer_key'), + ) + op.create_index('ix_printersupplyalerts_printerid', + 'printersupplyalerts', ['printerid']) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'printersupplyalerts' not in inspector.get_table_names(): + return + op.drop_table('printersupplyalerts') diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index 0c64ef5..863a072 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -8,12 +8,14 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping SUPPLY_COLORS, CAPACITY_TIERS, ) +from .supply_alert import PrinterSupplyAlert # per-printer toner alert state __all__ = [ 'Printer', 'PrinterType', 'PrinterDriver', 'ModelSupply', + 'PrinterSupplyAlert', 'SUPPLY_TYPES', 'SUPPLY_COLORS', 'CAPACITY_TIERS', diff --git a/plugins/printers/models/supply_alert.py b/plugins/printers/models/supply_alert.py new file mode 100644 index 0000000..5f2ca82 --- /dev/null +++ b/plugins/printers/models/supply_alert.py @@ -0,0 +1,41 @@ +"""Per-printer supply alert state. + +The supply poller (flask printers check-toner-alerts) is stateless: it reads +live levels from Zabbix each run. To alert once per downward crossing (and +re-arm after a refill) it needs to remember the last tier it alerted for each +printer + supply. One row per (printerid, supplykey); supplykey is the toner +color (black/cyan/magenta/yellow) or the raw item name when color is unknown. +""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class PrinterSupplyAlert(BaseModel): + """Last-alerted tier for one printer supply. lasttier in ok/warning/critical.""" + + __tablename__ = 'printersupplyalerts' + + printersupplyalertid = db.Column(db.Integer, primary_key=True) + printerid = db.Column( + db.Integer, + db.ForeignKey('printers.printerid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + supplykey = db.Column( + db.String(64), + nullable=False, + comment='Toner color, or Zabbix item name when color is unknown', + ) + lasttier = db.Column( + db.String(16), + nullable=False, + default='ok', + comment='Last tier alerted: ok, warning, or critical', + ) + + __table_args__ = ( + db.UniqueConstraint('printerid', 'supplykey', + name='uq_printersupplyalert_printer_key'), + ) diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 7198092..153fa7a 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -9,9 +9,11 @@ from flask import Flask, Blueprint import click from shopdb.plugins.base import BasePlugin, PluginMeta -from shopdb.api import db, AssetType +from shopdb.api import db, AssetType, Setting -from .models import Printer, PrinterType, ModelSupply, PrinterDriver +from .models import ( + Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert +) from .api import printers_asset_bp from .services import ZabbixService @@ -76,6 +78,7 @@ class PrintersPlugin(BasePlugin): 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]: @@ -103,8 +106,41 @@ class PrintersPlugin(BasePlugin): with app.app_context(): self._ensure_asset_type() self._ensure_printer_types() + self._seed_settings() logger.info("Printers plugin installed") + def on_enable(self, app: Flask) -> None: + # Idempotent re-seed so settings added in later versions reach sites + # that installed earlier (enable runs on every upgrade cycle). + with app.app_context(): + self._seed_settings() + + def _seed_settings(self) -> None: + """Seed low-toner alert recipient settings (idempotent).""" + defaults = [ + ('printers_alert_email', '', 'string', + 'Comma-separated low-toner alert recipients; empty uses the ' + 'site alert_recipients'), + ('printers_alert_userids', '', 'string', + 'Comma-separated shopdb user ids whose account emails receive ' + 'low-toner alerts'), + ('printers_alert_roleids', '', 'string', + 'Comma-separated role ids; every active member of these roles ' + 'receives low-toner alerts'), + ('printers_alert_supportteamid', '', 'string', + 'Support team whose webhook receives low-toner alerts; empty ' + 'uses the site alert_webhook_url'), + ('printers_alert_warning_threshold', '5', 'integer', + 'Toner percent remaining at or below which a warning email fires'), + ('printers_alert_critical_threshold', '0', 'integer', + 'Toner percent remaining at or below which a critical email fires'), + ] + for key, value, valuetype, description in defaults: + if Setting.get(key) is None: + Setting.set(key, value, valuetype=valuetype, + category='printers', description=description) + db.session.commit() + def _ensure_asset_type(self) -> None: """Ensure printer asset type exists.""" existing = AssetType.query.filter_by(assettype='printer').first() @@ -151,6 +187,19 @@ class PrintersPlugin(BasePlugin): """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.""" @@ -159,6 +208,22 @@ class PrintersPlugin(BasePlugin): """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): diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 6a78fa9..10cde9a 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -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', ] diff --git a/plugins/printers/services/supply_alerts.py b/plugins/printers/services/supply_alerts.py new file mode 100644 index 0000000..0bb8ab6 --- /dev/null +++ b/plugins/printers/services/supply_alerts.py @@ -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'

View {name}

' if link else '' + html = (f'

{name} {color} toner is at ' + f'{remaining:.0f}% ({tier}).

{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 diff --git a/plugins/printers/services/supply_parts.py b/plugins/printers/services/supply_parts.py index 178b109..8ba7c27 100644 --- a/plugins/printers/services/supply_parts.py +++ b/plugins/printers/services/supply_parts.py @@ -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.""" diff --git a/tests/test_plugins/test_printers_supply_alerts.py b/tests/test_plugins/test_printers_supply_alerts.py new file mode 100644 index 0000000..0bc07aa --- /dev/null +++ b/tests/test_plugins/test_printers_supply_alerts.py @@ -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'