135 lines
5.6 KiB
Python
135 lines
5.6 KiB
Python
"""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
|