Files
shopdb-flask/scripts/export_catalog.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

184 lines
8.2 KiB
Python

"""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 pathlib import Path
from collections import OrderedDict
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
# Load .env before anything reads config. wsgi.py does this for the served app
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
# and config falls back to a hardcoded root@localhost URL. That fails on a real
# deployment with a bare 1045 Access denied for 'root', naming a user the site
# does not use - and on a box where those root credentials happen to work, it
# is worse, because the script silently reads or WRITES the wrong database.
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
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('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())