Running `python scripts/export_catalog.py` on a deployed site died with 1045 "Access denied for user 'root'@'localhost'", naming a user that site does not have. Nothing loads .env for a plain script run. wsgi.py calls load_dotenv() for the served app and the flask CLI loads .env for itself, so both reach the real database; a bare `python scripts/...` gets neither. DATABASE_URL then stays unset, FLASK_ENV defaults to development, and config.py falls back to a hardcoded root@localhost URL. The read-only failure above is the harmless version. Six of these seven scripts WRITE - backfill, reclassify, retype, collapse, refile, import - and on any box where those fallback root credentials happen to resolve, they would have silently rewritten the wrong database with no error at all. That is also why this survived: the dev .env points at root, so the fallback matched what the developer expected and nothing ever looked wrong here. All seven now load .env from the repo root, resolved from __file__ rather than the working directory, since these are run by hand during a deployment and rarely from the repo root. tests/test_maintenance_scripts.py pins it for any future script: every script that calls create_app must call load_dotenv, before create_app rather than after it, and with an explicit path rather than a cwd-relative search.
217 lines
9.6 KiB
Python
217 lines
9.6 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
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env before anything reads config. wsgi.py does this for the served app
|
|
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
|
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
|
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
|
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
|
# does not use - and on a box where those root credentials happen to work, it
|
|
# is worse, because the script silently reads or WRITES the wrong database.
|
|
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
|
|
|
|
|
# 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())
|