"""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 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 import time from typing import Dict, List, Optional import requests from flask import current_app from shopdb.api 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.api 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.api 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 # Per item, per call. Zabbix applies `limit` to the WHOLE result set, not # per item, so the budget is multiplied by the number of items asked for. HISTORY_PER_ITEM = 2000 def gethistory(self, itemids, days=90, peritem=None): """Raw level history for supply items: {itemid: [(clock, value), ...]}. Ordered oldest-first, which is what the analysis expects. Two things about the Zabbix API make the obvious call return the wrong rows, and both bit us: * `limit` caps the whole answer, not each item. A four-cartridge printer polled every five minutes writes over 100k rows in 90 days. * combined with `sortorder: ASC`, the cap keeps the OLDEST rows - so a "90 day" request came back holding the first few days of the window and nothing since. Every rate was computed from history a quarter of a year stale. So: sort DESC to keep the NEWEST rows, scale the budget by item count, and hand back ascending. history=3 is the unsigned-integer table, where a percent-remaining item lands; a site that types the item as float needs history=0. Items can be split across the two, so both are asked and the results merged - an earlier version stopped as soon as either answered, which silently lost every float-typed supply on a printer that also had an integer one. Returns {} when Zabbix is off or unreachable, so callers degrade to "no estimate" instead of failing. """ if not itemids: return {} itemids = [str(i) for i in itemids] peritem = peritem or self.HISTORY_PER_ITEM timefrom = int(time.time()) - days * 86400 collected = {} for historytype in (3, 0): outstanding = [i for i in itemids if i not in collected] if not outstanding: break rows = self._apicall("history.get", { "output": "extend", "history": historytype, "itemids": outstanding, "time_from": timefrom, "sortfield": "clock", "sortorder": "DESC", "limit": peritem * len(outstanding), }) or [] for row in rows: collected.setdefault(str(row.get("itemid")), []).append( (row.get("clock"), row.get("value"))) for itemid, points in collected.items(): points.reverse() # DESC came back; analysis wants ASC return collected # Trends are hourly aggregates and are kept far longer than raw history. # 90 days of raw 5-minute readings is 26k rows an item; the same window in # trends is 2160. For a burn rate over months that is the right table - # nothing is gained by fitting a line through every individual poll. TRENDS_MIN_DAYS = 7 TRENDS_PER_ITEM = 3000 def gettrends(self, itemids, days=90, peritem=None): """Hourly level averages: {itemid: [(clock, value), ...]}, oldest first. Same `limit` caveat as gethistory - it caps the whole answer, so the budget is scaled and the sort is DESC. Returns {} if trends are not kept (a site can set the trend period to 0), which is a real configuration, not an error. Callers fall back to raw history. """ if not itemids: return {} itemids = [str(i) for i in itemids] peritem = peritem or self.TRENDS_PER_ITEM timefrom = int(time.time()) - days * 86400 rows = self._apicall("trend.get", { "output": ["itemid", "clock", "value_avg"], "itemids": itemids, "time_from": timefrom, "sortfield": "clock", "sortorder": "DESC", "limit": peritem * len(itemids), }) or [] collected = {} for row in rows: collected.setdefault(str(row.get("itemid")), []).append( (row.get("clock"), row.get("value_avg"))) for itemid, points in collected.items(): points.reverse() return collected def getlevelhistory(self, itemids, days=90): """Level history for a forecast, from whichever table serves it best. Trends for a long window, raw history for a short one, and raw history for any item the trend table has nothing for. """ if not itemids: return {} if days < self.TRENDS_MIN_DAYS: return self.gethistory(itemids, days=days) collected = self.gettrends(itemids, days=days) missing = [i for i in (str(i) for i in itemids) if not collected.get(i)] if missing: collected.update(self.gethistory(missing, days=days)) return collected 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")