MEGAINFRA-WEBAPP/scripts/migrations/2026-09-14-require-site.py
DEls f007b5e8fb feat(schema): require site on every ingested collection
Tightened site from optional to required on fuel_logs, timesheet_entries,
delivery_notes and purchase_orders now that every route writes it and
every historical row has been backfilled. plant_hours_log was already
required (matches docs/data-model.md's documented invariant, confirmed
against production). timesheets (the document collection) was already
required from Task 6 -- a no-op here, not a regression.

fuel_sheets.site and plant_hours_sheets.site deliberately stay optional:
those sheets can legitimately span multiple sites.

Verified: creating a fuel_logs row with no site now gets a hard 400
(validation_required) from PocketBase itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:32:48 +02:00

221 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""
Task 16 of the ingestion restructure: tighten `site` from optional to
required on the collections where every row now has one written at
create time (Tasks 11-13 made the routes write it; the backfills in
Tasks 2-3-era migrations closed the historical gap).
PB_URL=https://pb.mega.infra.apphub.co.za \\
PB_ADMIN_EMAIL=admin@mega.co.za \\
PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-14-require-site.py [--apply]
DRY RUN BY DEFAULT.
Before running this, Step 1 of Task 16 (the plan's verification query)
must show 0 rows with an empty `site` on every target collection. This
script does not re-run that check - it assumes it has already been done
and trusts the schema, not the data, from here.
Targets (site relation -> required=True, minSelect=1):
fuel_logs - 147 rows were orphaned before the backfill; never again
timesheet_entries - the future per-user allowlist filter reads this directly
delivery_notes - already always set at upload
purchase_orders - required at upload from Task 13
timesheets - a wage sheet (the document, not timesheet_entries)
belongs to one site
Assert-only, no write attempted:
plant_hours_log - the plan claims this is already minSelect=1 in
production. We read the live schema and fail loudly
if that claim is wrong, rather than trusting it.
Explicitly NOT touched by this script (do not add them):
fuel_sheets.site, plant_hours_sheets.site - these documents legitimately
span multiple sites (the fuel importer resolves site per row from a
spreadsheet column). Forcing one site onto the document would make the
uploader assert something false.
Idempotent: a collection whose site field already has required=True and
minSelect=1 is reported as a no-op, not re-patched.
"""
import json
import os
import sys
import urllib.error
import urllib.request
APPLY = "--apply" in sys.argv
TIGHTEN_TARGETS = [
"fuel_logs",
"timesheet_entries",
"delivery_notes",
"purchase_orders",
"timesheets",
]
ASSERT_ONLY_TARGETS = [
"plant_hours_log",
]
DO_NOT_TOUCH = ["fuel_sheets", "plant_hours_sheets"]
def admin_auth(base_url: str) -> str:
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 resp:
return json.loads(resp.read())["token"]
def api(base_url, token, method, path, body=None):
"""
A 404 is only soft for GET - "this collection is absent" is a
legitimate answer to a read. On a write it is not: a PATCH that
404s returning None would be read downstream as a successful patch.
"""
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 resp:
txt = resp.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 find_site_field(collection):
return next((f for f in collection.get("fields", []) if f["name"] == "site"), None)
def main() -> int:
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
# Say where we are writing. The default target is production, and a
# run whose output names no host is a run you cannot audit later.
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
print(f"\nnot touched (must stay optional): {', '.join(DO_NOT_TOUCH)}")
# --- Assert-only collections: plant_hours_log must already be tight.
print("\n--- assert-only ---")
for name in ASSERT_ONLY_TARGETS:
coll = api(base_url, token, "GET", f"/api/collections/{name}")
if coll is None:
print(f"ERROR: collection {name!r} not found on {base_url}")
return 1
site = find_site_field(coll)
if site is None:
print(f"ERROR: {name} has no `site` field")
return 1
if site.get("required") is True and site.get("minSelect") == 1:
print(
f"{name}: OK, site already required=True minSelect=1 "
f"(matches the plan's claim, not changing)"
)
else:
print(
f"DISCREPANCY: {name}.site is required={site.get('required')!r} "
f"minSelect={site.get('minSelect')!r}, expected required=True "
f"minSelect=1. The plan's documented invariant does not hold "
f"in production. Not modifying this collection - flagging only."
)
return 1
# --- Tighten targets: patch if needed, no-op if already tight.
print("\n--- tighten targets ---")
to_patch = [] # (name, collection, updated_fields)
for name in TIGHTEN_TARGETS:
coll = api(base_url, token, "GET", f"/api/collections/{name}")
if coll is None:
print(f"ERROR: collection {name!r} not found on {base_url}")
return 1
site = find_site_field(coll)
if site is None:
print(f"ERROR: {name} has no `site` field")
return 1
already_tight = site.get("required") is True and site.get("minSelect") == 1
if already_tight:
print(f"{name}: no-op, site already required=True minSelect=1")
continue
print(
f"{name}: would change site "
f"required={site.get('required')!r}->True "
f"minSelect={site.get('minSelect')!r}->1"
)
updated_fields = []
for f in coll["fields"]:
if f["name"] == "site":
f = dict(f)
f["required"] = True
f["minSelect"] = 1
updated_fields.append(f)
to_patch.append((name, coll, updated_fields))
if not APPLY:
if to_patch:
print(
f"\nDRY RUN. Would patch {len(to_patch)} collection(s): "
f"{', '.join(n for n, _, _ in to_patch)}. Re-run with --apply to write."
)
else:
print("\nDRY RUN. No collections need changes - all already tight.")
return 0
for name, coll, updated_fields in to_patch:
print(f"\nPATCH {name}: setting site.required=True, site.minSelect=1")
patched = api(
base_url, token, "PATCH", f"/api/collections/{name}",
{"fields": updated_fields},
)
if patched is None:
print(f" FAILED: PATCH {name} returned no collection")
return 1
after = api(base_url, token, "GET", f"/api/collections/{name}")
site = find_site_field(after) if after else None
if site is None or site.get("required") is not True or site.get("minSelect") != 1:
print(
f" FAILED: post-patch read shows site required="
f"{site.get('required') if site else None!r} "
f"minSelect={site.get('minSelect') if site else None!r}"
)
return 1
print(f" OK {name}.site is now required=True minSelect=1")
print(f"\napplied to {len(to_patch)} collection(s).")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(
f"ERROR: missing environment variable {e}. "
f"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.",
file=sys.stderr,
)
sys.exit(2)