Files
shopdb-flask/scripts/site_imports/approval.py

191 lines
8.3 KiB
Python

"""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, creatable=None):
"""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).
creatable
the group names the caller's writer can actually create, or None
for "no opinion". A needs-create group outside this set is reported
under `uncreatable` and makes the packet blocking, because a plan
that promises rows nothing will create is as unrunnable as one with
unresolved values.
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
# A needs-create group the writer cannot actually create BLOCKS the plan,
# the same as an unresolved value. It is the same failure either way: the
# import cannot do what the preview says it would.
#
# It has to be decided HERE rather than at write time. The writer's own
# refusal sits behind the acknowledgement check, and the UI only ever
# acknowledges the groups it renders a control for - so a plan needing an
# uncreatable group reached Commit looking clean, and died on a message
# about acknowledging something the screen had no checkbox for. Blocking at
# plan time puts it in the preview, where the admin can act on it.
#
# creatable=None means "no opinion" - the CLI callers that do not pass it
# keep the old behaviour.
uncreatable = OrderedDict()
if creatable is not None:
allowed = set(creatable)
for group_name, hits in needscreate.items():
if group_name not in allowed:
uncreatable[group_name] = hits
return OrderedDict([
('kind', kind),
('batchid', str(uuid.uuid4())),
('planhash', plan_hash(plan)),
('rowcount', len(plan)),
('counts', OrderedDict(counts)),
('needscreate', needscreate),
('uncreatable', uncreatable),
('unresolved', unresolved),
('blocking', bool(unresolved) or bool(uncreatable)),
])
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))
# An uncreatable group blocks, so it has to be ON SCREEN. Setting
# blocking without printing the reason left "BLOCKED (resolve the values
# above)" pointing at nothing.
uncreatable = packet.get('uncreatable') or {}
if uncreatable:
for group, items in uncreatable.items():
lines.append('')
lines.append(' BLOCKING: this import cannot create %s rows, but '
'%d are needed - create them first, or alias the '
'export values to rows that exist:'
% (group, len(items)))
for name, count in items.items():
lines.append(' x %-28s (%d rows)' % (name, count))
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 problems 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)