Import the wilm agent store, WINC adoption work and batches CLI, with four fixes
This commit is contained in:
134
scripts/site_imports/resolver.py
Normal file
134
scripts/site_imports/resolver.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Shared four-state resolver vocabulary for site-import dry runs.
|
||||
|
||||
IMPORT-AGNOSTIC. Nothing here knows about machines, eq1, locations, or any one
|
||||
site. Every site importer maps a raw exported value onto a database row, and
|
||||
every one of those mappings lands in exactly one of four states:
|
||||
|
||||
RESOLVED the value maps to a row that already exists; safe to set.
|
||||
BLANK there is nothing to set (empty cell, or a deliberately
|
||||
unmappable value); leave the target field as-is.
|
||||
NEEDS_CREATE the value maps to a valid canonical name, but that row does
|
||||
not exist yet; a writer may create it, WITH acknowledgment.
|
||||
UNRESOLVED the value has no mapping at all; a human must add one before
|
||||
any writer runs. This is the state that blocks approval.
|
||||
|
||||
Keeping this vocabulary in one place is what lets a promotion/approval helper,
|
||||
an undo journal, and any number of importers all speak the same language. Add
|
||||
new importers by writing their lookups and alias maps; reuse these primitives to
|
||||
turn a raw value into a Resolution.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict, namedtuple
|
||||
|
||||
# The four terminal states a single field resolution can end in.
|
||||
RESOLVED = 'resolved'
|
||||
BLANK = 'blank'
|
||||
NEEDS_CREATE = 'needs-create'
|
||||
UNRESOLVED = 'unresolved'
|
||||
|
||||
# status: one of the four above.
|
||||
# id: the database id when RESOLVED, else None.
|
||||
# name: the canonical target name when known (RESOLVED / NEEDS_CREATE), else
|
||||
# None. Carried so callers can report and, later, create by name.
|
||||
Resolution = namedtuple('Resolution', ('status', 'id', 'name'))
|
||||
|
||||
|
||||
def norm(value):
|
||||
"""Lowercase, stripped form used for every name comparison and alias key.
|
||||
|
||||
Centralised so a lookup table, an alias map, and the value being resolved
|
||||
are always normalised the same way. If this ever changes, it changes for
|
||||
everyone at once, which is the point.
|
||||
"""
|
||||
return (value or '').strip().lower()
|
||||
|
||||
|
||||
def load_lookup(connection, table, id_column, name_column, display=None):
|
||||
"""norm(name) -> id for every row of any table. One loader, every importer.
|
||||
|
||||
This is the "match against what is actually in the database" primitive. It
|
||||
reads the live table at call time, so a value a human adds in Settings is
|
||||
visible on the next run with no code change: a name that exists resolves,
|
||||
one that does not is NEEDS_CREATE or UNRESOLVED. Every site importer maps its
|
||||
exported values onto some reference table (locations, businessunits, vendors,
|
||||
...) and they should all load that table the same way rather than each
|
||||
hand-rolling a SELECT.
|
||||
|
||||
Pass a dict as `display` to also collect norm(name) -> original-cased name,
|
||||
so a report can echo the name the way the database spells it rather than the
|
||||
normalised key.
|
||||
|
||||
First writer wins on a duplicate normalised name, which is deterministic and
|
||||
matches how the importers resolve: the ambiguity is a data problem to report,
|
||||
not something the loader should silently reorder.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
out = {}
|
||||
rows = connection.execute(text(
|
||||
'SELECT %s, %s FROM %s' % (id_column, name_column, table)))
|
||||
for ident, name in rows:
|
||||
key = norm(name)
|
||||
if key:
|
||||
out.setdefault(key, ident)
|
||||
if display is not None:
|
||||
display.setdefault(key, (name or '').strip())
|
||||
return out
|
||||
|
||||
|
||||
def resolve_name(name, lookup_by_name, missing=UNRESOLVED):
|
||||
"""Resolve a value by direct name match against a lookup table.
|
||||
|
||||
lookup_by_name maps norm(name) -> database id. A blank name is BLANK. A hit
|
||||
is RESOLVED with the id. A miss is `missing` - callers pass UNRESOLVED when a
|
||||
human must supply a mapping, or NEEDS_CREATE when the name is trusted and a
|
||||
writer may create it.
|
||||
"""
|
||||
if not (name or '').strip():
|
||||
return Resolution(BLANK, None, None)
|
||||
ident = lookup_by_name.get(norm(name))
|
||||
if ident is not None:
|
||||
return Resolution(RESOLVED, ident, name)
|
||||
return Resolution(missing, None, name)
|
||||
|
||||
|
||||
def resolve_alias(raw, alias_map, lookup_by_name, missing=UNRESOLVED):
|
||||
"""Resolve a dirty raw value through an alias map, then a lookup table.
|
||||
|
||||
The alias-first path: a raw export value (possibly truncated or oddly cased)
|
||||
is normalised and looked up in alias_map to get a canonical name, which is
|
||||
then resolved against lookup_by_name.
|
||||
|
||||
raw is blank -> BLANK (nothing to set)
|
||||
raw not in alias_map -> missing (default UNRESOLVED: add an alias)
|
||||
alias maps to None -> BLANK (deliberately unmappable value)
|
||||
canonical exists in lookup -> RESOLVED
|
||||
canonical not yet in lookup -> NEEDS_CREATE
|
||||
"""
|
||||
if not (raw or '').strip():
|
||||
return Resolution(BLANK, None, None)
|
||||
key = norm(raw)
|
||||
if key not in alias_map:
|
||||
return Resolution(missing, None, None)
|
||||
canonical = alias_map[key]
|
||||
if canonical is None:
|
||||
return Resolution(BLANK, None, None)
|
||||
return resolve_name(canonical, lookup_by_name, missing=NEEDS_CREATE)
|
||||
|
||||
|
||||
def tally(plan, status_column, value_column, status):
|
||||
"""Count rows in a plan whose status_column equals `status`, by value.
|
||||
|
||||
Returns an OrderedDict of value -> count, highest count first. Shared by the
|
||||
dry-run report and the approval packet so the numbers a human reads and the
|
||||
numbers a writer enforces come from one place.
|
||||
"""
|
||||
counts = {}
|
||||
for row in plan:
|
||||
if row.get(status_column) == status:
|
||||
value = row.get(value_column) or ''
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
ordered = OrderedDict()
|
||||
for value, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||
ordered[value] = count
|
||||
return ordered
|
||||
Reference in New Issue
Block a user