diff --git a/deploy/windows/installer/ShopDBFlask.iss b/deploy/windows/installer/ShopDBFlask.iss index e39dbfe6..31e47078 100644 --- a/deploy/windows/installer/ShopDBFlask.iss +++ b/deploy/windows/installer/ShopDBFlask.iss @@ -201,6 +201,7 @@ var // the check takes a few seconds, runs hidden, and gives no sign of life. CheckingPage: TOutputProgressWizardPage; PluginPage: TInputOptionWizardPage; + CatalogPage: TInputOptionWizardPage; PluginNames: TArrayOfString; PluginPageReady: Boolean; DbPageReady: Boolean; @@ -397,7 +398,21 @@ begin if Trim(PluginNames[I]) <> '' then PluginPage.Add(PluginLabel(Trim(PluginNames[I]))); - DbChoicePage := CreateInputOptionPage(PluginPage.ID, + // Offered, not forced. A site that machines nothing inherits 21 machine types + // it will never use, and a catalog nobody wanted is just clutter in every + // dropdown. Ticked by default because the alternative is retyping a vendor and + // model list another site already built. + CatalogPage := CreateInputOptionPage(PluginPage.ID, + 'Starter data', 'Load the shared equipment catalog?', + 'Vendors, model numbers and the type lists, as built up across the other ' + + 'sites. It only ADDS - nothing existing is changed or removed - and it can ' + + 'be loaded later with "flask seed catalog". No assets, locations or people ' + + 'are included; this is catalogue information only.', + False, False); + CatalogPage.Add('Load the shared vendor and model catalog'); + CatalogPage.Values[0] := True; + + DbChoicePage := CreateInputOptionPage(CatalogPage.ID, 'Database', 'Where should ShopDB-Flask store its data?', 'Most sites already run MySQL for the existing shopdb application. If so, ' + 'choose the second option - installing a second server would collide on ' + @@ -1177,6 +1192,8 @@ begin ' -OnFailure never' + ' -ClientIpSource ' + ClientIpSourceArg + ' -SitePlugins "' + SelectedPlugins + '"'; + if CatalogPage.Values[0] then + Common := Common + ' -SeedCatalog'; // Subpath deployment: an IIS Application under the existing site instead of a // site of its own. Appended here because Pascal has no conditional expression. diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 index 43733b7a..547c56c9 100644 --- a/deploy/windows/installer/shopdb-admin.ps1 +++ b/deploy/windows/installer/shopdb-admin.ps1 @@ -22,7 +22,7 @@ [CmdletBinding()] param( [ValidateSet('menu','status','start','stop','restart','logs','open','backup', - 'check','sessions','plugins','add-plugin','verify','uninstall')] + 'check','repair','sessions','plugins','add-plugin','verify','uninstall')] [string] $Command = 'menu', [string] $Path = '', # Machine-readable output for 'check'. The people running this are expected to @@ -577,11 +577,101 @@ function Invoke-Check { $env:FLASK_APP = 'shopdb' try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } } finally { Pop-Location } + + # Surface an unfinished install HERE, where somebody is already looking, + # rather than leaving them to infer it from 500s on unrelated pages. + $state = Test-Provisioned + if ($state.SchemaCurrent -eq $false -or $state.Seeded -eq $false) { + Say '' + Say ' THIS SERVER IS NOT FULLY PROVISIONED' 'Red' + foreach ($detail in $state.Detail) { Say (" - {0}" -f $detail) 'Red' } + Say ' Most pages will answer 500 until this is fixed. Run:' 'Yellow' + Say ' shopdb-admin.ps1 repair' 'White' + } + Say '' Say ' For help from an AI assistant, paste the output of:' 'DarkGray' Say ' shopdb-admin.ps1 check -Json' 'White' } +function Test-Provisioned { + """Is the schema current and the reference data seeded?""" + # A server whose migrations or seeds never completed does not fail politely: + # it answers 500 on most pages and 404 on settings that were never created, + # which reads as a broken application rather than an unfinished install. One + # site was stood up that way and spent a morning being debugged as a bug. + $result = @{ SchemaCurrent = $true; Seeded = $true; Detail = @() } + + $out = Invoke-Flask @('db','current') + $current = ($out | Out-String) + $head = (Invoke-Flask @('db','heads') | Out-String) + if ($script:LastFlaskExit -ne 0) { + $result.SchemaCurrent = $false + $result.Detail += 'could not read the schema version' + } elseif ($current -notmatch '\(head\)' -and $head.Trim()) { + # `db current` appends "(head)" when the database is at the newest + # revision. Its absence means migrations are outstanding. + $result.SchemaCurrent = $false + $result.Detail += 'database schema is behind the application' + } + + # Sentinel seeds. permissions, settings and the reference data are each + # created by a `flask seed` command, and their absence is what produced the + # 404s on settings keys at a site whose install never finished. + $seedOut = (Invoke-Flask @('db-utils','seed-state') | Out-String) + if ($seedOut -match 'No such command') { + # An older build with no seed-state. Report UNKNOWN rather than healthy: + # claiming a clean bill of health from a check that did not run is how a + # broken server passes inspection. + $result.Seeded = $null + $result.Detail += 'seed state could not be checked (older build)' + } elseif ($script:LastFlaskExit -ne 0 -or $seedOut -match 'MISSING') { + $result.Seeded = $false + $result.Detail += 'seed data is missing (permissions, settings or reference data)' + } + return $result +} + +function Invoke-Repair { + Head 'Repair provisioning' + Say ' Brings the database up to the application: migrations, plugin' + Say ' migrations, and the seed data. Every step is idempotent, so running' + Say ' this on a healthy server changes nothing.' + Say '' + + $steps = @( + @{ Name = 'core schema'; Args = @('db','upgrade') }, + @{ Name = 'plugin schemas'; Args = @('plugin','upgrade-all') }, + @{ Name = 'permissions'; Args = @('seed','permissions') }, + @{ Name = 'settings'; Args = @('seed','settings') }, + @{ Name = 'reference data'; Args = @('seed','reference-data') } + ) + $failed = @() + foreach ($step in $steps) { + Say (" {0} ..." -f $step.Name) 'White' + $out = Invoke-Flask $step.Args + if ($script:LastFlaskExit -ne 0) { + $failed += $step.Name + Say (" FAILED (exit {0})" -f $script:LastFlaskExit) 'Red' + $out | Select-Object -Last 6 | ForEach-Object { Say " $_" 'Red' } + } else { + Say ' done' 'Green' + } + } + + if ($failed.Count -gt 0) { + Say '' + Say (" {0} step(s) failed: {1}" -f $failed.Count, ($failed -join ', ')) 'Red' + Say ' Nothing further was skipped - each step ran independently.' 'Red' + Say ' Send the output above, or run: shopdb-admin.ps1 check -Json' 'Red' + return + } + + Say '' + Say ' Provisioning complete. Restarting the application.' 'Green' + Restart-App +} + function Show-Sessions { Head 'Worker processes' $w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue @@ -856,6 +946,7 @@ switch ($Command) { 'open' { Open-Site } 'backup' { Backup-Db $Path } 'check' { Invoke-Check } + 'repair' { Invoke-Repair } 'sessions' { Show-Sessions } 'plugins' { Show-Plugins } 'add-plugin'{ Add-Plugin $Path } diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 index d7c04854..b3ffb528 100644 --- a/deploy/windows/installer/shopdb-install.ps1 +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -127,6 +127,10 @@ param( # client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard # visitor-location lookup and per-host login rate limiting all break quietly. [ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct', + # Load the shared vendor/model catalog during stage 3. Offered by the + # wizard rather than assumed: a site that machines nothing does not want + # 21 machine types cluttering its dropdowns. + [switch] $SeedCatalog, # Required before this installer will alter an installation it did not # create. Everything here is built for a greenfield server: it makes its own # Python, its own venv and its own IIS objects, and its upgrade path assumes @@ -1747,6 +1751,14 @@ build to get a matching pair, then send the install log to support. Invoke-Native $Flask @('seed',$seed) "seed $seed" } + # Additive and idempotent, so a re-run adds only what is genuinely new + # and never overwrites a description somebody corrected here. + if ($SeedCatalog) { + Write-Log 'flask seed catalog (shared vendor and model catalog)' + $catalogOut = Invoke-Native $Flask @('seed','catalog') 'seed catalog' -OkExit 0,1 + $catalogOut | Select-Object -Last 12 | ForEach-Object { Write-Log " $_" } + } + # Plugin registry starts empty on a fresh box. apply-profile resolves the # declared set's hard-dependency closure and installs + enables it in # dependency order, idempotently (ADR-013). It fails loudly if the profile diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index 605144b3..87fc0271 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -205,30 +205,41 @@
Map Position (Optional) -
-
- - -
-
- - + +
+ +
+
+ Position: {{ form.mapx }}, {{ form.mapy }} + +
+
+ + +
+ +
+ +
+
Notes @@ -274,11 +285,16 @@ import { businessunitsApi } from '@/api' import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue' +import ShopFloorMap from '@/components/ShopFloorMap.vue' +import Modal from '@/components/Modal.vue' +import { currentTheme } from '@/stores/theme' import { useIdentifierFlags } from '@/composables/identifierSettings' import { apiError } from '@/utils/apiError' const { isEnabled } = useIdentifierFlags() +const showMapPicker = ref(false) +const tempMapPosition = ref(null) const route = useRoute() const router = useRouter() @@ -483,6 +499,24 @@ function cancel() { router.push('/network') } } +function handlePositionPicked(position) { + tempMapPosition.value = position +} + +function confirmMapPosition() { + if (tempMapPosition.value) { + form.value.mapx = tempMapPosition.value.left + form.value.mapy = tempMapPosition.value.top + } + showMapPicker.value = false +} + +function clearMapPosition() { + form.value.mapx = null + form.value.mapy = null + tempMapPosition.value = null +} + + + + + + diff --git a/scripts/export_catalog.py b/scripts/export_catalog.py new file mode 100644 index 00000000..cf0d8b77 --- /dev/null +++ b/scripts/export_catalog.py @@ -0,0 +1,173 @@ +"""Export the reference CATALOG from a live instance to a portable JSON file. + +A new site starts with almost nothing: `flask seed reference-data` writes a +dozen generic model types and no vendors or models at all. So the first thing +anyone does at a new facility is retype a vendor and model list that another +facility already spent a year building. This lets that catalog travel. + +CATALOG ONLY. Vendors, models and the type vocabularies are descriptions of +equipment that exists in the world, and are the same at every site. Assets, +locations, employees, business units and anything with a serial number are that +site's own records and are deliberately NOT exported - nobody wants West +Jefferson's machines appearing at another plant. + +Vendor contact details are also skipped: a rep's name and phone number belong to +whoever has the relationship, not to the catalog. + + python scripts/export_catalog.py # to shopdb/data/catalog.json + python scripts/export_catalog.py --out mine.json + python scripts/export_catalog.py --pretty # readable diffs in git + +Load it on another instance with: flask seed catalog +""" + +import argparse +import json +import os +import sys +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +DEFAULT_OUT = os.path.join('shopdb', 'data', 'catalog.json') + + +def export(connection): + from sqlalchemy import inspect, text + + inspector = inspect(connection) + tables = set(inspector.get_table_names()) + data = OrderedDict() + + def rows(sql): + return [dict(r._mapping) for r in connection.execute(text(sql))] + + def has(table): + return table in tables + + # --- type vocabularies ------------------------------------------------- + # Each is a small controlled list, and a site with none of them cannot even + # classify what it owns. + if has('modeltypes'): + data['modeltypes'] = rows(""" + SELECT modeltype, category, description, icon + FROM modeltypes ORDER BY modeltype""") + for table, column in (('machinetypes', 'machinetype'), + ('computertypes', 'computertype'), + ('printertypes', 'printertype'), + ('networkdevicetypes', 'networkdevicetype'), + ('locationtypes', 'locationtype')): + if has(table): + extra = ', color' if 'color' in {c['name'] for c in inspector.get_columns(table)} else '' + data[table] = rows('SELECT %s, description%s FROM %s ORDER BY %s' + % (column, extra, table, column)) + + if has('operatingsystems'): + columns = {c['name'] for c in inspector.get_columns('operatingsystems')} + wanted = [c for c in ('osname', 'osversion', 'osfamily', 'description') if c in columns] + data['operatingsystems'] = rows('SELECT %s FROM operatingsystems ORDER BY osname' + % ', '.join(wanted)) + + # --- vendors ----------------------------------------------------------- + # Name and website only. Contact person, phone and address are a site's own + # relationship with that vendor, not a property of the vendor. + if has('vendors'): + columns = {c['name'] for c in inspector.get_columns('vendors')} + wanted = [c for c in ('vendor', 'website', 'description') if c in columns] + data['vendors'] = rows('SELECT %s FROM vendors ORDER BY vendor' % ', '.join(wanted)) + + # --- models ------------------------------------------------------------ + # Referenced by NAME, not id: ids differ between databases, and a catalog + # keyed on them would silently attach models to the wrong vendor. + if has('models'): + data['models'] = rows(""" + SELECT m.modelnumber AS modelnumber, + v.vendor AS vendor, + mt.modeltype AS modeltype, + m.description AS description, + m.documentationurl AS documentationurl, + m.imageurl AS imageurl + FROM models m + LEFT JOIN vendors v ON m.vendorid = v.vendorid + LEFT JOIN modeltypes mt ON m.modeltypeid = mt.modeltypeid + ORDER BY v.vendor, m.modelnumber""") + + # --- printer supplies -------------------------------------------------- + # The single most useful thing to inherit after the models themselves: a new + # site with printers gets every toner, drum and maintenance-kit part number + # already matched to the right model, instead of somebody reading them off + # cartridges. Keyed by the model's natural key for the same reason models + # are keyed by vendor name. + if has('modelsupplies') and has('models'): + data['modelsupplies'] = rows(""" + SELECT mo.modelnumber AS modelnumber, + v.vendor AS vendor, + s.supplytype AS supplytype, + s.color AS color, + s.capacitytier AS capacitytier, + s.partnumber AS partnumber, + s.marketingname AS marketingname, + s.pageyield AS pageyield, + s.notes AS notes + FROM modelsupplies s + JOIN models mo ON s.modelnumberid = mo.modelnumberid + LEFT JOIN vendors v ON mo.vendorid = v.vendorid + ORDER BY v.vendor, mo.modelnumber, s.partnumber""") + + # --- small generic vocabularies --------------------------------------- + # Each is a controlled list that means the same thing at any site. + if has('measuringtooltypes'): + data['measuringtooltypes'] = rows( + 'SELECT name, description, color FROM measuringtooltypes ORDER BY name') + if has('notificationtypes'): + data['notificationtypes'] = rows( + 'SELECT typename, typedescription, typecolor FROM notificationtypes ORDER BY typename') + if has('accessprotocols'): + columns = {c['name'] for c in inspector.get_columns('accessprotocols')} + wanted = [c for c in ('name', 'scheme', 'defaultport', 'linktemplate') if c in columns] + data['accessprotocols'] = rows('SELECT %s FROM accessprotocols ORDER BY name' + % ', '.join(wanted)) + + # Drop empty optional values so the file stays readable and a NULL does not + # arrive as the string "None". + for key, records in data.items(): + cleaned = [] + for record in records: + cleaned.append({k: v for k, v in record.items() if v not in (None, '')}) + data[key] = cleaned + return data + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--out', default=DEFAULT_OUT, help='output file') + parser.add_argument('--pretty', action='store_true', + help='indent the JSON so git diffs are readable') + args = parser.parse_args() + + from shopdb import create_app + from shopdb.extensions import db + + app = create_app() + with app.app_context(): + data = export(db.session.connection()) + + directory = os.path.dirname(os.path.abspath(args.out)) + if directory: + os.makedirs(directory, exist_ok=True) + with open(args.out, 'w', encoding='utf-8') as handle: + json.dump(data, handle, indent=2 if args.pretty else None, + ensure_ascii=False, sort_keys=False) + handle.write('\n') + + print('Wrote %s' % args.out) + for key, records in data.items(): + print(' %-22s %d' % (key, len(records))) + print('') + print('Load it on another instance with: flask seed catalog') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 66e24d05..c8582804 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -88,6 +88,12 @@ def create_app(config_name: str = None) -> Flask: # Initialize extensions init_extensions(app) + # An optional unique column accepts any number of NULLs but exactly one + # empty string, so a second blank code collided and became a 500. Normalise + # blank to NULL once, at the mapper, rather than in each endpoint. + from .utils.blankunique import register_blank_unique_normaliser + register_blank_unique_normaliser() + # Initialize plugin manager with app.app_context(): plugin_manager.init_app(app, db) @@ -186,6 +192,31 @@ def register_error_handlers(app: Flask): from .utils.responses import error_response, ErrorCodes from .exceptions import ShopDBException + # A uniqueness violation is the caller's problem, not a server fault. Without + # this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and + # nothing usable on screen - the operator saw "internal server error" for + # having reused a code that was already taken. + from sqlalchemy.exc import IntegrityError + + @app.errorhandler(IntegrityError) + def handle_integrity_error(error): + from .extensions import db + db.session.rollback() + message = str(getattr(error, 'orig', error)) + app.logger.warning('integrity error: %s', message) + if 'Duplicate entry' in message or 'UNIQUE constraint' in message: + return error_response( + ErrorCodes.CONFLICT, + 'That value is already in use. Codes and identifiers must be unique.', + http_code=409) + if 'foreign key constraint' in message.lower(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'That record refers to something that does not exist, or is still in use elsewhere.', + http_code=400) + return error_response(ErrorCodes.VALIDATION_ERROR, + 'The database rejected that change.', http_code=400) + @app.errorhandler(ShopDBException) def handle_shopdb_exception(error): http_codes = { diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 8f0b47c4..2355e1df 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -10,6 +10,58 @@ def db_cli(): pass +@db_cli.command('seed-state') +@with_appcontext +def seed_state(): + """Report whether the seed data a working install depends on is present. + + A server whose seeds never ran does not fail politely: settings endpoints + answer 404 for keys that were never created and most pages answer 500, which + reads as a broken application rather than an unfinished install. This gives + the operator console something definite to test, so it can say "run repair" + instead of leaving somebody to infer it from unrelated errors. + + Prints one line per group and exits non-zero if anything is missing, so it + can be used as a gate as well as read by a person. + """ + import sys + from shopdb.extensions import db + from sqlalchemy import text + + # Sentinels, not exhaustive counts. Each is created by one of the three seed + # commands, so a zero here means that command never ran. + checks = [ + ('permissions', 'SELECT COUNT(*) FROM permissions', 'flask seed permissions'), + ('settings', 'SELECT COUNT(*) FROM settings', 'flask seed settings'), + ('asset types', 'SELECT COUNT(*) FROM assettypes', 'flask seed reference-data'), + ('location types', 'SELECT COUNT(*) FROM locationtypes', 'flask seed reference-data'), + ] + + missing = [] + for label, sql, remedy in checks: + try: + count = db.session.execute(text(sql)).scalar() or 0 + except Exception as exc: + click.echo(click.style(' MISSING ', fg='red') + + '%s - table unreadable (%s)' % (label, type(exc).__name__)) + missing.append((label, remedy)) + continue + if count == 0: + click.echo(click.style(' MISSING ', fg='red') + + '%s - none present, run: %s' % (label, remedy)) + missing.append((label, remedy)) + else: + click.echo(click.style(' OK ', fg='green') + '%s (%d)' % (label, count)) + + if missing: + click.echo('') + click.echo(click.style('%d group(s) missing. This server is not fully provisioned.' + % len(missing), fg='red')) + sys.exit(1) + click.echo('') + click.echo(click.style('Seed data present.', fg='green')) + + @db_cli.command('create-all') @with_appcontext def create_all(): @@ -147,6 +199,189 @@ def seed_cli(): pass +@seed_cli.command('catalog') +@click.option('--file', 'path', default=None, + help='catalog JSON to load (default shopdb/data/catalog.json)') +@click.option('--dry-run', is_flag=True, help='report what would be added, write nothing') +@with_appcontext +def seed_catalog(path, dry_run): + """Load the shared vendor/model catalog shipped with the product. + + `seed reference-data` writes a dozen generic model types and no vendors or + models, so every new site began by retyping a catalog another site had + already built. This loads that catalog instead. + + IDEMPOTENT and ADDITIVE. Records are matched by natural key - a vendor by + name, a model by vendor plus model number, a type by its name - so running + it twice adds nothing the second time. It never updates or deletes an + existing record: a site that has corrected a description or pointed a model + at its own photo keeps its version. + + Catalog only. Nothing here identifies a site: no assets, locations, + employees or serial numbers. + """ + import json + import os + from shopdb.extensions import db + from shopdb.core.models import ModelType, OperatingSystem, LocationType, Vendor, Model + + if not path: + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'data', 'catalog.json') + if not os.path.isfile(path): + click.echo(click.style('No catalog file at %s' % path, fg='red')) + raise SystemExit(1) + + with open(path, encoding='utf-8') as handle: + data = json.load(handle) + + added = {} + + def note(kind, count): + if count: + added[kind] = added.get(kind, 0) + count + + def simple(key, model, column): + """Type vocabularies: one row per name.""" + records = data.get(key) or [] + count = 0 + for record in records: + name = record.get(column) + if not name: + continue + if db.session.query(model).filter_by(**{column: name}).first(): + continue + db.session.add(model(**{k: v for k, v in record.items() + if hasattr(model, k)})) + count += 1 + note(key, count) + + simple('modeltypes', ModelType, 'modeltype') + simple('locationtypes', LocationType, 'locationtype') + simple('operatingsystems', OperatingSystem, 'osname') + + # Per-plugin type tables exist only where that plugin is installed, so they + # are loaded through the ORM registry rather than imported directly - a lean + # build without printers must not fail here. + plugin_types = [ + ('machinetypes', 'machinetype'), + ('computertypes', 'computertype'), + ('printertypes', 'printertype'), + ('networkdevicetypes', 'networkdevicetype'), + ] + by_table = {m.class_.__tablename__: m.class_ for m in db.Model.registry.mappers} + for key, column in plugin_types: + model = by_table.get(key) + if model is None: + continue + simple(key, model, column) + + # Vendors before models, since a model resolves its vendor by name. + count = 0 + for record in data.get('vendors') or []: + name = record.get('vendor') + if not name or Vendor.query.filter_by(vendor=name).first(): + continue + db.session.add(Vendor(**{k: v for k, v in record.items() if hasattr(Vendor, k)})) + count += 1 + note('vendors', count) + db.session.flush() + + vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()} + modeltype_ids = {m.modeltype: m.modeltypeid for m in ModelType.query.all()} + + count = 0 + skipped_vendor = 0 + for record in data.get('models') or []: + modelnumber = record.get('modelnumber') + if not modelnumber: + continue + vendorid = vendor_ids.get(record.get('vendor')) + # The catalog's unique key is model number PLUS vendor, so the same + # number from two makers stays two records. + if Model.query.filter_by(modelnumber=modelnumber, vendorid=vendorid).first(): + continue + if record.get('vendor') and vendorid is None: + skipped_vendor += 1 + continue + db.session.add(Model( + modelnumber=modelnumber, + vendorid=vendorid, + modeltypeid=modeltype_ids.get(record.get('modeltype')), + description=record.get('description'), + documentationurl=record.get('documentationurl'), + imageurl=record.get('imageurl'), + )) + count += 1 + note('models', count) + + # Small plugin vocabularies, loaded through the registry so a lean build + # missing that plugin skips them instead of failing to import. + for key, model_table, column in (('measuringtooltypes', 'measuringtooltypes', 'name'), + ('notificationtypes', 'notificationtypes', 'typename'), + ('accessprotocols', 'accessprotocols', 'name')): + model = by_table.get(model_table) + if model is not None: + simple(key, model, column) + + # Printer supplies. Resolved against the models loaded above, by the model's + # natural key - part numbers are useless attached to the wrong printer. + supply_model = by_table.get('modelsupplies') + if supply_model is not None and (data.get('modelsupplies') or []): + db.session.flush() + model_key = {} + for m in Model.query.all(): + model_key[(m.modelnumber, m.vendorid)] = m.modelnumberid + vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()} + + count = 0 + orphaned = 0 + for record in data['modelsupplies']: + partnumber = record.get('partnumber') + modelnumber = record.get('modelnumber') + if not partnumber or not modelnumber: + continue + modelnumberid = model_key.get((modelnumber, vendor_ids.get(record.get('vendor')))) + if modelnumberid is None: + orphaned += 1 + continue + if db.session.query(supply_model).filter_by( + modelnumberid=modelnumberid, partnumber=partnumber).first(): + continue + db.session.add(supply_model( + modelnumberid=modelnumberid, + supplytype=record.get('supplytype') or 'toner', + color=record.get('color') or 'none', + capacitytier=record.get('capacitytier') or 'standard', + partnumber=partnumber, + marketingname=record.get('marketingname'), + pageyield=record.get('pageyield'), + notes=record.get('notes'), + )) + count += 1 + note('modelsupplies', count) + if orphaned: + click.echo(click.style(' %d supply record(s) skipped: their model is not in this catalog' + % orphaned, fg='yellow')) + + if dry_run: + db.session.rollback() + click.echo(click.style('DRY RUN - nothing written.', fg='yellow')) + else: + db.session.commit() + + if not added: + click.echo(click.style('Catalog already present, nothing to add.', fg='green')) + else: + for kind in sorted(added): + click.echo(' %-22s +%d' % (kind, added[kind])) + click.echo('') + click.echo(click.style('Catalog loaded from %s' % os.path.basename(path), fg='green')) + if skipped_vendor: + click.echo(click.style(' %d model(s) skipped: their vendor is not in this catalog' + % skipped_vendor, fg='yellow')) + + @seed_cli.command('reference-data') @with_appcontext def seed_reference_data(): diff --git a/shopdb/data/catalog.json b/shopdb/data/catalog.json new file mode 100644 index 00000000..c2a7b917 --- /dev/null +++ b/shopdb/data/catalog.json @@ -0,0 +1,2779 @@ +{ + "modeltypes": [ + { + "modeltype": "5-axis Mill", + "category": "Equipment" + }, + { + "modeltype": "Access Point", + "category": "Network", + "description": "Wireless access point for network connectivity" + }, + { + "modeltype": "Broach", + "category": "Equipment" + }, + { + "modeltype": "Camera", + "category": "Network", + "description": "Security camera for facility monitoring" + }, + { + "modeltype": "CMM", + "category": "Equipment", + "description": "A coordinate-measuring machine (CMM) is a device that measures the geometry of physical objects by sensing discrete points on the surface of the object with a probe." + }, + { + "modeltype": "CNC Lathe", + "category": "Equipment", + "description": "CNC Lathe" + }, + { + "modeltype": "CNC Mill", + "category": "Equipment", + "description": "CNC Milling Machine" + }, + { + "modeltype": "Desktop PC", + "category": "PC", + "description": "Desktop Computer" + }, + { + "modeltype": "Eddy Current", + "category": "Equipment", + "description": "Wild Stallions will never be a super band until we have Eddie Van Halen on guitar." + }, + { + "modeltype": "EDM", + "category": "Equipment", + "description": "Electrical Discharge Machine" + }, + { + "modeltype": "Furnace", + "category": "Equipment" + }, + { + "modeltype": "Grinder", + "category": "Equipment" + }, + { + "modeltype": "Hobbing Machine", + "category": "Equipment" + }, + { + "modeltype": "Horizontal Machining Center", + "category": "Equipment" + }, + { + "modeltype": "IDF", + "category": "Network", + "description": "Intermediate Distribution Frame - network equipment closet" + }, + { + "modeltype": "Inspection Station", + "category": "Equipment", + "description": "Inspection Station" + }, + { + "modeltype": "Intertia Welder", + "category": "Equipment" + }, + { + "modeltype": "Laptop", + "category": "PC", + "description": "Laptop Computer" + }, + { + "modeltype": "Lathe", + "category": "Equipment", + "description": "The Okuma & Howa 2SPV80 is a CNC machine renowned for its precision and efficiency in manufacturing processes. It is widely utilized across various sectors, facilitating the production of intricate components with ease. Industries such as automotive, aerospace, and metalworking benefit from its capabilities. " + }, + { + "modeltype": "LocationOnly", + "category": "Equipment" + }, + { + "modeltype": "Measuring Machine", + "category": "Equipment" + }, + { + "modeltype": "Mill Turn", + "category": "Equipment" + }, + { + "modeltype": "Part Marker", + "category": "Equipment", + "description": "Dot peen markers provide a reliable, permanent method for marking materials with information that is crucial for product identification and traceability in various industrial applications." + }, + { + "modeltype": "Part Washer", + "category": "Equipment" + }, + { + "modeltype": "PC", + "category": "PC", + "description": "Standard user workstation computer" + }, + { + "modeltype": "Printer", + "category": "Printer", + "description": "Network printer - HP, Xerox, or other print devices" + }, + { + "modeltype": "Robotic Deburring", + "category": "Equipment" + }, + { + "modeltype": "Server", + "category": "Network", + "description": "Physical or virtual server" + }, + { + "modeltype": "Shopfloor PC", + "category": "PC", + "description": "Shopfloor Computer" + }, + { + "modeltype": "Shotpeen", + "category": "Equipment", + "description": "Shot peening is a cold working process used to produce a compressive residual stress layer and modify the mechanical properties of metals and composites." + }, + { + "modeltype": "Switch", + "category": "Network", + "description": "Network switch for connectivity" + }, + { + "modeltype": "USB Device", + "category": "Equipment" + }, + { + "modeltype": "Vertical Lathe", + "category": "Equipment" + }, + { + "modeltype": "Vertical Turning Center", + "category": "Equipment" + }, + { + "modeltype": "Wax Trace", + "category": "Equipment" + } + ], + "machinetypes": [ + { + "machinetype": "5-axis Mill" + }, + { + "machinetype": "Broach" + }, + { + "machinetype": "CMM", + "description": "A coordinate-measuring machine (CMM) is a device that measures the geometry of physical objects by sensing discrete points on the surface of the object with a probe.", + "color": "#8e44ad" + }, + { + "machinetype": "Eddy Current", + "description": "Wild Stallions will never be a super band until we have Eddie Van Halen on guitar." + }, + { + "machinetype": "Furnace" + }, + { + "machinetype": "Grinder" + }, + { + "machinetype": "Hobbing Machine" + }, + { + "machinetype": "Horizontal Machining Center" + }, + { + "machinetype": "Intertia Welder" + }, + { + "machinetype": "Lathe", + "description": "The Okuma & Howa 2SPV80 is a CNC machine renowned for its precision and efficiency in manufacturing processes. It is widely utilized across various sectors, facilitating the production of intricate components with ease. Industries such as automotive, aerospace, and metalworking benefit from its capabilities. " + }, + { + "machinetype": "LocationOnly" + }, + { + "machinetype": "Measuring Machine" + }, + { + "machinetype": "Mill Turn" + }, + { + "machinetype": "Part Marker", + "description": "Dot peen markers provide a reliable, permanent method for marking materials with information that is crucial for product identification and traceability in various industrial applications." + }, + { + "machinetype": "Part Washer" + }, + { + "machinetype": "Robotic Deburring" + }, + { + "machinetype": "Shotpeen", + "description": "Shot peening is a cold working process used to produce a compressive residual stress layer and modify the mechanical properties of metals and composites." + }, + { + "machinetype": "USB Device" + }, + { + "machinetype": "Vertical Lathe" + }, + { + "machinetype": "Vertical Turning Center" + }, + { + "machinetype": "Wax Trace" + } + ], + "computertypes": [ + { + "computertype": "CMM", + "color": "#7934f3" + }, + { + "computertype": "Engineer" + }, + { + "computertype": "Genspect" + }, + { + "computertype": "Heat Treat" + }, + { + "computertype": "Keyence" + }, + { + "computertype": "Part Marker" + }, + { + "computertype": "Shopfloor" + }, + { + "computertype": "Standard" + }, + { + "computertype": "Uncategorized" + }, + { + "computertype": "Wax / Trace" + } + ], + "printertypes": [ + { + "printertype": "Card", + "description": "ID / card printer", + "color": "#2dce89" + }, + { + "printertype": "Dot Matrix", + "description": "Dot matrix printer" + }, + { + "printertype": "Inkjet", + "description": "Inkjet printer" + }, + { + "printertype": "Label", + "description": "Label / receipt printer" + }, + { + "printertype": "Laser", + "description": "Laser printer (toner)" + }, + { + "printertype": "MFP", + "description": "Multifunction printer with scan/copy/fax" + }, + { + "printertype": "Other", + "description": "Unclassified / other printer" + }, + { + "printertype": "Plotter", + "description": "Large format plotter" + }, + { + "printertype": "Thermal", + "description": "Thermal printer" + } + ], + "networkdevicetypes": [ + { + "networkdevicetype": "Access Point", + "description": "Wireless access point for network connectivity" + }, + { + "networkdevicetype": "Camera", + "description": "Security camera for facility monitoring" + }, + { + "networkdevicetype": "IDF", + "description": "Intermediate Distribution Frame - network equipment closet" + }, + { + "networkdevicetype": "Server", + "description": "Physical or virtual server" + }, + { + "networkdevicetype": "Switch", + "description": "Network switch for connectivity" + } + ], + "locationtypes": [ + { + "locationtype": "building" + }, + { + "locationtype": "cell" + }, + { + "locationtype": "hallway" + }, + { + "locationtype": "lab" + }, + { + "locationtype": "meetingroom" + }, + { + "locationtype": "networkcloset" + }, + { + "locationtype": "office" + }, + { + "locationtype": "operation" + }, + { + "locationtype": "section" + }, + { + "locationtype": "storage" + }, + { + "locationtype": "subcell" + } + ], + "operatingsystems": [ + { + "osname": "Linux", + "osversion": "Various" + }, + { + "osname": "Microsoft Windows 10 Enterprise" + }, + { + "osname": "Microsoft Windows 10 Enterprise 10.0.19045" + }, + { + "osname": "Microsoft Windows 10 Enterprise 2016 LTSB" + }, + { + "osname": "Microsoft Windows 10 Enterprise LTSC" + }, + { + "osname": "Microsoft Windows 10 Pro" + }, + { + "osname": "Microsoft Windows 11 Enterprise" + }, + { + "osname": "MicrosoftWindows10Enterprise" + }, + { + "osname": "TBD" + }, + { + "osname": "Windows 10", + "osversion": "10.0" + }, + { + "osname": "Windows 11", + "osversion": "11.0" + }, + { + "osname": "Windows Server 2019", + "osversion": "2019" + }, + { + "osname": "Windows Server 2022", + "osversion": "2022" + }, + { + "osname": "Windows10" + } + ], + "vendors": [ + { + "vendor": "ABTech" + }, + { + "vendor": "Allen-Bradley" + }, + { + "vendor": "Broach" + }, + { + "vendor": "Brother", + "website": "https://www.brother.com" + }, + { + "vendor": "Brown/Sharpe" + }, + { + "vendor": "Campbell" + }, + { + "vendor": "Cisco" + }, + { + "vendor": "Dell" + }, + { + "vendor": "Dell Inc." + }, + { + "vendor": "DellInc." + }, + { + "vendor": "DMG Mori" + }, + { + "vendor": "Doosan" + }, + { + "vendor": "DT" + }, + { + "vendor": "Eddy" + }, + { + "vendor": "Epson" + }, + { + "vendor": "Fanuc" + }, + { + "vendor": "Fidia" + }, + { + "vendor": "Gleason-Pfauter" + }, + { + "vendor": "GM Enterprises" + }, + { + "vendor": "Grob" + }, + { + "vendor": "Haas Automation" + }, + { + "vendor": "Hexagon" + }, + { + "vendor": "HID" + }, + { + "vendor": "HP" + }, + { + "vendor": "HP Inc.", + "website": "https://www.hp.com" + }, + { + "vendor": "Hwacheon" + }, + { + "vendor": "Keyence" + }, + { + "vendor": "LaPointe" + }, + { + "vendor": "Lenovo", + "website": "https://www.lenovo.com" + }, + { + "vendor": "Makino" + }, + { + "vendor": "Microsoft", + "website": "https://www.microsoft.com" + }, + { + "vendor": "Mitutoyo" + }, + { + "vendor": "MTI" + }, + { + "vendor": "Network Rack" + }, + { + "vendor": "OKK" + }, + { + "vendor": "Okuma" + }, + { + "vendor": "Phoenix Inc" + }, + { + "vendor": "Progessive" + }, + { + "vendor": "QEMU" + }, + { + "vendor": "Ransohoff" + }, + { + "vendor": "Siemens" + }, + { + "vendor": "TBD" + }, + { + "vendor": "Telesis" + }, + { + "vendor": "Test" + }, + { + "vendor": "Toshulin" + }, + { + "vendor": "UnscopedLiveVendor" + }, + { + "vendor": "VM-Ware" + }, + { + "vendor": "WJDT" + }, + { + "vendor": "Xerox" + }, + { + "vendor": "Zebra" + }, + { + "vendor": "Zebra Technologies", + "website": "https://www.zebra.com" + }, + { + "vendor": "Zeiss" + }, + { + "vendor": "Zoller" + } + ], + "models": [ + { + "modelnumber": "EAS1000", + "vendor": "ABTech", + "modeltype": "Measuring Machine", + "imageurl": "/images/models/machines/abtech-eas1000.png" + }, + { + "modelnumber": "Allen-Bradley", + "vendor": "Allen-Bradley", + "description": "Allen-Bradley Controller" + }, + { + "modelnumber": "Vertical Broach", + "vendor": "Broach", + "modeltype": "Broach" + }, + { + "modelnumber": "Model One", + "vendor": "Brown/Sharpe", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "1000C1000", + "vendor": "Campbell", + "modeltype": "Grinder", + "imageurl": "/images/models/machines/1000C1000.jpg" + }, + { + "modelnumber": "9120AXI-B", + "vendor": "Cisco", + "modeltype": "Access Point", + "imageurl": "/images/models/machines/Cisco9120.png" + }, + { + "modelnumber": "C9200-48P-E", + "vendor": "Cisco", + "modeltype": "Switch" + }, + { + "modelnumber": "C9500-24Y4C-A", + "vendor": "Cisco", + "modeltype": "Switch" + }, + { + "modelnumber": "OptiPlex5000", + "vendor": "Dell" + }, + { + "modelnumber": "Dell Pro 13 Plus PB13250", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Dell Pro 14 Plus PB14250", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Dell Pro Tower Plus QBT1250", + "vendor": "Dell Inc." + }, + { + "modelnumber": "Latitude 5340", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "Latitude 5350", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "Latitude 5430", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Latitude 5440", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly", + "imageurl": "/images/models/machines/latitude5440.png" + }, + { + "modelnumber": "Latitude 5450", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly", + "imageurl": "/images/models/computers/Latitude-5450.png" + }, + { + "modelnumber": "OptiPlex 3080", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "OptiPlex 5040", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "OptiPlex 5050", + "vendor": "Dell Inc.", + "modeltype": "PC", + "imageurl": "/images/models/computers/Optiplex-5050.png" + }, + { + "modelnumber": "OptiPlex 5060", + "vendor": "Dell Inc.", + "modeltype": "PC", + "imageurl": "/images/models/computers/Optiplex-5060.png" + }, + { + "modelnumber": "OptiPlex 7000", + "vendor": "Dell Inc.", + "modeltype": "PC", + "imageurl": "/images/models/computers/Optiplex-7000.png" + }, + { + "modelnumber": "OptiPlex 7070", + "vendor": "Dell Inc.", + "modeltype": "PC", + "imageurl": "/images/models/machines/OptiPlex-7070.png" + }, + { + "modelnumber": "OptiPlex 7080", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly", + "imageurl": "/images/models/computers/Optiplex-7080.jpg" + }, + { + "modelnumber": "OptiPlex 7090", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "OptiPlex Micro 7020", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "OptiPlex Tower Plus 7010", + "vendor": "Dell Inc.", + "modeltype": "PC", + "imageurl": "/images/models/computers/OptiPlex-Tower-Plus-7010.png" + }, + { + "modelnumber": "OptiPlex Tower Plus 7020", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "Precision 5560", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly", + "imageurl": "/images/models/machines/precision5560.jpg" + }, + { + "modelnumber": "Precision 5570", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 5680", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 5690", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 5820 Tower", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 5860 Tower", + "vendor": "Dell Inc." + }, + { + "modelnumber": "Precision 7680", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 7750", + "vendor": "Dell Inc.", + "modeltype": "PC" + }, + { + "modelnumber": "Precision 7780", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Precision 7875 Tower", + "vendor": "Dell Inc.", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "OptiPlex7080", + "vendor": "DellInc." + }, + { + "modelnumber": "NT-4300", + "vendor": "DMG Mori", + "modeltype": "Mill Turn", + "imageurl": "/images/models/machines/nt4300.jpg" + }, + { + "modelnumber": "Puma MX3100", + "vendor": "Doosan", + "modeltype": "Mill Turn", + "imageurl": "/images/models/machines/mx3100.png" + }, + { + "modelnumber": "IDF", + "vendor": "DT", + "modeltype": "IDF" + }, + { + "modelnumber": "Server", + "vendor": "DT", + "modeltype": "Server" + }, + { + "modelnumber": "EZ-Eddy", + "vendor": "Eddy", + "modeltype": "Eddy Current", + "imageurl": "/images/models/machines/ezeddy.png" + }, + { + "modelnumber": "TM-C3500", + "vendor": "Epson", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/Epson-C3500.png" + }, + { + "modelnumber": "0i-TC", + "vendor": "Fanuc", + "description": "FANUC Series 0i-TC Controller" + }, + { + "modelnumber": "18i-TB", + "vendor": "Fanuc", + "description": "FANUC Series 18i-TB Controller" + }, + { + "modelnumber": "31i", + "vendor": "Fanuc", + "description": "FANUC Series 31i Controller" + }, + { + "modelnumber": "31i-A", + "vendor": "Fanuc", + "description": "FANUC Series 31i-MODEL A Controller" + }, + { + "modelnumber": "31i-A5", + "vendor": "Fanuc", + "description": "FANUC Series 31i-MODEL A5 Controller" + }, + { + "modelnumber": "31i-B", + "vendor": "Fanuc", + "description": "FANUC Series 31i-MODEL B Controller" + }, + { + "modelnumber": "31i-B5", + "vendor": "Fanuc", + "description": "FANUC Series 31i-MODEL B5 Controller" + }, + { + "modelnumber": "32i-B", + "vendor": "Fanuc", + "description": "FANUC Series 32i-MODEL B Controller" + }, + { + "modelnumber": "M710UC", + "vendor": "Fanuc", + "modeltype": "Robotic Deburring", + "imageurl": "/images/models/machines/M710uc.png" + }, + { + "modelnumber": "Panel i", + "vendor": "Fanuc", + "description": "FANUC Panel i Controller" + }, + { + "modelnumber": "Power Motion i-A", + "vendor": "Fanuc", + "description": "FANUC Power Motion i-MODEL A Controller" + }, + { + "modelnumber": "Professional 5", + "vendor": "Fanuc", + "description": "FANUC Professional 5 Controller" + }, + { + "modelnumber": "R-30iA", + "vendor": "Fanuc", + "description": "FANUC System R-30iA Controller" + }, + { + "modelnumber": "R-30iB", + "vendor": "Fanuc", + "description": "FANUC System R-30iB Controller" + }, + { + "modelnumber": "Unknown", + "vendor": "Fanuc", + "description": "FANUC (Unknown Model) Controller" + }, + { + "modelnumber": "D218", + "vendor": "Fidia", + "modeltype": "5-axis Mill", + "imageurl": "/images/models/machines/d218.png" + }, + { + "modelnumber": "FIDIA C20", + "vendor": "Fidia", + "description": "FIDIA C20 Controller" + }, + { + "modelnumber": "P600S", + "vendor": "Gleason-Pfauter", + "modeltype": "Hobbing Machine", + "imageurl": "/images/models/machines/p600s.png" + }, + { + "modelnumber": "Vacuum Furnace", + "vendor": "GM Enterprises", + "modeltype": "Furnace", + "imageurl": "/images/models/machines/furnace.png" + }, + { + "modelnumber": "550T", + "vendor": "Grob", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "G750", + "vendor": "Grob", + "modeltype": "Mill Turn", + "imageurl": "/images/models/machines/g750.jpg" + }, + { + "modelnumber": "7.10.7 SF", + "vendor": "Hexagon", + "modeltype": "CMM", + "imageurl": "/images/models/machines/7107sf.png" + }, + { + "modelnumber": "CMM", + "vendor": "Hexagon", + "modeltype": "CMM", + "imageurl": "/images/models/machines/cmm.png" + }, + { + "modelnumber": "Global Advantage", + "vendor": "Hexagon", + "modeltype": "CMM" + }, + { + "modelnumber": "DTC 4500e", + "vendor": "HID", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/DTC4500e.png" + }, + { + "modelnumber": "Color Laserjet CP2025", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/LaserJet -CP2025.png" + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-M254dw.png" + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "HP DesignJet T1700dr PS", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/HP-DesignJet-T1700dr.png" + }, + { + "modelnumber": "HP LaserJet M610 / M611 / M612 / M634 / M635 / M636", + "vendor": "HP", + "modeltype": "Printer" + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "imageurl": "/images/models/printers/Laserjet-Pro-M251nw.png" + }, + { + "modelnumber": "LaserJet 4250tn", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-4250.png" + }, + { + "modelnumber": "LaserJet 4250tn DO NOT USE", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/LaserJet-4250.png" + }, + { + "modelnumber": "LaserJet M406", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/LaserJet-M406.png" + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-M454dn.png" + }, + { + "modelnumber": "LaserJet M506", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/LaserJet-M506.png" + }, + { + "modelnumber": "LaserJet M602", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-M602.png" + }, + { + "modelnumber": "LaserJet P3015dn", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-P3015dn.png" + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/Laserjet-Pro-M251nw.png" + }, + { + "modelnumber": "LaserJet Pro 4001n", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-4001n.png" + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "imageurl": "/images/models/printers/LaserJet-Pro-M252dw.png" + }, + { + "modelnumber": "LaserJet Pro M404n", + "vendor": "HP", + "modeltype": "Printer", + "imageurl": "/images/models/printers/LaserJet-M404.png" + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "imageurl": "/images/models/printers/LaserJet-M454dn.png" + }, + { + "modelnumber": "LaserJet Pro M607", + "vendor": "HP", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/LaserJet-M607.png" + }, + { + "modelnumber": "VT 550 2SP", + "vendor": "Hwacheon", + "modeltype": "Vertical Turning Center", + "imageurl": "/images/models/machines/vt5502sp.png" + }, + { + "modelnumber": "VR-3100", + "vendor": "Keyence", + "modeltype": "Measuring Machine", + "imageurl": "/images/models/machines/keyence-vr3100.png" + }, + { + "modelnumber": "Horizontal Broach", + "vendor": "LaPointe", + "modeltype": "Broach", + "imageurl": "/images/models/machines/hbroach.png" + }, + { + "modelnumber": "a81nx", + "vendor": "Makino", + "modeltype": "Horizontal Machining Center", + "imageurl": "/images/models/machines/a81nx.png" + }, + { + "modelnumber": "C-4500", + "vendor": "Mitutoyo", + "modeltype": "Wax Trace", + "imageurl": "/images/models/machines/c4500.png" + }, + { + "modelnumber": "CV-3200", + "vendor": "Mitutoyo", + "modeltype": "Wax Trace" + }, + { + "modelnumber": "480S", + "vendor": "MTI", + "modeltype": "Intertia Welder" + }, + { + "modelnumber": "Network Rack", + "vendor": "Network Rack" + }, + { + "modelnumber": "VP9000", + "vendor": "OKK", + "modeltype": "5-axis Mill", + "imageurl": "/images/models/machines/vp9000.jpg" + }, + { + "modelnumber": "2SP-V80", + "vendor": "Okuma", + "modeltype": "Lathe" + }, + { + "modelnumber": "LOC650", + "vendor": "Okuma", + "modeltype": "Mill Turn", + "imageurl": "/images/models/machines/loc650.png" + }, + { + "modelnumber": "Multus", + "vendor": "Okuma", + "modeltype": "Mill Turn" + }, + { + "modelnumber": "OKUMA OSP-P300L", + "vendor": "Okuma", + "description": "OKUMA OSP-P300L Controller" + }, + { + "modelnumber": "OKUMA OSP-P300LA", + "vendor": "Okuma", + "description": "OKUMA OSP-P300LA Controller" + }, + { + "modelnumber": "VTM-100", + "vendor": "Okuma", + "modeltype": "Vertical Lathe", + "imageurl": "/images/models/machines/vtm100.png" + }, + { + "modelnumber": "Phoenix Broach", + "vendor": "Phoenix Inc", + "modeltype": "Broach", + "imageurl": "/images/models/machines/phoenixbroach.png" + }, + { + "modelnumber": "Shotpeen", + "vendor": "Progessive", + "modeltype": "Shotpeen", + "imageurl": "/images/models/machines/shotpeen.png" + }, + { + "modelnumber": "Ubuntu 24.04 PC (Q35 + ICH9, 2009)", + "vendor": "QEMU" + }, + { + "modelnumber": "Lean Drum", + "vendor": "Ransohoff", + "modeltype": "Part Washer", + "imageurl": "/images/models/machines/leandrum.jpg" + }, + { + "modelnumber": "LeanJet RB-2", + "vendor": "Ransohoff", + "modeltype": "Part Washer", + "imageurl": "/images/models/machines/rb2.png" + }, + { + "modelnumber": "SIEMENS", + "vendor": "Siemens", + "description": "SIEMENS Controller" + }, + { + "modelnumber": "SIEMENS SIMATIC PANEL TOUCH", + "vendor": "Siemens", + "description": "SIEMENS SIMATIC PANEL TOUCH Controller" + }, + { + "modelnumber": "SIEMENS SINUMERIK", + "vendor": "Siemens", + "description": "SIEMENS SINUMERIK Controller" + }, + { + "modelnumber": "PINSTAMP - TMP4210 Dot Peen", + "vendor": "Telesis", + "modeltype": "Part Marker" + }, + { + "modelnumber": "Test", + "vendor": "Test" + }, + { + "modelnumber": "Powerturn", + "vendor": "Toshulin", + "modeltype": "Vertical Turning Center", + "imageurl": "/images/models/machines/powerturn.png" + }, + { + "modelnumber": "Virtual", + "vendor": "VM-Ware", + "modeltype": "Server" + }, + { + "modelnumber": "TBD", + "vendor": "WJDT", + "modeltype": "LocationOnly" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/AltaLink-C8130.png" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "modeltype": "Printer", + "imageurl": "/images/models/printers/Xerox-EC8036.png" + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/Versalink-B405.png" + }, + { + "modelnumber": "Versalink B7125", + "vendor": "Xerox", + "modeltype": "Printer", + "imageurl": "/images/models/printers/Versalink-B7125.png" + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/Versalink-C405.png" + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox" + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/Versalink-C7125.png" + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "modeltype": "Printer", + "imageurl": "/images/models/printers/Versalink-C7125.png" + }, + { + "modelnumber": "Zebra ZT411", + "vendor": "Zebra", + "modeltype": "LocationOnly", + "imageurl": "/images/models/printers/zt411.png" + }, + { + "modelnumber": "Redomatic 600", + "vendor": "Zoller", + "modeltype": "Measuring Machine" + }, + { + "modelnumber": "ZOLLER", + "vendor": "Zoller", + "description": "ZOLLER Controller" + } + ], + "modelsupplies": [ + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF500A", + "marketingname": "202A Black", + "pageyield": 1400 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF500X", + "marketingname": "202X Black", + "pageyield": 3200 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "CF501A", + "marketingname": "202A Cyan", + "pageyield": 1300 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "CF501X", + "marketingname": "202X Cyan", + "pageyield": 2500 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "CF502A", + "marketingname": "202A Yellow", + "pageyield": 1300 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "CF502X", + "marketingname": "202X Yellow", + "pageyield": 2500 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "CF503A", + "marketingname": "202A Magenta", + "pageyield": 1300 + }, + { + "modelnumber": "Color LaserJet M254dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "CF503X", + "marketingname": "202X Magenta", + "pageyield": 2500 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "W2110A", + "marketingname": "206A Black", + "pageyield": 1350 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "W2110X", + "marketingname": "206X Black", + "pageyield": 3150 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "W2111A", + "marketingname": "206A Cyan", + "pageyield": 1250 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "W2111X", + "marketingname": "206X Cyan", + "pageyield": 2450 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "W2112A", + "marketingname": "206A Yellow", + "pageyield": 1250 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "W2112X", + "marketingname": "206X Yellow", + "pageyield": 2450 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "W2113A", + "marketingname": "206A Magenta", + "pageyield": 1250 + }, + { + "modelnumber": "Color LaserJet M255dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "W2113X", + "marketingname": "206X Magenta", + "pageyield": 2450 + }, + { + "modelnumber": "HP LaserJet M610 / M611 / M612 / M634 / M635 / M636", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "W1470A", + "marketingname": "147A Black", + "pageyield": 10500 + }, + { + "modelnumber": "HP LaserJet M610 / M611 / M612 / M634 / M635 / M636", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "W1470X", + "marketingname": "147X Black (not M610)", + "pageyield": 25200 + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF210A", + "marketingname": "131A Black", + "pageyield": 1600 + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF210X", + "marketingname": "131X Black", + "pageyield": 2400 + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "CF211A", + "marketingname": "131A Cyan", + "pageyield": 1800 + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "CF212A", + "marketingname": "131A Yellow", + "pageyield": 1800 + }, + { + "modelnumber": "LaserJet 200 color M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "CF213A", + "marketingname": "131A Magenta", + "pageyield": 1800 + }, + { + "modelnumber": "LaserJet 4250tn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "Q5942A", + "marketingname": "42A Black", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet 4250tn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "Q5942X", + "marketingname": "42X Black", + "pageyield": 20000 + }, + { + "modelnumber": "LaserJet 4250tn DO NOT USE", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "Q5942A", + "marketingname": "42A Black", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet 4250tn DO NOT USE", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "Q5942X", + "marketingname": "42X Black", + "pageyield": 20000 + }, + { + "modelnumber": "LaserJet M406", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF258A", + "marketingname": "58A Black", + "pageyield": 3000 + }, + { + "modelnumber": "LaserJet M406", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF258X", + "marketingname": "58X Black", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet M406", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "CF258XC", + "marketingname": "58X Black (Contract)", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "W2020A", + "marketingname": "414A Black", + "pageyield": 2400 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "W2020X", + "marketingname": "414X Black", + "pageyield": 7500 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "W2021A", + "marketingname": "414A Cyan", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "W2021X", + "marketingname": "414X Cyan", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "W2022A", + "marketingname": "414A Yellow", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "W2022X", + "marketingname": "414X Yellow", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "W2023A", + "marketingname": "414A Magenta", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet M454dn", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "W2023X", + "marketingname": "414X Magenta", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet M506", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF287A", + "marketingname": "87A Black", + "pageyield": 9000 + }, + { + "modelnumber": "LaserJet M506", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF287X", + "marketingname": "87X Black", + "pageyield": 18000 + }, + { + "modelnumber": "LaserJet M506", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "CF287XC", + "marketingname": "87X Black (Contract)", + "pageyield": 18000 + }, + { + "modelnumber": "LaserJet M602", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CE390A", + "marketingname": "90A Black", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet M602", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CE390X", + "marketingname": "90X Black (not M601)", + "pageyield": 24000 + }, + { + "modelnumber": "LaserJet M602", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "CE390XC", + "marketingname": "90X Black (Contract)", + "pageyield": 24000 + }, + { + "modelnumber": "LaserJet P3015dn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CE255A", + "marketingname": "55A Black", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet P3015dn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CE255X", + "marketingname": "55X Black", + "pageyield": 12500 + }, + { + "modelnumber": "LaserJet P3015dn", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "CE255XC", + "marketingname": "55X Black (Contract)", + "pageyield": 12500 + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF210A", + "marketingname": "131A Black", + "pageyield": 1600 + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF210X", + "marketingname": "131X Black", + "pageyield": 2400 + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "CF211A", + "marketingname": "131A Cyan", + "pageyield": 1800 + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "CF212A", + "marketingname": "131A Yellow", + "pageyield": 1800 + }, + { + "modelnumber": "Laserjet Pro 200 M251nw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "CF213A", + "marketingname": "131A Magenta", + "pageyield": 1800 + }, + { + "modelnumber": "LaserJet Pro 4001n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "W1020XC", + "marketingname": "148X Black (Contract)", + "pageyield": 9500 + }, + { + "modelnumber": "LaserJet Pro 4001n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "W1480A", + "marketingname": "148A Black", + "pageyield": 2900 + }, + { + "modelnumber": "LaserJet Pro 4001n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "W1480X", + "marketingname": "148X Black", + "pageyield": 9500 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF400A", + "marketingname": "201A Black", + "pageyield": 1500 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF400X", + "marketingname": "201X Black", + "pageyield": 2800 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "CF401A", + "marketingname": "201A Cyan", + "pageyield": 1400 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "CF401X", + "marketingname": "201X Cyan", + "pageyield": 2300 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "CF402A", + "marketingname": "201A Yellow", + "pageyield": 1400 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "CF402X", + "marketingname": "201X Yellow", + "pageyield": 2300 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "CF403A", + "marketingname": "201A Magenta", + "pageyield": 1400 + }, + { + "modelnumber": "LaserJet Pro M252dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "CF403X", + "marketingname": "201X Magenta", + "pageyield": 2300 + }, + { + "modelnumber": "LaserJet Pro M404n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF258A", + "marketingname": "58A Black", + "pageyield": 3000 + }, + { + "modelnumber": "LaserJet Pro M404n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "CF258X", + "marketingname": "58X Black", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet Pro M404n", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "CF258XC", + "marketingname": "58X Black (Contract)", + "pageyield": 10000 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "W2020A", + "marketingname": "414A Black", + "pageyield": 2400 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "W2020X", + "marketingname": "414X Black", + "pageyield": 7500 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "W2021A", + "marketingname": "414A Cyan", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "W2021X", + "marketingname": "414X Cyan", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "W2022A", + "marketingname": "414A Yellow", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "W2022X", + "marketingname": "414X Yellow", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "W2023A", + "marketingname": "414A Magenta", + "pageyield": 2100 + }, + { + "modelnumber": "LaserJet Pro M454dw", + "vendor": "HP", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "W2023X", + "marketingname": "414X Magenta", + "pageyield": 6000 + }, + { + "modelnumber": "LaserJet Pro M607", + "vendor": "HP", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "CF237A", + "marketingname": "37A Black", + "pageyield": 11000 + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R01742", + "marketingname": "C8135 Black (Metered)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "metered", + "partnumber": "006R01743", + "marketingname": "C8135 Cyan (Metered)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "metered", + "partnumber": "006R01744", + "marketingname": "C8135 Magenta (Metered)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "metered", + "partnumber": "006R01745", + "marketingname": "C8135 Yellow (Metered)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R01746", + "marketingname": "C8135 Black (Sold)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "006R01747", + "marketingname": "C8135 Cyan (Sold)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "006R01748", + "marketingname": "C8135 Magenta (Sold)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "006R01749", + "marketingname": "C8135 Yellow (Sold)" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "008R08101", + "marketingname": "C8135 Waste Cartridge" + }, + { + "modelnumber": "Altalink C8135", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00681", + "marketingname": "C8135 Drum" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R01509", + "marketingname": "EC8036 Black (Metered)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "metered", + "partnumber": "006R01510", + "marketingname": "EC8036 Yellow (Metered)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "metered", + "partnumber": "006R01511", + "marketingname": "EC8036 Magenta (Metered)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "metered", + "partnumber": "006R01512", + "marketingname": "EC8036 Cyan (Metered)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R01697", + "marketingname": "C8030 Black (Sold)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "006R01698", + "marketingname": "C8030 Cyan (Sold)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "006R01699", + "marketingname": "C8030 Magenta (Sold)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "006R01700", + "marketingname": "C8030 Yellow (Sold)" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "008R13061", + "marketingname": "C8030 / EC8036 Waste Cartridge" + }, + { + "modelnumber": "AltaLink EC8036", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00662", + "marketingname": "C8030 / EC8036 Drum" + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "101R00554", + "marketingname": "B400 / B405 Drum" + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "106R03580", + "marketingname": "B405 Black", + "pageyield": 5900 + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "106R03582", + "marketingname": "B405 Black (High)", + "pageyield": 13900 + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "extrahigh", + "partnumber": "106R03584", + "marketingname": "B405 Black (Extra High)", + "pageyield": 24600 + }, + { + "modelnumber": "Versalink B405DN", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "106R03586", + "marketingname": "B405 Black (Metered)", + "pageyield": 24600 + }, + { + "modelnumber": "Versalink B7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R01817", + "marketingname": "B7125 Black (Metered)", + "pageyield": 34300 + }, + { + "modelnumber": "Versalink B7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R01818", + "marketingname": "B7125 Black (Sold/High)", + "pageyield": 34300 + }, + { + "modelnumber": "Versalink B7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "dmo", + "partnumber": "006R01819", + "marketingname": "B7125 Black (DMO)", + "pageyield": 34300 + }, + { + "modelnumber": "Versalink B7125", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00687", + "marketingname": "B7125 Drum" + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "106R03500", + "marketingname": "C405 Black", + "pageyield": 2500 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "106R03501", + "marketingname": "C405 Yellow", + "pageyield": 2500 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "106R03502", + "marketingname": "C405 Cyan", + "pageyield": 2500 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "106R03503", + "marketingname": "C405 Magenta", + "pageyield": 2500 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "106R03512", + "marketingname": "C405 Black (High)", + "pageyield": 5000 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "extrahigh", + "partnumber": "106R03524", + "marketingname": "C405 Black (Extra High)", + "pageyield": 10500 + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "108R01121", + "marketingname": "C400 / C405 Drum" + }, + { + "modelnumber": "Versalink C405", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "108R01124", + "marketingname": "C400 / C405 Waste Cartridge" + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R04677", + "marketingname": "C415 Black", + "pageyield": 2400 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "006R04678", + "marketingname": "C415 Cyan", + "pageyield": 2000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "006R04679", + "marketingname": "C415 Magenta", + "pageyield": 2000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "006R04680", + "marketingname": "C415 Yellow", + "pageyield": 2000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "high", + "partnumber": "006R04685", + "marketingname": "C415 Black (High)", + "pageyield": 10500 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "high", + "partnumber": "006R04686", + "marketingname": "C415 Cyan (High)", + "pageyield": 7000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "high", + "partnumber": "006R04687", + "marketingname": "C415 Magenta (High)", + "pageyield": 7000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "high", + "partnumber": "006R04688", + "marketingname": "C415 Yellow (High)", + "pageyield": 7000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R04693", + "marketingname": "C415 Black (Metered)", + "pageyield": 15000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "metered", + "partnumber": "006R04694", + "marketingname": "C415 Cyan (Metered)", + "pageyield": 10000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "metered", + "partnumber": "006R04695", + "marketingname": "C415 Magenta (Metered)", + "pageyield": 10000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "metered", + "partnumber": "006R04696", + "marketingname": "C415 Yellow (Metered)", + "pageyield": 10000 + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "008R13325", + "marketingname": "C415 Waste Cartridge" + }, + { + "modelnumber": "Versalink C415", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00701", + "marketingname": "C415 Drum / Imaging Unit" + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R01820", + "marketingname": "C7125 Black (Metered)", + "pageyield": 22500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "metered", + "partnumber": "006R01821", + "marketingname": "C7125 Cyan (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "metered", + "partnumber": "006R01822", + "marketingname": "C7125 Magenta (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "metered", + "partnumber": "006R01823", + "marketingname": "C7125 Yellow (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R01824", + "marketingname": "C7125 Black (Sold)", + "pageyield": 31300 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "006R01825", + "marketingname": "C7125 Cyan (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "006R01826", + "marketingname": "C7125 Magenta (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "006R01827", + "marketingname": "C7125 Yellow (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00688", + "marketingname": "C7125 Drum" + }, + { + "modelnumber": "Versalink C7100", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "115R00128", + "marketingname": "C7100 Series Waste Cartridge" + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "metered", + "partnumber": "006R01820", + "marketingname": "C7125 Black (Metered)", + "pageyield": 22500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "metered", + "partnumber": "006R01821", + "marketingname": "C7125 Cyan (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "metered", + "partnumber": "006R01822", + "marketingname": "C7125 Magenta (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "metered", + "partnumber": "006R01823", + "marketingname": "C7125 Yellow (Metered)", + "pageyield": 15500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "black", + "capacitytier": "standard", + "partnumber": "006R01824", + "marketingname": "C7125 Black (Sold)", + "pageyield": 31300 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "cyan", + "capacitytier": "standard", + "partnumber": "006R01825", + "marketingname": "C7125 Cyan (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "magenta", + "capacitytier": "standard", + "partnumber": "006R01826", + "marketingname": "C7125 Magenta (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "toner", + "color": "yellow", + "capacitytier": "standard", + "partnumber": "006R01827", + "marketingname": "C7125 Yellow (Sold)", + "pageyield": 18500 + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "drum", + "color": "none", + "capacitytier": "standard", + "partnumber": "013R00688", + "marketingname": "C7125 Drum" + }, + { + "modelnumber": "Versalink C7125", + "vendor": "Xerox", + "supplytype": "waste", + "color": "none", + "capacitytier": "standard", + "partnumber": "115R00128", + "marketingname": "C7100 Series Waste Cartridge" + } + ], + "measuringtooltypes": [ + { + "name": "Bore Gage", + "description": "Bore / hole diameter gage", + "color": "#7934f3" + }, + { + "name": "Caliper", + "description": "Vernier / digital caliper", + "color": "#14abef" + }, + { + "name": "Gage Block Set", + "description": "Reference gage block set", + "color": "#e83e8c" + }, + { + "name": "Height Gage", + "description": "Height / vertical measuring gage", + "color": "#ffc107" + }, + { + "name": "Indicator", + "description": "Dial / test indicator", + "color": "#11cdef" + }, + { + "name": "Micrometer", + "description": "Outside / inside / depth micrometer", + "color": "#2dce89" + }, + { + "name": "Other", + "description": "Other measuring tool", + "color": "#6c757d" + }, + { + "name": "Thread Gage", + "description": "Go / no-go thread gage", + "color": "#fb6340" + } + ], + "notificationtypes": [ + { + "typename": "General", + "typecolor": "#28a745" + }, + { + "typename": "Recertification", + "typecolor": "#0d6efd" + }, + { + "typename": "Recognition", + "typecolor": "#ffc107" + } + ], + "accessprotocols": [ + { + "name": "RDP", + "scheme": "rdp", + "defaultport": 3389, + "linktemplate": "rdp://{host}:{port}" + }, + { + "name": "VNC", + "scheme": "vnc", + "defaultport": 5900, + "linktemplate": "com.realvnc.vncviewer.connect://{host}:{port}" + }, + { + "name": "WinRM", + "scheme": "https", + "defaultport": 5986, + "linktemplate": "https://{host}:{port}/wsman" + } + ] +} diff --git a/shopdb/utils/blankunique.py b/shopdb/utils/blankunique.py new file mode 100644 index 00000000..cd16c9ed --- /dev/null +++ b/shopdb/utils/blankunique.py @@ -0,0 +1,81 @@ +"""Store an empty optional unique field as NULL, not as an empty string. + +A column that is `unique=True` and nullable accepts any number of NULLs, which +is what makes "optional but unique" work at all. It accepts exactly ONE empty +string. So the first record saved with a blank code succeeds and the second +fails with "Duplicate entry '' for key", surfacing as a 500 from an endpoint +that did nothing wrong. + +That is not a business-unit problem. A dozen columns across core and the plugins +are unique and nullable - asset numbers, host names, item codes, subnet names - +and every one of them is a blank web form away from the same collision. Fixing +them one endpoint at a time would leave the next one to be discovered by a user. + +So it is fixed once, at the mapper: before any insert or update, a string column +that is unique and nullable and has been set to '' or whitespace is stored as +NULL instead. An empty string carries no information in a unique column - there +is no case where two records both meaning "blank" should collide - so nothing is +lost by normalising it. + +Registered from create_app. Applies to every model, including plugin models +loaded later, because it hooks the shared Mapper class rather than a list of +models known at import time. +""" + +from sqlalchemy import event, String, inspect +from sqlalchemy.orm import Session + +_CACHE = {} + + +def _blankable_columns(mapper): + """Unique, nullable, string columns on this mapper - worked out once each.""" + cached = _CACHE.get(mapper) + if cached is not None: + return cached + + columns = [] + for prop in mapper.column_attrs: + for column in prop.columns: + if not column.unique or not column.nullable: + continue + # Only text. A unique nullable integer cannot be handed '' by a form + # without failing type coercion long before it reaches here. + if not isinstance(column.type, String): + continue + columns.append(prop.key) + break + _CACHE[mapper] = columns + return columns + + +def _normalise_instance(obj): + try: + mapper = inspect(obj).mapper + except Exception: + return + for key in _blankable_columns(mapper): + value = getattr(obj, key, None) + if isinstance(value, str) and not value.strip(): + setattr(obj, key, None) + + +def _before_flush(session, _flush_context, _instances): + # Both new and modified: editing a record to clear its code has to become + # NULL for the same reason creating one with a blank code does. + for obj in list(session.new) + list(session.dirty): + _normalise_instance(obj) + + +def register_blank_unique_normaliser(): + """Hook every session flush, for every model including plugins'. + + Listening on Session rather than on individual mappers means models imported + later - which is every plugin model - are covered without registration, and + it avoids depending on mapper-level event semantics that differ across + SQLAlchemy versions. + """ + if getattr(register_blank_unique_normaliser, '_installed', False): + return + event.listen(Session, 'before_flush', _before_flush) + register_blank_unique_normaliser._installed = True