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.
232 lines
7.5 KiB
Python
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())
|