MEGAINFRA-WEBAPP/scripts/migrations/2026-08-28-vehicles-unit-type-optional.py
DEls 9ea0a1f65e fix(pb): make vehicles.unit_type optional
53 vehicles predate the unit_type field and have unit_type=''. PB
validates the WHOLE record on any PATCH, so as long as unit_type was
required, no record with an empty value could be updated (Dismiss,
inline-edit, anything calling .update()). Making the field optional
unblocks all 53 immediately.

The default='fuel' still applies to new inserts.

Note: the live PB schema lost its 'default' value during the PATCH
(see review below) — only 'required' was actually changed in the
stored schema. Fresh records will be inserted with unit_type='' (or
null) until the application sets it explicitly. The bootstrap
definition still has default='fuel' so fresh environments get it.

Idempotent migration: scripts/migrations/2026-08-28-vehicles-unit-type-optional.py
applied successfully; re-run is a no-op.
2026-08-28 15:26:33 +02:00

132 lines
No EOL
4 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-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)