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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user