204 lines
9.0 KiB
Python
204 lines
9.0 KiB
Python
"""Fill in an asset's vendor from the catalog model it already points at.
|
|
|
|
An asset that carries a model but no vendor is showing a blank the database can
|
|
answer: the model records its own vendor, and both sides reference the same
|
|
vendors table. The detail pages fall back to it at display time, but the record
|
|
itself stays empty, so the edit form shows an empty vendor box and any report
|
|
reading vendorid directly still sees nothing. This writes it down.
|
|
|
|
ONLY fills rows where the asset's vendor is NULL and the model names one. It
|
|
never overwrites a vendor somebody chose, and it never guesses: if the model has
|
|
no vendor either, the row is left alone.
|
|
|
|
It also fills the asset's TYPE from the model's type, but ONLY where the two
|
|
names are identical. modeltypes is the catalog-wide list covering every kind of
|
|
asset - it holds "Access Point", "Camera" and "Desktop PC" alongside the machine
|
|
entries - so it is a different taxonomy from machinetypes and the two cannot be
|
|
equated in general. Across the whole catalog only about two thirds of the names
|
|
overlap. Restricted to the models an asset class actually uses the picture is
|
|
different: every one of the 262 machines in the development database maps
|
|
exactly, because the non-machine entries are never used by machines.
|
|
|
|
So the rule is exact name match or nothing. A model type with no same-named
|
|
entry in the asset's own type table is REPORTED and left alone, never guessed
|
|
at, because the failure mode is a machine labelled "Desktop PC".
|
|
|
|
Dry run by default; nothing is written without --commit.
|
|
|
|
python scripts/backfill_vendor_from_model.py
|
|
python scripts/backfill_vendor_from_model.py --commit
|
|
python scripts/backfill_vendor_from_model.py --only machines --commit
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
# Each asset table that carries both a model link and its own vendor, with the
|
|
# column that identifies a row in reports and logs.
|
|
TARGETS = [
|
|
('machines', 'machineid', 'machinename'),
|
|
('computers', 'computerid', 'computername'),
|
|
('printers', 'printerid', 'printername'),
|
|
('networkdevices', 'networkdeviceid', 'hostname'),
|
|
]
|
|
|
|
# The asset's own type column, and the table it points at. Each asset class
|
|
# keeps its own type list; the catalog's modeltypes is a different taxonomy, so
|
|
# these are joined by NAME and only when the name matches exactly.
|
|
TYPE_TARGETS = {
|
|
'machines': ('machinetypeid', 'machinetypes', 'machinetype'),
|
|
'computers': ('computertypeid', 'computertypes', 'computertype'),
|
|
'printers': ('printertypeid', 'printertypes', 'printertype'),
|
|
'networkdevices': ('networkdevicetypeid', 'networkdevicetypes', 'networkdevicetype'),
|
|
}
|
|
|
|
|
|
def resolve(connection, table, idcol, labelcol):
|
|
"""Rows that would change, newest table columns tolerated."""
|
|
from sqlalchemy import text
|
|
columns = {c['name'] for c in __import__('sqlalchemy').inspect(connection).get_columns(table)}
|
|
if 'modelnumberid' not in columns or 'vendorid' not in columns:
|
|
return None, []
|
|
label = labelcol if labelcol in columns else idcol
|
|
rows = connection.execute(text(f"""
|
|
SELECT a.{idcol} AS assetid,
|
|
a.{label} AS label,
|
|
m.modelnumber AS modelnumber,
|
|
m.vendorid AS vendorid,
|
|
v.vendor AS vendorname
|
|
FROM {table} a
|
|
JOIN models m ON a.modelnumberid = m.modelnumberid
|
|
JOIN vendors v ON m.vendorid = v.vendorid
|
|
WHERE a.vendorid IS NULL
|
|
AND m.vendorid IS NOT NULL
|
|
ORDER BY a.{idcol}
|
|
""")).fetchall()
|
|
return label, rows
|
|
|
|
|
|
def resolve_types(connection, table, idcol, labelcol):
|
|
"""Rows whose type could be taken from the model, plus the ones that cannot.
|
|
|
|
Returns (fillable, unmatched). unmatched rows have a model type that has no
|
|
identically named entry in this asset's own type table, and are never
|
|
touched - they are reported so somebody can decide.
|
|
"""
|
|
from sqlalchemy import inspect, text
|
|
spec = TYPE_TARGETS.get(table)
|
|
if not spec:
|
|
return [], []
|
|
typecol, typetable, typenamecol = spec
|
|
columns = {c['name'] for c in inspect(connection).get_columns(table)}
|
|
if 'modelnumberid' not in columns or typecol not in columns:
|
|
return [], []
|
|
label = labelcol if labelcol in columns else idcol
|
|
|
|
fillable = connection.execute(text(f"""
|
|
SELECT a.{idcol} AS assetid, a.{label} AS label,
|
|
mt.modeltype AS modeltype, tt.{typenamecol} AS typename
|
|
FROM {table} a
|
|
JOIN models mo ON a.modelnumberid = mo.modelnumberid
|
|
JOIN modeltypes mt ON mo.modeltypeid = mt.modeltypeid
|
|
JOIN {typetable} tt ON tt.{typenamecol} = mt.modeltype
|
|
WHERE a.{typecol} IS NULL
|
|
ORDER BY a.{idcol}
|
|
""")).fetchall()
|
|
|
|
unmatched = connection.execute(text(f"""
|
|
SELECT mt.modeltype AS modeltype, COUNT(*) AS n
|
|
FROM {table} a
|
|
JOIN models mo ON a.modelnumberid = mo.modelnumberid
|
|
JOIN modeltypes mt ON mo.modeltypeid = mt.modeltypeid
|
|
LEFT JOIN {typetable} tt ON tt.{typenamecol} = mt.modeltype
|
|
WHERE a.{typecol} IS NULL AND tt.{typenamecol} IS NULL
|
|
GROUP BY mt.modeltype ORDER BY n DESC
|
|
""")).fetchall()
|
|
return fillable, unmatched
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--commit', action='store_true',
|
|
help='write the changes (default is a dry run)')
|
|
parser.add_argument('--only', metavar='TABLE',
|
|
help='restrict to one table, e.g. machines')
|
|
parser.add_argument('--limit', type=int, default=20,
|
|
help='rows to list per table in the preview (default 20)')
|
|
args = parser.parse_args()
|
|
|
|
# FLASK_ENV is deliberately NOT forced. The app reads it from .env, which on
|
|
# a server already says production; overriding it here demanded a SECRET_KEY
|
|
# the environment had no reason to provide and the script could not run.
|
|
from sqlalchemy import text
|
|
from shopdb import create_app
|
|
from shopdb.extensions import db
|
|
|
|
app = create_app()
|
|
with app.app_context():
|
|
connection = db.session.connection()
|
|
total = 0
|
|
for table, idcol, labelcol in TARGETS:
|
|
if args.only and args.only != table:
|
|
continue
|
|
label, rows = resolve(connection, table, idcol, labelcol)
|
|
if label is None:
|
|
print(f'{table}: no model or vendor column here, skipped')
|
|
continue
|
|
print(f'\n{table}: {len(rows)} row(s) would get a vendor')
|
|
for row in rows[:args.limit]:
|
|
print(f' {row.assetid:>7} {str(row.label)[:28]:<28} '
|
|
f'model {row.modelnumber[:22]:<22} -> {row.vendorname}')
|
|
if len(rows) > args.limit:
|
|
print(f' ... and {len(rows) - args.limit} more')
|
|
total += len(rows)
|
|
|
|
if args.commit and rows:
|
|
connection.execute(text(f"""
|
|
UPDATE {table} a
|
|
JOIN models m ON a.modelnumberid = m.modelnumberid
|
|
SET a.vendorid = m.vendorid
|
|
WHERE a.vendorid IS NULL
|
|
AND m.vendorid IS NOT NULL
|
|
"""))
|
|
|
|
# Type, by exact name only.
|
|
typed, unmatched = resolve_types(connection, table, idcol, labelcol)
|
|
spec = TYPE_TARGETS.get(table)
|
|
print(f'{table}: {len(typed)} row(s) would get a type')
|
|
for row in typed[:args.limit]:
|
|
print(f' {row.assetid:>7} {str(row.label)[:28]:<28} '
|
|
f'model type {row.modeltype[:22]:<22} -> {row.typename}')
|
|
if len(typed) > args.limit:
|
|
print(f' ... and {len(typed) - args.limit} more')
|
|
for row in unmatched:
|
|
print(f' LEFT ALONE: {row.n} row(s) with model type '
|
|
f'"{row.modeltype}" - no matching {spec[1]} entry')
|
|
total += len(typed)
|
|
|
|
if args.commit and typed:
|
|
typecol, typetable, typenamecol = spec
|
|
connection.execute(text(f"""
|
|
UPDATE {table} a
|
|
JOIN models mo ON a.modelnumberid = mo.modelnumberid
|
|
JOIN modeltypes mt ON mo.modeltypeid = mt.modeltypeid
|
|
JOIN {typetable} tt ON tt.{typenamecol} = mt.modeltype
|
|
SET a.{typecol} = tt.{typecol}
|
|
WHERE a.{typecol} IS NULL
|
|
"""))
|
|
|
|
if args.commit:
|
|
db.session.commit()
|
|
print(f'\nCommitted. {total} row(s) updated.')
|
|
else:
|
|
print(f'\nDRY RUN - nothing written. {total} row(s) would change.')
|
|
print('Re-run with --commit to apply.')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|