WJ legacy-import reference loader: harness + reference/employees stages
Layer 2 of the import design (see the memory + scratchpad/IMPORT-PLAN.md): a SITE-SPECIFIC reference loader that maps WJ's classic-ASP schema onto the maintained, schema-agnostic IMPORT-API contract. Other sites copy the pattern against their own source DB; nobody runs this loader as-is. Harness (scripts/site_imports/wjf/harness.py): builds the app against the current DATABASE_URL (point it at a throwaway import DB), mints an unscoped admin PAT in-process, and drives the real import endpoints through the app test client with Authorization: Bearer + X-Import-Mode - exercising the same routes/authz/validation an HTTP client would, no running server needed. Read-only pymysql access to the three scratch source DBs; legacy-id -> new-id crosswalks persist to JSON so a crashed run resumes and later stages resolve FKs. Stages implemented + verified idempotent against a fresh scratch target (shopdb_flask_import): reference (vendors 46, businessunits 13, operatingsystems 11) and employees (directory 415, re-run updated-not-duplicated). Remaining stages (models, applications, assets hub + crosswalk, dependents, network, usb, verify) are stubbed with the same shape; README documents the adoption playbook. idmap.json is generated state (gitignored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -75,3 +75,4 @@ secrets.yml
|
|||||||
*_secret
|
*_secret
|
||||||
*_secrets
|
*_secrets
|
||||||
credentials.json
|
credentials.json
|
||||||
|
scripts/site_imports/wjf/idmap.json
|
||||||
|
|||||||
0
scripts/site_imports/__init__.py
Normal file
0
scripts/site_imports/__init__.py
Normal file
45
scripts/site_imports/wjf/README.md
Normal file
45
scripts/site_imports/wjf/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# WJ classic-ASP import loader (reference implementation)
|
||||||
|
|
||||||
|
This is **site-specific reference glue**, not product code. It maps West
|
||||||
|
Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the
|
||||||
|
maintained, schema-agnostic import contract in `docs/IMPORT-API.md`.
|
||||||
|
|
||||||
|
**Every adopting site has its own source database.** Nobody else runs this
|
||||||
|
loader. Instead, copy the pattern:
|
||||||
|
|
||||||
|
1. Point the harness at your source DB(s) (edit `harness.Source`).
|
||||||
|
2. Write per-entity stages that read your tables and POST to the same
|
||||||
|
`docs/IMPORT-API.md` endpoints with `Authorization: Bearer <admin PAT>` and
|
||||||
|
`X-Import-Mode: true`.
|
||||||
|
3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages
|
||||||
|
resolve foreign keys and a crashed run resumes.
|
||||||
|
|
||||||
|
The import API is the stable contract; loaders are per-site. The mapping itself
|
||||||
|
can be produced with the agent-assisted workflow (point Fable/Opus agents at a
|
||||||
|
source DB + this contract -> they emit the mapping + a loader skeleton).
|
||||||
|
|
||||||
|
## Running (against a THROWAWAY import database)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \
|
||||||
|
venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees
|
||||||
|
```
|
||||||
|
|
||||||
|
Prereqs: a fresh target DB built with `flask db upgrade` + `flask plugin
|
||||||
|
upgrade-all` + `flask seed permissions/settings/reference-data`, and the three
|
||||||
|
source dumps loaded into scratch DBs (`shopdb_src`, `cmmc_usb_src`,
|
||||||
|
`wjf_employees_src`). See `scratchpad/IMPORT-PLAN.md` for the full mapping,
|
||||||
|
resolved decisions, and remaining stages.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Implemented + verified idempotent:** `reference` (vendors, businessunits,
|
||||||
|
operatingsystems), `employees` (directory bulk upsert; photos deferred).
|
||||||
|
- **TODO stages:** `models`, `applications`, `assets` (the hub - fan machines
|
||||||
|
out by type, persist the machineid->assetid crosswalk), `dependents`
|
||||||
|
(installs, warranties, notifications, KB), `network` (+ subnets/VLANs), `usb`
|
||||||
|
(cmmc device + checkinout pairing), `verify`.
|
||||||
|
|
||||||
|
The harness (PAT auth, import-mode, id-map persistence, endpoint error capture)
|
||||||
|
is proven; the remaining stages are additional `stage_*` functions in `run.py`
|
||||||
|
following the same shape.
|
||||||
0
scripts/site_imports/wjf/__init__.py
Normal file
0
scripts/site_imports/wjf/__init__.py
Normal file
157
scripts/site_imports/wjf/harness.py
Normal file
157
scripts/site_imports/wjf/harness.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Import harness for the WJ classic-ASP -> flask reference loader.
|
||||||
|
|
||||||
|
This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's
|
||||||
|
classic schema onto the maintained, schema-agnostic import contract in
|
||||||
|
docs/IMPORT-API.md. Other sites copy this pattern against their own source DB;
|
||||||
|
they do not run this loader.
|
||||||
|
|
||||||
|
The harness builds the flask app against the current DATABASE_URL (point it at a
|
||||||
|
throwaway import database), mints an unscoped admin PAT in-process, and drives
|
||||||
|
the REAL import endpoints through the app test client with the X-Import-Mode
|
||||||
|
header - so it exercises the same routes/authz/validation an HTTP client would,
|
||||||
|
without needing a running server. Legacy-id -> new-id maps persist to JSON so a
|
||||||
|
crashed run resumes and later stages resolve foreign keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
from shopdb import create_app
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def _source_password():
|
||||||
|
"""Root password for the scratch source DBs: reuse the dev DATABASE_URL in
|
||||||
|
.env unless SOURCE_DB_PASSWORD overrides."""
|
||||||
|
if os.environ.get('SOURCE_DB_PASSWORD'):
|
||||||
|
return os.environ['SOURCE_DB_PASSWORD']
|
||||||
|
env = os.path.join(os.path.dirname(__file__), '..', '..', '..', '.env')
|
||||||
|
with open(os.path.abspath(env)) as handle:
|
||||||
|
for line in handle:
|
||||||
|
if line.startswith('DATABASE_URL='):
|
||||||
|
after = line.split('root:', 1)[1]
|
||||||
|
return after.split('@', 1)[0]
|
||||||
|
raise RuntimeError('could not resolve source DB password')
|
||||||
|
|
||||||
|
|
||||||
|
class Source:
|
||||||
|
"""Read-only access to the three scratch source databases."""
|
||||||
|
|
||||||
|
def __init__(self, host='127.0.0.1', port=3306, user='root', password=None):
|
||||||
|
self.password = password or _source_password()
|
||||||
|
self.host, self.port, self.user = host, port, user
|
||||||
|
self._conns = {}
|
||||||
|
|
||||||
|
def db(self, name):
|
||||||
|
if name not in self._conns:
|
||||||
|
self._conns[name] = pymysql.connect(
|
||||||
|
host=self.host, port=self.port, user=self.user,
|
||||||
|
password=self.password, database=name, charset='utf8mb4',
|
||||||
|
cursorclass=pymysql.cursors.DictCursor)
|
||||||
|
return self._conns[name]
|
||||||
|
|
||||||
|
def rows(self, dbname, sql, args=None):
|
||||||
|
with self.db(dbname).cursor() as cur:
|
||||||
|
cur.execute(sql, args or ())
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
class IdMap:
|
||||||
|
"""Persisted legacy-id -> new-id crosswalks, one namespace per entity."""
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self._data = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as handle:
|
||||||
|
self._data = json.load(handle)
|
||||||
|
|
||||||
|
def put(self, namespace, legacy, new):
|
||||||
|
self._data.setdefault(namespace, {})[str(legacy)] = new
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get(self, namespace, legacy, default=None):
|
||||||
|
return self._data.get(namespace, {}).get(str(legacy), default)
|
||||||
|
|
||||||
|
def has(self, namespace, legacy):
|
||||||
|
return str(legacy) in self._data.get(namespace, {})
|
||||||
|
|
||||||
|
def count(self, namespace):
|
||||||
|
return len(self._data.get(namespace, {}))
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
with open(self.path, 'w') as handle:
|
||||||
|
json.dump(self._data, handle, indent=1)
|
||||||
|
|
||||||
|
|
||||||
|
class Harness:
|
||||||
|
"""App + admin PAT + import-mode test client + id maps."""
|
||||||
|
|
||||||
|
def __init__(self, idmap_path=None):
|
||||||
|
self.app = create_app()
|
||||||
|
self.appcontext = self.app.app_context()
|
||||||
|
self.appcontext.push()
|
||||||
|
self._silence_sql_logging()
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.secret = self._mint_admin_pat()
|
||||||
|
default = os.path.join(os.path.dirname(__file__), 'idmap.json')
|
||||||
|
self.ids = IdMap(idmap_path or default)
|
||||||
|
self.source = Source()
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def _silence_sql_logging(self):
|
||||||
|
import logging
|
||||||
|
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
def _mint_admin_pat(self):
|
||||||
|
"""Create (or reuse) an admin user and an unscoped admin PAT. Unscoped
|
||||||
|
because import mode + role gates require full owner authority
|
||||||
|
(docs/IMPORT-API.md). Returns the clear secret."""
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from shopdb.core.models import User, Role, ApiToken
|
||||||
|
admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first()
|
||||||
|
if not admin:
|
||||||
|
role = Role.query.filter_by(rolename='admin').first() \
|
||||||
|
or Role(rolename='admin', description='Administrator')
|
||||||
|
db.session.add(role)
|
||||||
|
admin = User(username='importer', email='importer@localhost',
|
||||||
|
passwordhash=generate_password_hash('x'), isactive=True)
|
||||||
|
admin.roles.append(role)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
secret = ApiToken.generate_secret()
|
||||||
|
token = ApiToken(userid=admin.userid, name='wjf legacy import',
|
||||||
|
tokenprefix=ApiToken.prefix_of(secret),
|
||||||
|
tokenhash=ApiToken.hash_secret(secret), scopes=None)
|
||||||
|
db.session.add(token)
|
||||||
|
db.session.commit()
|
||||||
|
return secret
|
||||||
|
|
||||||
|
def _headers(self, import_mode):
|
||||||
|
# A PAT authenticates as Authorization: Bearer shopdb_pat_... (the
|
||||||
|
# before_request shim resolves it into a JWT identity). X-API-Key is for
|
||||||
|
# collector/service tokens, not personal tokens.
|
||||||
|
headers = {'Authorization': f'Bearer {self.secret}'}
|
||||||
|
if import_mode:
|
||||||
|
headers['X-Import-Mode'] = 'true'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def post(self, path, payload, import_mode=True):
|
||||||
|
"""POST to an import endpoint. Returns (status, data). 409s are returned
|
||||||
|
(idempotency handled by the caller), other 4xx/5xx are recorded."""
|
||||||
|
resp = self.client.post(path, json=payload, headers=self._headers(import_mode))
|
||||||
|
body = resp.get_json() or {}
|
||||||
|
data = body.get('data', body)
|
||||||
|
if resp.status_code >= 400 and resp.status_code != 409:
|
||||||
|
self.errors.append((path, resp.status_code, payload, data))
|
||||||
|
return resp.status_code, data
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
resp = self.client.get(path, headers=self._headers(False))
|
||||||
|
body = resp.get_json() or {}
|
||||||
|
return resp.status_code, body.get('data', body)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.ids.save()
|
||||||
|
self.appcontext.pop()
|
||||||
134
scripts/site_imports/wjf/run.py
Normal file
134
scripts/site_imports/wjf/run.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""WJ classic-ASP -> flask reference import loader (site-specific).
|
||||||
|
|
||||||
|
Run against a THROWAWAY import database:
|
||||||
|
|
||||||
|
DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \\
|
||||||
|
venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees
|
||||||
|
|
||||||
|
Stages are idempotent (re-running resolves existing rows by natural key), so a
|
||||||
|
crashed run resumes. See scripts/site_imports/wjf/harness.py for the contract
|
||||||
|
this drives, and scratchpad/IMPORT-PLAN.md for the full mapping + decisions.
|
||||||
|
|
||||||
|
Implemented: reference (vendors, businessunits, operatingsystems), employees.
|
||||||
|
TODO stages (assets hub + dependents + network + usb) are stubbed - they need
|
||||||
|
the machineid->assetid crosswalk this harness persists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from .harness import Harness
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert(h, path, payload, unique_field, list_path=None):
|
||||||
|
"""POST payload; on 409 resolve the existing row by its unique field.
|
||||||
|
Returns the new/existing id (best-effort key 'id' variants), or None."""
|
||||||
|
status, data = h.post(path, payload)
|
||||||
|
if status in (200, 201):
|
||||||
|
return _id_of(data)
|
||||||
|
if status == 409:
|
||||||
|
_, rows = h.get(list_path or path)
|
||||||
|
items = rows if isinstance(rows, list) else rows.get('items', rows)
|
||||||
|
for row in (items or []):
|
||||||
|
if str(row.get(unique_field, '')).strip().lower() == \
|
||||||
|
str(payload[unique_field]).strip().lower():
|
||||||
|
return _id_of(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _id_of(row):
|
||||||
|
for key in ('vendorid', 'businessunitid', 'osid', 'id'):
|
||||||
|
if isinstance(row, dict) and row.get(key) is not None:
|
||||||
|
return row[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stage_reference(h):
|
||||||
|
"""Seed the reference tier that later FKs resolve against, capturing
|
||||||
|
legacy-id -> new-id crosswalks."""
|
||||||
|
counts = {}
|
||||||
|
|
||||||
|
vendors = h.source.rows('shopdb_src',
|
||||||
|
'SELECT vendorid, vendor FROM vendors WHERE isactive=1')
|
||||||
|
for v in vendors:
|
||||||
|
newid = _upsert(h, '/api/vendors', {'vendor': v['vendor']}, 'vendor')
|
||||||
|
if newid:
|
||||||
|
h.ids.put('vendor', v['vendorid'], newid)
|
||||||
|
counts['vendors'] = h.ids.count('vendor')
|
||||||
|
|
||||||
|
bus = h.source.rows('shopdb_src',
|
||||||
|
'SELECT businessunitid, businessunit FROM businessunits WHERE isactive=1')
|
||||||
|
for b in bus:
|
||||||
|
newid = _upsert(h, '/api/businessunits', {'businessunit': b['businessunit']},
|
||||||
|
'businessunit')
|
||||||
|
if newid:
|
||||||
|
h.ids.put('businessunit', b['businessunitid'], newid)
|
||||||
|
counts['businessunits'] = h.ids.count('businessunit')
|
||||||
|
|
||||||
|
oses = h.source.rows('shopdb_src',
|
||||||
|
'SELECT osid, operatingsystem FROM operatingsystems')
|
||||||
|
for o in oses:
|
||||||
|
name = (o['operatingsystem'] or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
newid = _upsert(h, '/api/operatingsystems', {'osname': name}, 'osname')
|
||||||
|
if newid:
|
||||||
|
h.ids.put('os', o['osid'], newid)
|
||||||
|
counts['operatingsystems'] = h.ids.count('os')
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def stage_employees(h):
|
||||||
|
"""Bulk-upsert the employee directory (selfhosted). Photos are deferred
|
||||||
|
(per the import decisions); occurrences are out of scope."""
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
Setting.set('employee_directory_mode', 'selfhosted', valuetype='string',
|
||||||
|
category='employees')
|
||||||
|
from shopdb.extensions import db
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
rows = h.source.rows('wjf_employees_src',
|
||||||
|
'SELECT SSO, First_Name, Last_Name, Team, Role FROM employees')
|
||||||
|
lines = ['SSO,First_Name,Last_Name,Team,Role']
|
||||||
|
for r in rows:
|
||||||
|
vals = [str(r['SSO']), (r['First_Name'] or '').strip(),
|
||||||
|
(r['Last_Name'] or '').strip(), (r['Team'] or '').strip(),
|
||||||
|
(r['Role'] or '').strip()]
|
||||||
|
lines.append(','.join(v.replace(',', ' ') for v in vals))
|
||||||
|
status, data = h.post('/api/employees/directory/import', {'csv': '\n'.join(lines)})
|
||||||
|
return {'employees_posted': len(rows), 'result': data}
|
||||||
|
|
||||||
|
|
||||||
|
STAGES = {
|
||||||
|
'reference': stage_reference,
|
||||||
|
'employees': stage_employees,
|
||||||
|
# TODO: 'models', 'applications', 'assets', 'dependents', 'network', 'usb'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--stages', default='reference,employees',
|
||||||
|
help='comma list of stages to run')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
h = Harness()
|
||||||
|
print(f'== WJ import loader (target: {h.app.config["SQLALCHEMY_DATABASE_URI"].split("/")[-1]}) ==')
|
||||||
|
try:
|
||||||
|
for name in args.stages.split(','):
|
||||||
|
name = name.strip()
|
||||||
|
if name not in STAGES:
|
||||||
|
print(f' skip unknown stage: {name}')
|
||||||
|
continue
|
||||||
|
result = STAGES[name](h)
|
||||||
|
print(f' [{name}] {result}')
|
||||||
|
if h.errors:
|
||||||
|
print(f'\n {len(h.errors)} endpoint error(s):')
|
||||||
|
for path, code, _payload, data in h.errors[:10]:
|
||||||
|
print(f' {code} {path}: {data}')
|
||||||
|
finally:
|
||||||
|
h.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user