#!/usr/bin/env python3 """Find active paying Gaiia accounts that have no CPE equipment assigned. Usage: GAIIA_KEY=... python3 scripts/missing_cpe.py GAIIA_KEY=... python3 scripts/missing_cpe.py --json """ from __future__ import annotations import argparse import json import os import re import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(REPO, "gaiia", "src")) from gaiia import GaiiaClient # noqa: E402 PAYING = {"ACTIVE", "TRIAL"} _INDOOR_PATTERNS = ("AC1200", "AC1000", "Vilo", "hc220", "GWN") def norm_mac(s: str) -> str: return re.sub(r"[^0-9a-f]", "", (s or "").lower()) def paginate(client, q_with, q_first, path): cur = None while True: d = client.query(q_first) if cur is None else client.query(q_with, {"after": cur}) node = d for part in path.split("."): node = node[part] for n in node.get("nodes") or []: yield n pi = node.get("pageInfo") or {} if not pi.get("hasNextPage"): return cur = pi.get("endCursor") if not cur: return def fetch_data(): """Return (paying_accounts, account_ids_with_cpe).""" accounts: dict[str, dict] = {} q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){ nodes{ id readableId displayName status{ name } billingSubscriptions(first:50){ nodes{ status } } } pageInfo{ hasNextPage endCursor } } }""" q_acct_first = """query{ accounts(first:100){ nodes{ id readableId displayName status{ name } billingSubscriptions(first:50){ nodes{ status } } } pageInfo{ hasNextPage endCursor } } }""" q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){ nodes{ id model{ name } fields{ nodes{ data modelField{ name } } } assignation{ assignee{ __typename ... on Account{ id } } } } pageInfo{ hasNextPage endCursor } } }""" q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "") with GaiiaClient(timezone="America/Chicago") as g: for a in paginate(g, q_acct_with, q_acct_first, "accounts"): subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])] paying = any(s in PAYING for s in subs) active = a["status"]["name"] == "Active" if paying and active: accounts[a["id"]] = { "id": a["id"], "readable": a["readableId"], "name": a["displayName"], } accts_with_cpe: set[str] = set() for it in paginate(g, q_inv_with, q_inv_first, "inventoryItems"): model = (it.get("model") or {}).get("name", "") if any(p.lower() in model.lower() for p in _INDOOR_PATTERNS): continue assn = it.get("assignation") or {} assignee = assn.get("assignee") or {} if assignee.get("__typename") == "Account": accts_with_cpe.add(assignee["id"]) return accounts, accts_with_cpe def main(): ap = argparse.ArgumentParser() ap.add_argument("--json", action="store_true") args = ap.parse_args() if not os.environ.get("GAIIA_KEY"): sys.exit("GAIIA_KEY not set") print("Fetching Gaiia data...", file=sys.stderr) accounts, accts_with_cpe = fetch_data() missing = {aid: a for aid, a in accounts.items() if aid not in accts_with_cpe} print(f" {len(accounts)} paying accounts, " f"{len(accts_with_cpe)} have CPEs, " f"{len(missing)} missing CPEs", file=sys.stderr) if args.json: out = [{"readable": a["readable"], "name": a["name"], "id": a["id"]} for a in sorted(missing.values(), key=lambda a: a["name"].lower())] json.dump(out, sys.stdout, indent=2) return print(f"\n{len(missing)} paying accounts without CPE equipment:\n") for a in sorted(missing.values(), key=lambda a: a["name"].lower()): print(f" #{a['readable']:6} {a['name']}") if __name__ == "__main__": main()