MEGAINFRA-WEBAPP/scripts/migrations/2026-09-11-backfill-entity-file-hashes.py
DEls 5274ac4981 fix(migration): separate download failures from missing files
Review found a transient 500 during download landed in the "no stored
file" bucket, so a broken run printed as a clean one and exited 0. Now
counted separately and exits non-zero.

Also: 404 is no longer swallowed on mutations (a PATCH against a deleted
record was counted as written), the target host and mode are printed, the
apply path reports a final written count, and filenames quote with
safe="".

Plan Task 1 Step 4 still carried the superseded copy-from-batch code
block under the rewritten prose, contradicting it. Replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:39:54 +02:00

221 lines
7.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Backfill file_hash on delivery_notes and purchase_orders by hashing the
file stored on the record itself.
PB_URL=... PB_ADMIN_EMAIL=... PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-11-backfill-entity-file-hashes.py [--apply]
DRY RUN BY DEFAULT.
Part of the ingestion restructure (see
docs/superpowers/specs/2026-09-11-ingestion-restructure-design.md).
Task 9 moves delivery-note dedup off `data_import_batches` and onto the DN
row; Task 13 does the same for POs. Both need a hash on the entity.
WHY WE HASH RATHER THAN COPY FROM THE BATCH ROW
-----------------------------------------------
The obvious shortcut is to copy `data_import_batches.file_hash` across via
`processed_record_id`. Production says no:
* 10 delivery-note batches have no file_hash at all - they predate
commit 78fa8da which added SHA-256 dedup at upload time. All 10 are
inside the 30-day dedup window, so copying leaves a live gap.
* One PO row (uyed9xso3hlfemv) was produced from two DIFFERENT files
with different hashes, via the dedup-by-PO-number branch. Copying
gives last-write-wins on an arbitrary one of the two.
Hashing the stored bytes has neither problem, and it does not depend on
the collection we are about to delete.
Idempotent: records that already carry a file_hash are skipped. Records
with no stored file are reported and skipped - never guessed.
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
APPLY = "--apply" in sys.argv
# collection -> the file field whose bytes are the record's identity
TARGETS = {
"delivery_notes": "slip_image",
"purchase_orders": "purchase_order_file",
}
def admin_auth(base_url):
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 api(base_url, token, method, path, body=None):
"""
A 404 is only soft for GET — "this collection/record is absent" is a
legitimate answer to a read. On a write it is not: a PATCH against a
since-deleted record returning None would be counted as a successful
write. Mutations raise.
"""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{base_url}{path}",
data=data,
method=method,
headers={"Authorization": token, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as resp:
txt = resp.read().decode()
return json.loads(txt) if txt else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
raise RuntimeError(
f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}"
) from e
def get_all(base_url, token, coll, fields):
out, page = [], 1
while True:
data = api(
base_url,
token,
"GET",
f"/api/collections/{coll}/records"
f"?perPage=500&page={page}&fields={fields}",
)
out.extend(data["items"])
if page >= data["totalPages"]:
return out
page += 1
def sha256_of_stored_file(base_url, token, coll, rec_id, filename):
# safe="" so a "/" in a stored filename escapes rather than forging a
# path segment. Both file fields are maxSelect:1 today, so the value is
# a bare filename — this is belt and braces.
#
# This unauthenticated-file assumption is load-bearing: both fields are
# `protected: false`, so the Authorization header below is ignored. If
# either is ever flipped to protected, PB requires a short-lived token
# from /api/files/token and this 403s.
url = (
f"{base_url}/api/files/{coll}/{rec_id}/"
f"{urllib.parse.quote(filename, safe='')}"
)
req = urllib.request.Request(url, headers={"Authorization": token})
with urllib.request.urlopen(req) as resp:
return hashlib.sha256(resp.read()).hexdigest()
def main():
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
# Say where we are writing. This script mutates production by default
# target, and a run whose output names no host is a run you cannot
# audit afterwards.
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
grand_write = grand_skip = grand_have = grand_err = 0
for coll, file_field in TARGETS.items():
rows = get_all(base_url, token, coll, f"id,file_hash,{file_field}")
writes, no_file, errors, already = [], [], [], 0
for r in rows:
if (r.get("file_hash") or "").strip():
already += 1
continue
fname = r.get(file_field) or ""
if not fname:
no_file.append(r["id"])
continue
try:
writes.append(
(r["id"], sha256_of_stored_file(
base_url, token, coll, r["id"], fname))
)
except Exception as e: # noqa: BLE001 - report, never guess
# Kept apart from `no_file` deliberately. "This record has
# no file" is a fact about the data; "the download blew up"
# is a fact about this run. Folding a transient 500 into
# the former makes a broken run read as a clean one.
errors.append(f"{r['id']} ({e})")
print(f"\n=== {coll} ===")
print(f" total : {len(rows)}")
print(f" already hashed : {already}")
print(f" to write : {len(writes)}")
print(f" no stored file : {len(no_file)}")
print(f" download errors : {len(errors)}")
for n in no_file[:20]:
print(f" SKIP (no file) {n}")
for n in errors[:20]:
print(f" ERROR {n}")
grand_write += len(writes)
grand_skip += len(no_file)
grand_err += len(errors)
grand_have += already
if APPLY:
for i, (rec_id, digest) in enumerate(writes, 1):
api(
base_url, token, "PATCH",
f"/api/collections/{coll}/records/{rec_id}",
{"file_hash": digest},
)
if i % 10 == 0:
print(f" wrote {i}/{len(writes)}")
print(f" wrote : {len(writes)}/{len(writes)}")
print(
f"\ntotals: to_write={grand_write} already={grand_have} "
f"no_file={grand_skip} errors={grand_err}"
)
if grand_err:
print(
f"\nFAILED: {grand_err} file(s) could not be downloaded. "
f"Nothing about this run is trustworthy — re-run it."
)
return 1
if not APPLY:
print("\nDRY RUN. Re-run with --apply to write.")
return 0
# Verify: nothing that has a file should be left without a hash.
left = 0
for coll, file_field in TARGETS.items():
for r in get_all(base_url, token, coll, f"id,file_hash,{file_field}"):
if (r.get(file_field) or "") and not (r.get("file_hash") or ""):
left += 1
print(f"records with a file but no hash after run: {left}")
return 0 if left == 0 else 1
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(f"ERROR: missing environment variable {e}", file=sys.stderr)
sys.exit(2)