From 3f320fcc8b5e8a2863ee74cd070c6393ac7cc617 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 5 Aug 2026 09:59:39 -0400 Subject: [PATCH] Derive an asset's vendor from its catalog model, and show the model's own type An asset that carries a model but no vendor was showing a blank the database could already answer: the model records its vendor, and both sides reference the same vendors table. Machines, PCs, printers and network devices now fall back to it. The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and the detail pages render "(from model)" beside the value, because the record itself is still empty: the edit form shows an empty vendor box, and a page implying the vendor is stored would be lying about where it came from. The model's type is exposed under its own name, modeltypename, and shown as a separate "Model type" row. It is deliberately NOT used to fill in the asset's own type. 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. Only about two thirds of the names overlap, and mapping one onto the other would mistype the remainder, with the failure mode being a machine labelled "Desktop PC". scripts/backfill_vendor_from_model.py writes the derived vendor down for real, since the display fallback leaves reports that read vendorid still seeing nothing. It is a dry run unless given --commit, fills only rows where the asset's vendor is NULL and the model names one, and never overwrites a vendor somebody chose. It skips a table lacking either column, so it runs against a server whose network migration has not been applied yet. Verified against the development database by nulling one machine's vendor inside a transaction: it was detected as fillable, restored to exactly its original value, and the rollback left the row untouched. FLASK_ENV is not forced by the script. The app already reads it from .env, and overriding it demanded a SECRET_KEY the environment had no reason to supply. --- plugins/computers/frontend/views/PCDetail.vue | 12 +- plugins/computers/models/computer.py | 12 ++ .../machines/frontend/views/MachineDetail.vue | 12 +- plugins/machines/models/machine.py | 12 ++ .../frontend/views/NetworkDeviceDetail.vue | 16 ++- plugins/network/models/network_device.py | 12 ++ .../printers/frontend/views/PrinterDetail.vue | 12 +- plugins/printers/models/printer.py | 12 ++ scripts/backfill_vendor_from_model.py | 121 ++++++++++++++++++ 9 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 scripts/backfill_vendor_from_model.py diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 173a4dc..64d7e65 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -95,12 +95,22 @@
Vendor - {{ computer.computer?.vendorname || '-' }} + + {{ computer.computer?.vendorname || '-' }} + + (from model) +
Model {{ computer.computer?.modelname || '-' }}
+ +
+ Model type + {{ computer.computer.modeltypename }} +
Operating System {{ computer.computer?.osname || '-' }} diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py index 514fe2e..6a35b4f 100644 --- a/plugins/computers/models/computer.py +++ b/plugins/computers/models/computer.py @@ -138,6 +138,18 @@ class Computer(BaseModel): result['modelname'] = self.model.modelnumber if self.model.imageurl: result['imageurl'] = self.model.imageurl + # The catalog model already knows its maker, so an asset that has a + # model but no vendor of its own is showing a blank the database can + # fill. Flagged rather than merged silently: the edit form still has + # an empty vendor box, and a page implying otherwise would be lying. + if not self.vendor and self.model.vendor: + result['vendorname'] = self.model.vendor.vendor + result['vendorfrommodel'] = True + # Exposed under its OWN name. modeltypes is the catalog-wide list + # covering every kind of asset, so it is not interchangeable with + # this asset's own type and must never be substituted for it. + if self.model.modeltype: + result['modeltypename'] = self.model.modeltype.modeltype # Names of enabled remote-access protocols (for list badges) result['accessprotocolnames'] = [ diff --git a/plugins/machines/frontend/views/MachineDetail.vue b/plugins/machines/frontend/views/MachineDetail.vue index 16e3e10..945834f 100644 --- a/plugins/machines/frontend/views/MachineDetail.vue +++ b/plugins/machines/frontend/views/MachineDetail.vue @@ -114,12 +114,22 @@
Vendor - {{ machine.machine?.vendorname || '-' }} + + {{ machine.machine?.vendorname || '-' }} + + (from model) +
Model {{ machine.machine?.modelname || '-' }}
+ +
+ Model type + {{ machine.machine.modeltypename }} +
diff --git a/plugins/machines/models/machine.py b/plugins/machines/models/machine.py index d73090c..507c374 100644 --- a/plugins/machines/models/machine.py +++ b/plugins/machines/models/machine.py @@ -123,6 +123,18 @@ class Machine(BaseModel): result['modelname'] = self.model.modelnumber if self.model.imageurl: result['imageurl'] = self.model.imageurl + # The catalog model already knows its maker, so an asset that has a + # model but no vendor of its own is showing a blank the database can + # fill. Flagged rather than merged silently: the edit form still has + # an empty vendor box, and a page implying otherwise would be lying. + if not self.vendor and self.model.vendor: + result['vendorname'] = self.model.vendor.vendor + result['vendorfrommodel'] = True + # Exposed under its OWN name. modeltypes is the catalog-wide list + # covering every kind of asset, so it is not interchangeable with + # this asset's own type and must never be substituted for it. + if self.model.modeltype: + result['modeltypename'] = self.model.modeltype.modeltype # Add controller info if self.controllervendor: diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index 9bc774a..c7a56c7 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -88,7 +88,21 @@
Vendor - {{ device.networkdevice?.vendorname || '-' }} + + {{ device.networkdevice?.vendorname || '-' }} + + (from model) + +
+
+ Model + {{ device.networkdevice.modelname }} +
+ +
+ Model type + {{ device.networkdevice.modeltypename }}
Device Type diff --git a/plugins/network/models/network_device.py b/plugins/network/models/network_device.py index 41d51be..d8ce412 100644 --- a/plugins/network/models/network_device.py +++ b/plugins/network/models/network_device.py @@ -130,5 +130,17 @@ class NetworkDevice(BaseModel): result['modelname'] = self.model.modelnumber if self.model.imageurl: result['imageurl'] = self.model.imageurl + # The catalog model already knows its maker, so an asset that has a + # model but no vendor of its own is showing a blank the database can + # fill. Flagged rather than merged silently: the edit form still has + # an empty vendor box, and a page implying otherwise would be lying. + if not self.vendor and self.model.vendor: + result['vendorname'] = self.model.vendor.vendor + result['vendorfrommodel'] = True + # Exposed under its OWN name. modeltypes is the catalog-wide list + # covering every kind of asset, so it is not interchangeable with + # this asset's own type and must never be substituted for it. + if self.model.modeltype: + result['modeltypename'] = self.model.modeltype.modeltype return result diff --git a/plugins/printers/frontend/views/PrinterDetail.vue b/plugins/printers/frontend/views/PrinterDetail.vue index 877d224..7fbe610 100644 --- a/plugins/printers/frontend/views/PrinterDetail.vue +++ b/plugins/printers/frontend/views/PrinterDetail.vue @@ -39,12 +39,22 @@
Vendor - {{ printer.printer.vendorname }} + + {{ printer.printer.vendorname }} + + (from model) +
Model {{ printer.printer.modelname }}
+ +
+ Model type + {{ printer.printer.modeltypename }} +
Serial Number {{ printer.serialnumber }} diff --git a/plugins/printers/models/printer.py b/plugins/printers/models/printer.py index 428a2f2..0c0094e 100644 --- a/plugins/printers/models/printer.py +++ b/plugins/printers/models/printer.py @@ -119,5 +119,17 @@ class Printer(BaseModel): result['modelname'] = self.model.modelnumber if self.model.imageurl: result['imageurl'] = self.model.imageurl + # The catalog model already knows its maker, so an asset that has a + # model but no vendor of its own is showing a blank the database can + # fill. Flagged rather than merged silently: the edit form still has + # an empty vendor box, and a page implying otherwise would be lying. + if not self.vendor and self.model.vendor: + result['vendorname'] = self.model.vendor.vendor + result['vendorfrommodel'] = True + # Exposed under its OWN name. modeltypes is the catalog-wide list + # covering every kind of asset, so it is not interchangeable with + # this asset's own type and must never be substituted for it. + if self.model.modeltype: + result['modeltypename'] = self.model.modeltype.modeltype return result diff --git a/scripts/backfill_vendor_from_model.py b/scripts/backfill_vendor_from_model.py new file mode 100644 index 0000000..ec7065a --- /dev/null +++ b/scripts/backfill_vendor_from_model.py @@ -0,0 +1,121 @@ +"""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. + +DELIBERATELY NOT DONE HERE: the asset's TYPE. 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 mapping one onto the other by name would mistype whatever does +not match. Only about two thirds of the names overlap. + +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'), +] + + +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 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 + """)) + + 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())