133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
One-shot PocketBase migration applied via:
|
||
|
|
|
||
|
|
PB_URL=https://pb.mega.infra.apphub.co.za \\
|
||
|
|
PB_ADMIN_EMAIL=admin@mega.co.za \\
|
||
|
|
PB_ADMIN_PASSWORD=... \\
|
||
|
|
python3 scripts/migrations/2026-09-22-add-fuel-log-coordinates.py
|
||
|
|
|
||
|
|
Adds `latitude`, `longitude`, `location_accuracy_m` to FUEL_LOGS so the
|
||
|
|
MEGA Clock Fleet scanner can record the GPS fix at the moment of scan.
|
||
|
|
|
||
|
|
The spreadsheet import path doesn't have a GPS context, so
|
||
|
|
desktop-imported rows stay null — same posture as `latest_consumption`,
|
||
|
|
which this migration sits next to in the collection's field list.
|
||
|
|
|
||
|
|
Range validation (lat ∈ [-90, 90], lng ∈ [-180, 180], accuracy ≥ 0)
|
||
|
|
is the route's responsibility, not PB's — the field stays permissive
|
||
|
|
so a future capture device (a hand-held GPS, a survey tool) can write
|
||
|
|
whatever it reports and a separate cleanup job decides which fixes
|
||
|
|
are usable.
|
||
|
|
|
||
|
|
Idempotent: re-running is safe; existing fields are skipped.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
|
||
|
|
|
||
|
|
COLLECTION = "fuel_logs"
|
||
|
|
|
||
|
|
NEW_FIELDS = [
|
||
|
|
{
|
||
|
|
"name": "latitude",
|
||
|
|
"type": "number",
|
||
|
|
"required": False,
|
||
|
|
# No min/max / noDecimal — mirrors `litres` and `odo_reading`.
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "longitude",
|
||
|
|
"type": "number",
|
||
|
|
"required": False,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "location_accuracy_m",
|
||
|
|
"type": "number",
|
||
|
|
"required": False,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def admin_auth(base_url: str) -> str:
|
||
|
|
email = os.environ["PB_ADMIN_EMAIL"]
|
||
|
|
password = os.environ["PB_ADMIN_PASSWORD"]
|
||
|
|
body = json.dumps({"identity": email, "password": 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 get_collection(base_url: str, token: str, name: str):
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{base_url}/api/collections/{name}",
|
||
|
|
headers={"Authorization": token},
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req) as resp:
|
||
|
|
return json.loads(resp.read())
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
if e.code == 404:
|
||
|
|
return None
|
||
|
|
raise
|
||
|
|
|
||
|
|
|
||
|
|
def patch_collection(base_url: str, token: str, name: str, body: dict):
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{base_url}/api/collections/{name}",
|
||
|
|
data=json.dumps(body).encode(),
|
||
|
|
headers={
|
||
|
|
"Authorization": token,
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
},
|
||
|
|
method="PATCH",
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req) as resp:
|
||
|
|
return json.loads(resp.read())
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
body_txt = e.read().decode() if e.fp else ""
|
||
|
|
raise RuntimeError(f"PATCH {name} failed: {e.code} {body_txt}") from e
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
|
||
|
|
token = admin_auth(base_url)
|
||
|
|
coll = get_collection(base_url, token, COLLECTION)
|
||
|
|
if not coll:
|
||
|
|
print(f"ERROR: collection {COLLECTION!r} not found on {base_url}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
existing_fields = coll.get("fields", [])
|
||
|
|
existing_field_names = {f["name"] for f in existing_fields}
|
||
|
|
new_fields = [f for f in NEW_FIELDS if f["name"] not in existing_field_names]
|
||
|
|
|
||
|
|
if new_fields:
|
||
|
|
merged_fields = existing_fields + new_fields
|
||
|
|
print(f"Adding {len(new_fields)} new field(s) to {COLLECTION}: "
|
||
|
|
f"{[f['name'] for f in new_fields]}")
|
||
|
|
patch_collection(base_url, token, COLLECTION, {"fields": merged_fields})
|
||
|
|
else:
|
||
|
|
print(f"No-op: all NEW_FIELDS already present on {COLLECTION}.")
|
||
|
|
|
||
|
|
final = get_collection(base_url, token, COLLECTION)
|
||
|
|
final_field_names = sorted(f["name"] for f in final.get("fields", []))
|
||
|
|
print(f"\nFinal field list on {COLLECTION}: {final_field_names}")
|
||
|
|
print("\nMigration complete.")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
try:
|
||
|
|
sys.exit(main())
|
||
|
|
except KeyError as e:
|
||
|
|
print(f"ERROR: missing environment variable {e}. "
|
||
|
|
"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.", file=sys.stderr)
|
||
|
|
sys.exit(2)
|