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())