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.
This commit is contained in:
cproudlock
2026-08-05 13:16:24 -04:00
parent 705dd771bd
commit ead5bd8f58
2 changed files with 327 additions and 1 deletions

View File

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