#!/usr/bin/env python3 """ One-off migration: backfill the `vehicle` relation on plant_hours_log rows where it was left empty. Why --- The plant detail page (`src/app/dashboard/plant/[vehicleId]/page.tsx`) and the plant list aggregation (`src/app/dashboard/plant/page.tsx`) both filter / group by the `vehicle` relation. Rows imported before the relation was enforced (or imported via paths that only set `vehicle_qr`) show up as "no data" on both surfaces even though the logs are clearly linked to a vehicle via `vehicle_qr`. This script resolves the QR against the vehicles collection and PATCHes the log row to set the relation. Idempotent ---------- - Rows that already have a `vehicle` relation are skipped. - Rows whose `vehicle_qr` doesn't match any vehicle are reported but not touched (an operator must resolve them manually). - The PATCH only writes the `vehicle` field, so re-running is safe. Run with `--dry-run` to print what would change without writing. Required: POCKETBASE_ADMIN_TOKEN (or email + password) in env / .env.local. """ import argparse import json import os import sys import time import urllib.error import urllib.request PB = os.environ.get( "NEXT_PUBLIC_POCKETBASE_URL", "https://pb.mega.infra.apphub.co.za" ) EMAIL = os.environ.get("POCKETBASE_ADMIN_EMAIL", "admin@mega.co.za") PASSWORD = os.environ.get("POCKETBASE_ADMIN_PASSWORD", "12345678") PER_PAGE = 500 def http(method, path, body=None, token=None): url = f"{PB}{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) if body is not None: req.add_header("Content-Type", "application/json") if token: req.add_header("Authorization", token) try: with urllib.request.urlopen(req) as r: txt = r.read().decode() return r.status, json.loads(txt) if txt else None except urllib.error.HTTPError as e: body = e.read().decode() try: parsed = json.loads(body) if body else {} except json.JSONDecodeError: parsed = {"raw": body} return e.code, parsed def login() -> str: token = os.environ.get("POCKETBASE_ADMIN_TOKEN") if token: code, _ = http("GET", "/api/collections/_superusers/auth-refresh", token=token) if code == 200: print("auth ok (existing token)") return token print("existing token rejected, falling back to email/password…") code, auth = http( "POST", "/api/collections/_superusers/auth-with-password", {"identity": EMAIL, "password": PASSWORD}, ) if code != 200: sys.exit(f"AUTH FAILED: {code} {auth}") print(f"auth ok, admin id={auth['record']['id']}") return auth["token"] def fetch_all(token: str, path: str) -> list: """PB's getFullList equivalent — paginates with perPage/page.""" items: list = [] page = 1 while True: sep = "&" if "?" in path else "?" code, batch = http( "GET", f"{path}{sep}perPage={PER_PAGE}&page={page}", token=token, ) if code != 200: sys.exit(f"GET {path} page {page} failed: {code} {batch}") if not batch or not batch.get("items"): break items.extend(batch["items"]) if len(batch["items"]) < PER_PAGE: break page += 1 return items def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) ap.add_argument( "--dry-run", action="store_true", help="Print what would change without PATCHing.", ) args = ap.parse_args() print(f"target: {PB}") print(f"mode: {'DRY RUN' if args.dry_run else 'LIVE'}") token = login() started = time.time() # 1. Fetch all vehicles and build a qr_code_id → id map (only those # with a QR set; rows with empty QR can't be resolved automatically). print("fetching vehicles…") vehicles = fetch_all(token, "/api/collections/vehicles/records") qr_to_id: dict = {} for v in vehicles: qr = (v.get("qr_code_id") or "").strip() if qr: # If a QR is duplicated across vehicles, prefer the first one # but log the conflict. if qr in qr_to_id and qr_to_id[qr] != v["id"]: print( f" WARN: qr_code_id={qr!r} appears on multiple vehicles " f"({qr_to_id[qr]} and {v['id']}) — using the first" ) else: qr_to_id[qr] = v["id"] print(f" {len(vehicles)} vehicles, {len(qr_to_id)} with a qr_code_id") # 2. Fetch all plant_hours_log rows. print("fetching plant_hours_log…") logs = fetch_all(token, "/api/collections/plant_hours_log/records") print(f" {len(logs)} log rows") # 3. Categorise. already_set = [] to_backfill = [] no_qr = [] qr_unknown = [] for log in logs: vehicle = log.get("vehicle") or "" qr = (log.get("vehicle_qr") or "").strip() if vehicle: already_set.append(log) continue if not qr: no_qr.append(log) continue target_id = qr_to_id.get(qr) if not target_id: qr_unknown.append((log, qr)) continue to_backfill.append((log, qr, target_id)) print() print("=== summary ===") print(f" already linked: {len(already_set)}") print(f" need backfill: {len(to_backfill)}") print(f" no qr, no vehicle: {len(no_qr)}") print(f" qr not found in fleet: {len(qr_unknown)}") if no_qr: print("\n rows with no vehicle and no vehicle_qr (first 10):") for log in no_qr[:10]: print(f" id={log['id']} start_date={log.get('start_date')!r}") if qr_unknown: print("\n rows whose vehicle_qr doesn't match any vehicle (first 10):") for log, qr in qr_unknown[:10]: print( f" id={log['id']} start_date={log.get('start_date')!r} " f"vehicle_qr={qr!r}" ) if not to_backfill: print("\nNothing to do.") return 0 print() if args.dry_run: print("DRY RUN — would PATCH the following rows:") for log, qr, target_id in to_backfill[:20]: print( f" {log['id']}: vehicle_qr={qr!r} → vehicle={target_id!r} " f"(start_date={log.get('start_date')!r})" ) if len(to_backfill) > 20: print(f" …and {len(to_backfill) - 20} more") return 0 print(f"PATCHing {len(to_backfill)} rows…") success = 0 failed: list = [] for i, (log, qr, target_id) in enumerate(to_backfill, start=1): code, resp = http( "PATCH", f"/api/collections/plant_hours_log/records/{log['id']}", {"vehicle": target_id}, token=token, ) if code == 200: success += 1 else: failed.append((log["id"], code, resp)) if i % 50 == 0 or i == len(to_backfill): print(f" {i}/{len(to_backfill)} processed ({success} ok)") print() print("=== result ===") print(f" backfilled: {success}") print(f" failed: {len(failed)}") if failed: print("\n failures (first 10):") for log_id, code, resp in failed[:10]: print(f" {log_id}: {code} {resp}") print(f"\nelapsed: {time.time() - started:.1f}s") return 0 if not failed else 1 if __name__ == "__main__": sys.exit(main())