Machine data arrives as a Maximo asset export (validated against the WINC site) that must update existing machines in place, not create canonical per-table rows, so it does not fit the ShopDB-native/Azure/Thor CSV adapter path. Bulk edits to live machines also need an escape hatch, so the whole commit is journaled and can be undone as one batch. Add a dedicated dry-run analyzer, approval packet, resolver/suggest helpers, and a reversible writer/undo backed by a new importbatches/importjournalentries schema (with migration). Expose preview/commit/batches/undo settings routes and a matching frontend source card and panel, and surface the batch/journal contract through shopdb.api for plugins.
150 lines
6.1 KiB
Python
150 lines
6.1 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):
|
|
"""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)
|