MEGAINFRA-WEBAPP/scripts/migrate-plant-hours-backfill-vehicle.py
DEls 602c62ba06 refactor(plant): enhance PlantPage and PlantView for fuel log integration
Updated PlantPage to aggregate vehicle data from fuel_logs, including hours worked and total value calculations based on odo readings. Adjusted PlantView to support navigation to vehicle details and improved log display with new data structure. Removed legacy code related to plant_hours_log and refined data handling for better performance and clarity.
2026-09-02 12:36:10 +02:00

232 lines
7.5 KiB
Python

#!/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())