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.
191 lines
7.2 KiB
Python
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
|