Import the wilm agent store, WINC adoption work and batches CLI, with four fixes
This commit is contained in:
154
shopdb/core/services/wincimport.py
Normal file
154
shopdb/core/services/wincimport.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""WINC machine-adoption import service (preview / commit / undo / list).
|
||||
|
||||
Web-facing wrapper over the validated WINC CLI helpers. The CLI in
|
||||
scripts/site_imports/winc drives the same analyzer, writer, and journal; this
|
||||
module adapts them to a Flask request: it works on the request-scoped
|
||||
db.session connection (so the route owns the transaction and commit) and returns
|
||||
plain JSON-serialisable dicts for the API.
|
||||
|
||||
WINC is NOT a source adapter like Azure/Thor. It never creates assets - it
|
||||
updates existing machines only, requires acknowledgment before creating business
|
||||
units, and records every change to the reversible import journal. That contract
|
||||
lives here and in the writer, not in the generic canonical-CSV importer.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from scripts.site_imports import journal
|
||||
from scripts.site_imports.approval import build_packet
|
||||
from scripts.site_imports.winc import writer as winc_writer
|
||||
from scripts.site_imports.winc.dry_run import analyze_rows, parse_export_text
|
||||
|
||||
|
||||
class WincImportError(Exception):
|
||||
"""An uploaded export could not be analyzed or applied as WINC."""
|
||||
|
||||
|
||||
def _connection():
|
||||
return db.session.connection()
|
||||
|
||||
|
||||
def _plan_and_packet(export_text):
|
||||
"""Parse an uploaded export, build the plan and the approval packet."""
|
||||
rows = parse_export_text(export_text)
|
||||
if not rows:
|
||||
raise WincImportError('the uploaded file had no rows')
|
||||
if 'assetnum' not in rows[0]:
|
||||
raise WincImportError(
|
||||
'this does not look like a WINC asset export (no assetnum column)')
|
||||
|
||||
plan, summary, _lookups = analyze_rows(_connection(), rows)
|
||||
packet = build_packet('winc-machine-adoption', plan, summary,
|
||||
winc_writer._PACKET_GROUPS)
|
||||
return plan, summary, packet
|
||||
|
||||
|
||||
def _report(summary, packet):
|
||||
"""Shape the packet + summary into the JSON the UI renders."""
|
||||
return {
|
||||
'kind': packet['kind'],
|
||||
'planhash': packet['planhash'],
|
||||
'rowcount': packet['rowcount'],
|
||||
'summary': dict(summary),
|
||||
'needscreate': {group: dict(items)
|
||||
for group, items in packet.get('needscreate', {}).items()},
|
||||
'unresolved': {group: dict(items)
|
||||
for group, items in packet.get('unresolved', {}).items()},
|
||||
'blocking': packet.get('blocking', False),
|
||||
}
|
||||
|
||||
|
||||
def preview(export_text):
|
||||
"""Read-only: analyze the export and return the report the UI shows.
|
||||
|
||||
Writes nothing. The whole point is to surface what a commit WOULD do -
|
||||
counts, the business units it would create (needs acknowledgment), and any
|
||||
unresolved values that block the import.
|
||||
"""
|
||||
_plan, summary, packet = _plan_and_packet(export_text)
|
||||
report = _report(summary, packet)
|
||||
report['committed'] = False
|
||||
return report
|
||||
|
||||
|
||||
def commit(export_text, acknowledge=None, createdby=None, planhash=None):
|
||||
"""Apply the plan reversibly inside the request transaction.
|
||||
|
||||
Rebuilds the plan from the same upload, re-checks it against a freshly built
|
||||
packet (drift, blocking, acknowledgment) and writes through the journal. The
|
||||
caller owns the commit/rollback of db.session. Returns the report plus the
|
||||
batchid on success.
|
||||
|
||||
planhash is the one the PREVIEW returned. It is what makes the approval mean
|
||||
anything: the packet handed to the writer is built from this same upload, so
|
||||
the writer's drift check compared a hash against itself and could never fail.
|
||||
An admin previewing file A and committing file B - or the same file after the
|
||||
locations or aliases moved underneath it - got whatever the second plan said,
|
||||
gated only by the needs-create group NAMES they happened to tick.
|
||||
|
||||
Passing it is optional so the CLI path, which builds and applies in one go,
|
||||
is unaffected. The web route requires it.
|
||||
|
||||
Raises WincImportError (mapped to a 400 by the route) when the plan is
|
||||
blocking, a needs-create group was not acknowledged, or the plan no longer
|
||||
matches the one that was approved.
|
||||
"""
|
||||
plan, summary, packet = _plan_and_packet(export_text)
|
||||
if planhash is not None and packet['planhash'] != planhash:
|
||||
raise WincImportError(
|
||||
'This plan is not the one that was previewed - the upload or the '
|
||||
'data behind it changed. Preview again and re-check what it would '
|
||||
'create before committing.')
|
||||
try:
|
||||
batchid = winc_writer.apply_plan(
|
||||
_connection(), plan, packet,
|
||||
acknowledge=acknowledge, createdby=createdby)
|
||||
except winc_writer.WriterError as exc:
|
||||
raise WincImportError(str(exc))
|
||||
|
||||
report = _report(summary, packet)
|
||||
report['committed'] = True
|
||||
report['batchid'] = batchid
|
||||
return report
|
||||
|
||||
|
||||
def undo(batchid):
|
||||
"""Reverse an applied batch. Caller owns the transaction. Returns a dict."""
|
||||
result = journal.undo_batch(_connection(), batchid,
|
||||
references=winc_writer._UNDO_REFERENCES)
|
||||
return {
|
||||
'batchid': result.batchid,
|
||||
'reverted': result.reverted,
|
||||
'conflicts': [
|
||||
{'operation': conflict.operation, 'table': conflict.table,
|
||||
'targetid': conflict.targetid, 'column': conflict.column,
|
||||
'reason': conflict.reason}
|
||||
for conflict in result.conflicts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_batches(limit=50):
|
||||
"""Applied/undone WINC batches, newest first, for the UI's undo list."""
|
||||
from sqlalchemy import text
|
||||
|
||||
def _iso(value):
|
||||
# PyMySQL hands back datetime objects; SQLite (tests) hands back the
|
||||
# stored ISO string. Normalise both to an ISO string with a Z suffix.
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, 'isoformat'):
|
||||
return value.isoformat() + 'Z'
|
||||
return str(value)
|
||||
|
||||
rows = _connection().execute(text(
|
||||
'SELECT batchid, kind, rowcount, createdat, createdby, status, '
|
||||
'undoneat FROM importbatches WHERE kind = :kind '
|
||||
'ORDER BY createdat DESC LIMIT :limit'),
|
||||
{'kind': 'winc-machine-adoption', 'limit': limit}).fetchall()
|
||||
return [
|
||||
{'batchid': r[0], 'kind': r[1], 'rowcount': r[2],
|
||||
'createdat': _iso(r[3]),
|
||||
'createdby': r[4], 'status': r[5],
|
||||
'undoneat': _iso(r[6])}
|
||||
for r in rows
|
||||
]
|
||||
Reference in New Issue
Block a user