Files
shopdb-flask/plugins/printers/services/supply_alerts.py
cproudlock 75386d2f51 geenforce: resource-scope binding for fetch tokens (0.15.0)
A geenforce.fetch token can now be pinned to specific manifest scopes so a
fleet-wide key (a display's, delivered by DSC or baked into the image) is not a
skeleton key for the whole content store. NULL binding = unrestricted, so every
existing service token keeps working.

Core:
- ApiToken.resourcescopes column + resourcescopelist property (migration
  7d30_apitoken_resourcescopes; NULL = unrestricted).
- apitokens API create/update accept + persist an optional resourcescopes list
  (a resource-name allowlist; not permission-catalog names).
- New contract helper authorized_service_token(scope): same check as
  service_token_authorized but returns the ApiToken so a plugin can read its
  binding. Contract 0.14.0 -> 0.15.0; also export SupportTeam.

GE-Enforce enforcement:
- get_manifest: a bound token requesting a scope outside its allowlist -> 403.
- get_payload: a bound token may only pull a blob its own scope(s) reference
  (service.blob_referenced_by_scopes); anything else -> 404 (no hash probing).
- Decorator stashes the authorized token on g for the route to read.

Also fixes a pre-existing contract-surface violation: the printers/printedparts
alert helpers imported shopdb.core.models / shopdb.extensions directly; now
via shopdb.api (SupportTeam newly exported). Docs: GE-ENFORCE-DISPLAY.md
provisioning note, PLUGIN-HOOKS.md, CLAUDE.md.

9 new resource-binding tests; full suite 1131 passing.
2026-07-23 09:02:42 -04:00

191 lines
7.2 KiB
Python

"""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.api 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