MEGAINFRA-WEBAPP/scripts/migrations/2026-09-16-add-suppliers-schema.py

153 lines
No EOL
5.8 KiB
Python

#!/usr/bin/env python3
"""
Phase 1 of the suppliers feature (see
docs/superpowers/specs/2026-09-11-suppliers-design.md).
PB_URL=https://pb.mega.infra.apphub.co.za \\
PB_ADMIN_EMAIL=admin@mega.co.za \\
PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-16-add-suppliers-schema.py [--apply]
DRY RUN BY DEFAULT.
Creates:
suppliers - name (text, required). No site relation -- a
supplier is not site-scoped (the same supplier
can deliver to multiple sites).
Adds:
purchase_orders.supplier_ref - relation -> suppliers, optional,
maxSelect=1, cascadeDelete=false.
Optional because it is populated by a
later migration (Task 4) and by OCR
matching (Task 5) -- not every PO has
one on day one.
Does NOT touch purchase_orders.supplier (the existing free-text field).
It stays exactly as it is, forever -- see spec section 2.2 for why.
Idempotent: a field/collection that already exists is skipped, not
recreated.
"""
import json
import os
import sys
import urllib.error
import urllib.request
APPLY = "--apply" in sys.argv
def admin_auth(base_url):
body = json.dumps({
"identity": os.environ["PB_ADMIN_EMAIL"],
"password": os.environ["PB_ADMIN_PASSWORD"],
}).encode()
req = urllib.request.Request(
f"{base_url}/api/collections/_superusers/auth-with-password",
data=body, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["token"]
def api(base_url, token, method, path, body=None):
"""404 softens to None only on GET. A mutation that 404s must raise --
silently treating it as success would hide a real failure."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{base_url}{path}", data=data, method=method,
headers={"Authorization": token, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
txt = r.read().decode()
return json.loads(txt) if txt else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
raise RuntimeError(
f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}"
) from e
def main():
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
# --- 1. suppliers collection ---
existing = api(base_url, token, "GET", "/api/collections/suppliers")
if existing is not None:
print("suppliers: already exists, skipping create")
suppliers_id = existing["id"]
else:
print("suppliers: would create (name: text, required)")
if APPLY:
created = api(base_url, token, "POST", "/api/collections", {
"name": "suppliers",
"type": "base",
"fields": [
{"name": "name", "type": "text", "required": True, "min": 1, "max": 200},
],
"listRule": "@request.auth.id != ''",
"viewRule": "@request.auth.id != ''",
"createRule": "@request.auth.id != ''",
"updateRule": None,
"deleteRule": None,
})
suppliers_id = created["id"]
print(f" created id={suppliers_id}")
else:
suppliers_id = None # unknown until applied; fine for dry run
# --- 2. purchase_orders.supplier_ref ---
po = api(base_url, token, "GET", "/api/collections/purchase_orders")
if po is None:
print("ABORT: purchase_orders collection not found")
return 1
has_field = any(f["name"] == "supplier_ref" for f in po["fields"])
if has_field:
print("purchase_orders.supplier_ref: already exists, skipping")
else:
print("purchase_orders.supplier_ref: would add (relation -> suppliers, optional)")
if APPLY:
if suppliers_id is None:
# We just created suppliers in this same run; re-fetch its id.
suppliers_id = api(base_url, token, "GET", "/api/collections/suppliers")["id"]
new_field = {
"name": "supplier_ref",
"type": "relation",
"required": False,
"collectionId": suppliers_id,
"maxSelect": 1,
"cascadeDelete": False,
}
api(base_url, token, "PATCH", f"/api/collections/{po['id']}",
{"fields": po["fields"] + [new_field]})
print(" added")
if not APPLY:
print("\nDRY RUN. Re-run with --apply to write.")
return 0
# --- verify ---
back = api(base_url, token, "GET", "/api/collections/suppliers")
assert back is not None, "suppliers collection missing after apply"
names = [f["name"] for f in back["fields"]]
assert "name" in names, f"suppliers missing 'name' field: {names}"
po_back = api(base_url, token, "GET", "/api/collections/purchase_orders")
po_names = [f["name"] for f in po_back["fields"]]
assert "supplier_ref" in po_names, f"purchase_orders missing supplier_ref: {po_names}"
assert "supplier" in po_names, "purchase_orders.supplier was removed -- it must stay"
print("\nOK. suppliers exists with 'name'; purchase_orders has both "
"'supplier' (unchanged) and 'supplier_ref' (new).")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(f"ERROR: missing environment variable {e}", file=sys.stderr)
sys.exit(2)