Import the wilm agent store, WINC adoption work and batches CLI, with four fixes
This commit is contained in:
149
scripts/site_imports/approval.py
Normal file
149
scripts/site_imports/approval.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Reusable approval / promotion gate for site-import writers.
|
||||
|
||||
IMPORT-AGNOSTIC. This turns a dry-run plan into an "approval packet": a frozen
|
||||
summary of what an import WOULD do, plus a content hash of the exact plan it
|
||||
describes. A writer refuses to run unless it is handed a packet whose hash still
|
||||
matches the plan in front of it. That is the whole mechanism: a human approves a
|
||||
specific, frozen plan, and the writer can prove nothing drifted since.
|
||||
|
||||
The same packet shape serves every importer, and it is deliberately the seam the
|
||||
undo work will reuse: a writer records the packet's batch id alongside the
|
||||
journal it writes, so approval, write, and undo all reference one identity.
|
||||
|
||||
A packet is a plain dict (JSON-serialisable) so it can be printed, saved next to
|
||||
an export, diffed between runs, and shown in a UI without any of this code:
|
||||
|
||||
{
|
||||
'kind': caller-supplied label, e.g. 'winc-machine-adoption'
|
||||
'planhash': sha256 of the canonicalised plan rows
|
||||
'rowcount': number of rows in the plan
|
||||
'counts': caller-supplied summary dict (what the report shows)
|
||||
'needscreate': { group -> { name -> count } } # requires acknowledgment
|
||||
'unresolved': { group -> { value -> count } } # blocks approval
|
||||
'blocking': True if any unresolved entries exist
|
||||
}
|
||||
|
||||
The writer's contract, in order:
|
||||
1. rebuild the plan from the same inputs,
|
||||
2. build a packet from it,
|
||||
3. compare planhash to the approved packet - abort on mismatch,
|
||||
4. refuse if packet['blocking'],
|
||||
5. require explicit acknowledgment for every needscreate group,
|
||||
6. only then write, stamping packet['batchid'] onto the journal.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
from scripts.site_imports.resolver import tally, NEEDS_CREATE, UNRESOLVED
|
||||
|
||||
|
||||
def plan_hash(plan):
|
||||
"""Stable sha256 over the plan's rows.
|
||||
|
||||
Canonicalised by serialising each row as sorted-key JSON so the hash depends
|
||||
only on content, not on dict ordering or whitespace. Two runs that produce
|
||||
the same decisions produce the same hash; any changed decision changes it,
|
||||
which is exactly what the writer checks before it touches the database.
|
||||
"""
|
||||
hasher = hashlib.sha256()
|
||||
for row in plan:
|
||||
canonical = json.dumps(row, sort_keys=True, default=str,
|
||||
separators=(',', ':'))
|
||||
hasher.update(canonical.encode('utf-8'))
|
||||
hasher.update(b'\n')
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def build_packet(kind, plan, counts, groups):
|
||||
"""Build an approval packet from a plan.
|
||||
|
||||
kind a short label identifying the importer, stored on the packet.
|
||||
plan the list of per-row dicts the writer will act on.
|
||||
counts the caller's summary dict (shown to the human as-is).
|
||||
groups how to read each field's status/value columns out of a row:
|
||||
[ (group_name, status_column, value_column), ... ]
|
||||
e.g. ('businessunit', 'businessunit_status', 'raw_eq1'). The value
|
||||
column is what a human would act on - the raw export value for
|
||||
UNRESOLVED (so they know what to alias) and the canonical name for
|
||||
NEEDS_CREATE (so they know what would be created).
|
||||
|
||||
needscreate is keyed on the canonical name column when present; callers pass
|
||||
the *_name column for that via the fourth tuple element when it differs from
|
||||
the value column.
|
||||
"""
|
||||
needscreate = OrderedDict()
|
||||
unresolved = OrderedDict()
|
||||
for group in groups:
|
||||
group_name, status_column, value_column = group[0], group[1], group[2]
|
||||
name_column = group[3] if len(group) > 3 else value_column
|
||||
unresolved_hits = tally(plan, status_column, value_column, UNRESOLVED)
|
||||
needs_hits = tally(plan, status_column, name_column, NEEDS_CREATE)
|
||||
if unresolved_hits:
|
||||
unresolved[group_name] = unresolved_hits
|
||||
if needs_hits:
|
||||
needscreate[group_name] = needs_hits
|
||||
|
||||
return OrderedDict([
|
||||
('kind', kind),
|
||||
('batchid', str(uuid.uuid4())),
|
||||
('planhash', plan_hash(plan)),
|
||||
('rowcount', len(plan)),
|
||||
('counts', OrderedDict(counts)),
|
||||
('needscreate', needscreate),
|
||||
('unresolved', unresolved),
|
||||
('blocking', bool(unresolved)),
|
||||
])
|
||||
|
||||
|
||||
def packet_matches_plan(packet, plan):
|
||||
"""True if the approved packet still describes this exact plan."""
|
||||
return packet.get('planhash') == plan_hash(plan)
|
||||
|
||||
|
||||
def format_packet(packet):
|
||||
"""Human-readable rendering of a packet for the console or a UI panel."""
|
||||
lines = []
|
||||
lines.append('APPROVAL PACKET: %s' % packet.get('kind', '?'))
|
||||
lines.append(' batch id : %s' % packet.get('batchid', '?'))
|
||||
lines.append(' plan hash: %s' % packet.get('planhash', '?'))
|
||||
lines.append(' rows : %d' % packet.get('rowcount', 0))
|
||||
|
||||
needscreate = packet.get('needscreate') or {}
|
||||
if needscreate:
|
||||
for group, items in needscreate.items():
|
||||
total = sum(items.values())
|
||||
lines.append('')
|
||||
lines.append(' WARNING: this import would CREATE %d %s(s). '
|
||||
'Confirm these are correct; you can edit them later '
|
||||
'in Settings:' % (len(items), group))
|
||||
for name, count in items.items():
|
||||
lines.append(' + %-28s (%d rows)' % (name, count))
|
||||
lines.append(' total new %s rows: %d' % (group, total))
|
||||
|
||||
unresolved = packet.get('unresolved') or {}
|
||||
if unresolved:
|
||||
for group, items in unresolved.items():
|
||||
lines.append('')
|
||||
lines.append(' BLOCKING: %d unresolved %s value(s) - add an alias '
|
||||
'for each before importing:' % (len(items), group))
|
||||
for value, count in items.items():
|
||||
lines.append(' ? %-28s (%d rows)' % (value, count))
|
||||
|
||||
lines.append('')
|
||||
lines.append(' status : %s' % (
|
||||
'BLOCKED (resolve the values above)' if packet.get('blocking')
|
||||
else 'READY for acknowledgment'))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def save_packet(packet, path):
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(packet, handle, indent=2)
|
||||
|
||||
|
||||
def load_packet(path):
|
||||
with open(path, encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
Reference in New Issue
Block a user