From ead5bd8f58e3c4fec19dfc29032eed111abd44bc Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 5 Aug 2026 13:16:24 -0400 Subject: [PATCH] Give the console a repair verb, and something real to check A server whose migrations or seeds never finished does not fail politely. Most pages answer 500 and settings endpoints answer 404 for keys that were never created, which reads as a broken application rather than an unfinished install. One site spent a morning being debugged that way. `shopdb-admin.ps1 repair` runs what stage 3 of the installer runs: db upgrade, plugin upgrade-all, and the three seeds. Every step is idempotent, so running it on a healthy server changes nothing, and each step runs independently so one failure does not silently skip the rest. `check` now says so before anyone has to infer it: THIS SERVER IS NOT FULLY PROVISIONED - seed data is missing (permissions, settings or reference data) Most pages will answer 500 until this is fixed. Run: shopdb-admin.ps1 repair That needs a real test to sit on, so `flask db-utils seed-state` reports each seed group and exits non-zero when any is missing. Verified by emptying the settings table inside a transaction: MISSING, exit 1, rollback clean. Without it the console check would have looked reassuring while testing nothing - an older build with no such command reports UNKNOWN rather than healthy, for the same reason. --- deploy/windows/installer/shopdb-admin.ps1 | 93 ++++++++- shopdb/cli/__init__.py | 235 ++++++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 index 43733b7..547c56c 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/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 8f0b47c..2355e1d 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():