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.
This commit is contained in:
parent
0ea0257edb
commit
9ea0a1f65e
2 changed files with 133 additions and 1 deletions
132
scripts/migrations/2026-08-28-vehicles-unit-type-optional.py
Normal file
132
scripts/migrations/2026-08-28-vehicles-unit-type-optional.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue