Files
shopdb-flask/plugins/printers/services/zabbix_service.py
cproudlock f1b3b65532 Zabbix supply backend rebuild + data-driven model supplies
Rewrite the printer Zabbix integration (Bearer auth, host-by-IP, tag-based
supply lookup, ping) and replace the hardcoded toner table with a
modelsupplies table + CRUD + seed. Add mock Zabbix server, live test
harness, and the Playwright screenshot tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:35:02 -04:00

255 lines
8.8 KiB
Python

"""Zabbix service for real-time printer supply lookups.
Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
the live integration:
- Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
The old payload "auth" field is rejected by Zabbix 7.0.
- Hosts are named by IP address, so a host is located with
host.get filter {host: [ip]}, not by interface address.
- Supply levels come from items tagged component=supplies AND type=level,
not from a key_ substring search.
- Each level item carries a color tag used for display and part lookup.
Configuration (database Setting overrides env var):
ZABBIX_ENABLED: turn the integration on
ZABBIX_URL: base URL or full api_jsonrpc.php URL
ZABBIX_TOKEN: API token
"""
import logging
from typing import Dict, List, Optional
import requests
from flask import current_app
from shopdb.extensions import cache
logger = logging.getLogger(__name__)
class ZabbixService:
"""Zabbix API client for printer supply and ping lookups."""
CACHE_TTL = 300 # 5 min, matches the classic Application cache
REACHABLE_CHECK_TTL = 60
# quick fail for the reachability probe
REACHABLE_TIMEOUT = 1.0
# (connect, read) for real API calls; item.get is slow, give it room
API_TIMEOUT = (3.0, 5.0)
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
SUPPLY_TAGS = [
{"tag": "component", "value": "supplies", "operator": 0},
{"tag": "type", "value": "level", "operator": 0},
]
def __init__(self):
self._url = None
self._token = None
# -- configuration -------------------------------------------------------
@property
def isenabled(self) -> bool:
"""Whether the integration is switched on."""
from shopdb.core.models import Setting
db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None:
return bool(db_enabled)
return current_app.config.get('ZABBIX_ENABLED', False)
@property
def isconfigured(self) -> bool:
"""Enabled, and a URL plus token are present."""
if not self.isenabled:
return False
from shopdb.core.models import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token)
@property
def endpoint(self) -> str:
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
url = (self._url or "").rstrip("/")
if url.endswith("api_jsonrpc.php"):
return url
return f"{url}/api_jsonrpc.php"
@property
def isreachable(self) -> bool:
"""Cheap connectivity probe, cached for 60s."""
if not self.isconfigured:
return False
cache_key = 'zabbix_reachable'
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
# any non-5xx answer means the web tier responded, so the server is
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
# wants a POST with json-rpc content type); that still counts.
reachable = response.status_code < 500
except requests.RequestException:
reachable = False
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
logger.debug("Zabbix reachability: %s", reachable)
return reachable
# -- low level call ------------------------------------------------------
def _apicall(self, method: str, params: Dict) -> Optional[object]:
"""One JSON-RPC call. Returns the result, or None on any error."""
if not self.isconfigured:
return None
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
headers = {
"Content-Type": "application/json-rpc",
"Authorization": f"Bearer {self._token}",
}
try:
response = requests.post(
self.endpoint,
json=payload,
headers=headers,
timeout=self.API_TIMEOUT,
)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError) as exc:
logger.error("Zabbix %s call failed: %s", method, exc)
return None
if "error" in data:
logger.error("Zabbix %s error: %s", method, data["error"])
return None
return data.get("result")
# -- host / item lookups -------------------------------------------------
def gethostidbyip(self, ip: str) -> Optional[str]:
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
result = self._apicall("host.get", {
"output": ["hostid"],
"filter": {"host": [ip]},
})
if result:
return result[0].get("hostid")
return None
def _extract_color(self, item: Dict) -> str:
"""Pull and normalise the color tag, falling back to the item name."""
color = ""
for tag in item.get("tags", []) or []:
if tag.get("tag") == "color":
color = (tag.get("value") or "").lower()
break
if "black" in color:
color = "black"
elif color in ("grey", "gray"):
color = "gray"
if not color:
name = (item.get("name") or "").lower()
for candidate in ("cyan", "magenta", "yellow", "black"):
if candidate in name:
color = candidate
break
if not color and ("gray" in name or "grey" in name):
color = "gray"
return color
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
"""Current supply levels for a printer, by IP.
Returns a list of dicts {name, level, color, itemid, status, state},
or None if the host is not in Zabbix. Drum/maintenance items are kept
(callers decide what to surface); only disabled (status=1) and
unsupported (state=1) items are dropped, matching the classic report.
"""
hostid = self.gethostidbyip(ip)
if not hostid:
logger.debug("No Zabbix host for IP %s", ip)
return None
items = self._apicall("item.get", {
"output": ["itemid", "name", "lastvalue", "lastclock",
"units", "status", "state"],
"hostids": hostid,
"selectTags": "extend",
"evaltype": 0, # and
"tags": self.SUPPLY_TAGS,
"sortfield": "name",
"monitored": True,
})
if not items:
return []
supplies = []
for item in items:
# skip disabled or unsupported items
if str(item.get("status", "0")) != "0":
continue
if str(item.get("state", "0")) != "0":
continue
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
supplies.append({
"name": item.get("name", "Unknown"),
"level": level,
"color": self._extract_color(item),
"itemid": item.get("itemid"),
})
return supplies
def getpingstatus(self, ip: str) -> str:
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
hostid = self.gethostidbyip(ip)
if not hostid:
return "-1"
items = self._apicall("item.get", {
"output": ["lastvalue"],
"hostids": hostid,
"search": {"key_": "icmpping"},
})
if items:
return str(items[0].get("lastvalue", "-1"))
return "-1"
# -- caching wrappers ----------------------------------------------------
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
"""getsuppliesbyip with a 5-minute per-IP cache."""
cache_key = f"zabbix_supplies_{ip}"
result = cache.get(cache_key)
if result is not None:
return result
result = self.getsuppliesbyip(ip)
if result is not None:
cache.set(cache_key, result, timeout=self.CACHE_TTL)
return result
def clearcache(self, ip: str = None):
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
if ip:
cache.delete(f"zabbix_supplies_{ip}")
cache.delete("printers_low_supplies")
cache.delete("zabbix_reachable")