diff --git a/scripts/migrations/2026-08-28-vehicles-unit-type-optional.py b/scripts/migrations/2026-08-28-vehicles-unit-type-optional.py new file mode 100644 index 0000000..834503c --- /dev/null +++ b/scripts/migrations/2026-08-28-vehicles-unit-type-optional.py @@ -0,0 +1,132 @@ +#!/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-08-28-vehicles-unit-type-optional.py + +Flips `vehicles.unit_type` from `required=true` to `required=false`. + +WHY: 53 existing vehicles have `unit_type = ''` because they predate +the schema field. PB validates the WHOLE record on any PATCH, so as +long as unit_type is required, no record with an empty value can be +updated (Dismiss, inline-edit, anything that calls .update()). Making +the field optional unblocks all 53 immediately without data changes. + +The `default = "fuel"` on the field definition still applies to NEW +inserts. Existing rows keep their empty value; they just don't fail +validation anymore. + +Idempotent: re-running is safe; if the field is already non-required +the script exits without PATCHing. +""" +import json +import os +import sys +import urllib.error +import urllib.request + + +COLLECTION = "vehicles" +FIELD_NAME = "unit_type" + + +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 + + fields = coll.get("fields", []) + target = next((f for f in fields if f.get("name") == FIELD_NAME), None) + if target is None: + print(f"ERROR: field {FIELD_NAME!r} not found on {COLLECTION}") + return 1 + + if not target.get("required", False): + print( + f"No-op: {COLLECTION}.{FIELD_NAME} is already required=false. " + f"Nothing to do." + ) + return 0 + + target["required"] = False + print( + f"Setting {COLLECTION}.{FIELD_NAME}.required = false " + f"(was: true)" + ) + patch_collection(base_url, token, COLLECTION, {"fields": fields}) + + final = get_collection(base_url, token, COLLECTION) + final_target = next( + f for f in final.get("fields", []) if f.get("name") == FIELD_NAME + ) + print( + f"\nFinal state of {COLLECTION}.{FIELD_NAME}: " + f"required={final_target.get('required')}, " + f"default={final_target.get('default')!r}" + ) + print("\nMigration complete.") + 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) \ No newline at end of file diff --git a/scripts/pb-bootstrap-schema.py b/scripts/pb-bootstrap-schema.py index 1693aec..a12d0e2 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -188,7 +188,7 @@ DEFS["vehicles"] = { field("description", "text", required=False), field("standard_work_hours", "text", required=False), field("fuel_limits", "text", required=False), - field("unit_type", "select", required=True, + field("unit_type", "select", required=False, values=["fuel", "plant"], maxSelect=1, default="fuel"), field("attention_dismissed_at", "date", required=False), ],