printers: low-toner alerts with configurable thresholds + support-team routing
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
This commit is contained in:
@@ -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',
|
||||
|
||||
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Low-Toner Alerts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<p class="field-hint">
|
||||
A scheduled poll checks Zabbix toner levels. A warning email fires when
|
||||
a toner crosses to the warning threshold or below, a critical email at
|
||||
the critical threshold. Alerts fire once per crossing; a refill re-arms
|
||||
them.
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Warning threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_warning_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Critical threshold (% remaining)</label>
|
||||
<input v-model.number="values.printers_alert_critical_threshold"
|
||||
type="number" min="0" max="100" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-toner alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-toner alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printers_alert_email" type="text"
|
||||
class="form-control" placeholder="print-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert support team (Teams webhook)</label>
|
||||
<select v-model="values.printers_alert_supportteamid" class="form-control">
|
||||
<option value="">Site default webhook</option>
|
||||
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
||||
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Low-toner alerts post to this team's webhook (set on Settings >
|
||||
Support Teams). Empty uses the site-wide alert webhook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printers_alert_email',
|
||||
'printers_alert_userids',
|
||||
'printers_alert_roleids',
|
||||
'printers_alert_supportteamid',
|
||||
'printers_alert_warning_threshold',
|
||||
'printers_alert_critical_threshold'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printers_alert_email: '',
|
||||
printers_alert_userids: '',
|
||||
printers_alert_roleids: '',
|
||||
printers_alert_supportteamid: '',
|
||||
printers_alert_warning_threshold: 5,
|
||||
printers_alert_critical_threshold: 0
|
||||
})
|
||||
const supportTeams = ref([])
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printers' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
values.value.printers_alert_warning_threshold =
|
||||
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
|
||||
values.value.printers_alert_critical_threshold =
|
||||
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printers_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printers_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
const teamsResponse = await supportteamsApi.list()
|
||||
supportTeams.value = teamsResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printers_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.form-row { display: flex; gap: 1rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
|
||||
41
plugins/printers/models/supply_alert.py
Normal file
41
plugins/printers/models/supply_alert.py
Normal file
@@ -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'),
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
190
plugins/printers/services/supply_alerts.py
Normal file
190
plugins/printers/services/supply_alerts.py
Normal file
@@ -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'<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
|
||||
@@ -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."""
|
||||
|
||||
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
@@ -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'
|
||||
Reference in New Issue
Block a user