"""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'
' 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