Files
shopdb-flask/scripts/site_imports/wjf/harness.py
cproudlock f34b9ca710
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Carry the level everywhere a position is drawn, and gate it per occurrence
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00

194 lines
7.7 KiB
Python

"""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._ensure_asset_types()
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 = []
self._defaultlevelid = None
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).
The legacy schema predates levels: it has ONE floor plan, so every
mapleft/maptop it carries is a coordinate on this site's default level.
Importing them without a level produces markers the map refuses to draw
- it will not guess a drawing for coordinates that do not name one.
"""
if self._defaultlevelid is None:
from shopdb.core.models import MapLevel
level = MapLevel.default_level()
self._defaultlevelid = level.levelid if level else None
return self._defaultlevelid
def _silence_sql_logging(self):
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
def _ensure_asset_types(self):
"""Register each plugin's AssetType + seeded subtypes. on_install (which
does this) is skipped when a DB is built with `flask plugin upgrade-all`
rather than a fresh `install`, so run it idempotently here - the create
routes 500 without the AssetType row."""
manager = self.app.extensions.get('plugin_manager')
if not manager:
return
for _name, plugin in manager.get_all_plugins().items():
try:
plugin.on_install(self.app)
except Exception:
pass
# A full import IS the setup, so mark it complete - otherwise the admin
# is bounced to the first-run wizard on every fresh import DB.
from shopdb.core.models import Setting
Setting.set('setup_complete', 'true', valuetype='boolean', category='site')
db.session.commit()
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()