Vendor derived from catalog model; model type shown separately

This commit is contained in:
2026-08-05 10:00:08 -04:00
parent c51e86e686
commit 73940ae992
9 changed files with 217 additions and 4 deletions

View File

@@ -95,12 +95,22 @@
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ computer.computer?.vendorname || '-' }}</span>
<span class="info-value">
{{ computer.computer?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="computer.computer?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
</div>
<!-- The catalog model's own type. Kept separate from this
asset's type: modeltypes spans every kind of asset. -->
<div class="info-row" v-if="computer.computer?.modeltypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ computer.computer.modeltypename }}</span>
</div>
<div class="info-row">
<span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>

View File

@@ -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'] = [

View File

@@ -114,12 +114,22 @@
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
<span class="info-value">
{{ machine.machine?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="machine.machine?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
</div>
<!-- The catalog model's own type. Kept separate from this
asset's type: modeltypes spans every kind of asset. -->
<div class="info-row" v-if="machine.machine?.modeltypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ machine.machine.modeltypename }}</span>
</div>
</div>
</div>

View File

@@ -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:

View File

@@ -88,7 +88,21 @@
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
<span class="info-value">
{{ device.networkdevice?.vendorname || '-' }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="device.networkdevice?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="info-row" v-if="device.networkdevice?.modelname">
<span class="info-label">Model</span>
<span class="info-value">{{ device.networkdevice.modelname }}</span>
</div>
<!-- The catalog model's own type, kept separate from the device
type: modeltypes spans every kind of asset. -->
<div class="info-row" v-if="device.networkdevice?.modeltypename">
<span class="info-label">Model type</span>
<span class="info-value">{{ device.networkdevice.modeltypename }}</span>
</div>
<div class="info-row">
<span class="info-label">Device Type</span>

View File

@@ -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

View File

@@ -39,12 +39,22 @@
<div class="hero-details">
<div class="hero-detail" v-if="printer.printer?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ printer.printer.vendorname }}</span>
<span class="hero-detail-value">
{{ printer.printer.vendorname }}
<!-- Inherited from the catalog model, not stored on this record. -->
<small v-if="printer.printer?.vendorfrommodel" class="text-muted">(from model)</small>
</span>
</div>
<div class="hero-detail" v-if="printer.printer?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
</div>
<!-- The catalog model's own type, kept separate from the printer's
type: modeltypes spans every kind of asset. -->
<div class="hero-detail" v-if="printer.printer?.modeltypename">
<span class="hero-detail-label">Model type</span>
<span class="hero-detail-value">{{ printer.printer.modeltypename }}</span>
</div>
<div class="hero-detail" v-if="printer.serialnumber">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>

View File

@@ -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

View File

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