147 lines
4.8 KiB
Python
Executable file
147 lines
4.8 KiB
Python
Executable file
#!/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-11-add-document-fields.py
|
|
|
|
Phase 1 of the ingestion restructure (see
|
|
docs/superpowers/specs/2026-09-11-ingestion-restructure-design.md).
|
|
|
|
Strictly additive. Adds:
|
|
delivery_notes.file_hash - SHA-256, so DN dedup can stop depending
|
|
on data_import_batches
|
|
purchase_orders.processing_status / attempts / last_error /
|
|
next_retry_at / last_attempt_at
|
|
- the queue fields that currently live on
|
|
the batch row, so the PO row can BE the
|
|
queue item
|
|
|
|
Nothing is made required here: existing rows have no values for these and
|
|
PocketBase rejects a required field that existing rows violate. Tightening
|
|
happens after the routes are writing the fields (Phase 3).
|
|
|
|
Idempotent: re-running skips fields that already exist.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ADDITIONS = {
|
|
"delivery_notes": [
|
|
{"name": "file_hash", "type": "text", "required": False, "max": 64},
|
|
],
|
|
"purchase_orders": [
|
|
# The PO row owns its file from upload onward (Task 13), so it
|
|
# owns the hash too — the dedup query has no pool to consult.
|
|
{"name": "file_hash", "type": "text", "required": False, "max": 64},
|
|
{
|
|
"name": "processing_status",
|
|
"type": "select",
|
|
"required": False,
|
|
"maxSelect": 1,
|
|
"values": [
|
|
"pending",
|
|
"processing",
|
|
"processed",
|
|
"failed",
|
|
"dead",
|
|
],
|
|
},
|
|
{"name": "attempts", "type": "number", "required": False},
|
|
{"name": "last_error", "type": "text", "required": False},
|
|
{"name": "next_retry_at", "type": "date", "required": False},
|
|
{"name": "last_attempt_at", "type": "date", "required": False},
|
|
],
|
|
}
|
|
|
|
|
|
def admin_auth(base_url: str) -> str:
|
|
body = json.dumps(
|
|
{
|
|
"identity": os.environ["PB_ADMIN_EMAIL"],
|
|
"password": os.environ["PB_ADMIN_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:
|
|
raise RuntimeError(
|
|
f"PATCH {name} failed: {e.code} {e.read().decode() if e.fp else ''}"
|
|
) from e
|
|
|
|
|
|
def main() -> int:
|
|
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
|
|
token = admin_auth(base_url)
|
|
|
|
for coll_name, new_fields in ADDITIONS.items():
|
|
coll = get_collection(base_url, token, coll_name)
|
|
if not coll:
|
|
print(f"ERROR: collection {coll_name!r} not found on {base_url}")
|
|
return 1
|
|
|
|
existing = coll.get("fields", [])
|
|
have = {f["name"] for f in existing}
|
|
to_add = [f for f in new_fields if f["name"] not in have]
|
|
|
|
if not to_add:
|
|
print(f"{coll_name}: no-op, all fields already present")
|
|
continue
|
|
|
|
print(f"{coll_name}: adding {[f['name'] for f in to_add]}")
|
|
patch_collection(base_url, token, coll_name, {"fields": existing + to_add})
|
|
|
|
print("\nFinal field lists:")
|
|
for coll_name in ADDITIONS:
|
|
final = get_collection(base_url, token, coll_name)
|
|
print(f" {coll_name}: {sorted(f['name'] for f in final.get('fields', []))}")
|
|
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)
|