Execute WIKI-UPDATE-PLAN.md (14 items): - NEW docs/PRINTER-INSTALLER.md: install-list / pc-default / install-batch contract + public installer map page. - NEW-shape docs/API-REFERENCE.md: index + pointer to the live generated docs (/api/docs Redoc, openapi.json, llms.txt, MCP), replacing a stale full dump. - geenforce cutover + GE-ENFORCE-DISPLAY/CLIENT/DEPLOY: server-first display dispatcher (display-role by FQDN, display-type.txt fallback), dashboarddefaults FQDN keying, legacy kiosk autostart self-heal (Wow6432Node), per-PC-type cutover status. - PLUGINS: printers/slides rows + plugin-permissions note (slides.manage). - IMPORT-API: dashboarddefaults FQDN-first keying. - CONFIG: word-wise search, site_timezone setting. - PILOT-DEPLOY: servers-to-network reclassify step. IMPORT-ADOPTION: fixup note. - CLAUDE.md: test count 1077->1159, HTTPS-cutover state. CHANGELOG: timezone + kiosk-autostart fixes, site_timezone setting.
27 KiB
Import API: migrating the classic ASP shopdb through HTTP alone
This is the operator manual for importing the legacy Classic-ASP shopdb database
(prodscratch on the dev MySQL container) into shopdb-flask using ONLY the HTTP
API. No direct writes to the shopdb_flask database are needed or wanted: every
row is created through a documented endpoint so authorization, validation,
auditing, and plugin hooks all run exactly as they do for a human operator.
A plain Python script can run the whole migration from this document.
Contents:
- Prerequisites
- Order of operations
- Full table-by-table mapping
- Tables with no target yet
- Idempotency recipe and a worked importer
- Verification: row-count parity
1. Prerequisites
Dev URLs
- Flask API:
http://localhost:5001 - All import calls target
/api/...on that host.
Admin token
Every write needs authentication, and import mode additionally needs an admin.
A large import can outlast a login JWT: access_token expires after one hour,
so a long run dies mid-import with 401s. Use a personal API token (PAT)
instead. A PAT never expires (unless you set an expiry), acts as the user that
created it, and is sent exactly like a JWT. Create one as an admin (via the
Settings > API Tokens page, or the API):
# Bootstrap: a short login JWT is fine just to mint the long-lived PAT.
JWT=$(curl -s http://localhost:5001/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"<admin>","password":"<password>"}' | jq -r '.data.access_token')
# The full secret (shopdb_pat_...) is returned ONCE. Save it now.
curl -s http://localhost:5001/api/apitokens \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"legacy import runner"}' | jq -r '.data.secret'
Send the PAT on every request as Authorization: Bearer shopdb_pat_.... It
authenticates the whole import surface (every create/update/delete plus import
mode) as its owning admin, exactly as a login JWT would, but without the hourly
expiry. Revoke it from the same Settings page (or DELETE /api/apitokens/<id>)
when the import is done.
Use an unscoped token for imports. A token may optionally carry a scopes
list that limits it to specific permissions; a scoped token suspends the admin
bypass and is denied on role-gated endpoints AND on import mode, so it cannot
run an import. Leave the "Restrict permissions" option off (the default) so the
token acts with the full authority of its admin owner. Minting a token itself
requires the apitokens.create permission (admins have it by default).
A short-lived login JWT still works for quick one-off calls if you prefer.
Import mode: the X-Import-Mode header
By default the server stamps createddate/modifieddate to "now" on every
create and update, which would erase a migrated row's real history. To preserve
it, send the request header:
X-Import-Mode: true
When (and only when) the caller is an admin AND that header is present:
- create/update endpoints on timestamped entities accept optional
createddateandmodifieddatefields in the JSON body and store them verbatim (naive UTC). Both2020-01-05T12:00:00(ISO) and the legacy2020-01-05 12:00:00(MySQL) forms are parsed. A bare2020-01-05works too. - the selfhosted USB checkout/checkin endpoints accept optional
checkouttimeandcheckintimeoverrides so historical events keep their real timestamps.
Without the header, or for a non-admin caller, those fields are silently ignored
and the server behaves exactly as it does normally. This is enforced centrally
by shopdb/utils/import_mode.py (import_mode_active, apply_import_timestamps,
parse_import_datetime), exposed on the plugin contract surface shopdb.api.
Timestamped entities that honor createddate/modifieddate: assets (all five
type plugins), vendors, models, modeltypes, businessunits, locations, operating
systems, applications, knowledge base, USB devices, asset relationships.
Entities that carry history in domain fields instead (createddate passthrough is
a no-op there, by design): notifications (starttime/endtime), warranties
(startdate/enddate/lastcheckeddate). Set those fields directly in the
payload; they are already accepted.
Reference data seed
Before importing, seed the reference tables that have no CRUD endpoint of their own (communication types such as IP/Serial/USB, default statuses, canonical relationship types, permissions, settings):
flask seed permissions
flask seed settings
flask seed reference-data
communicationtypes (the target of legacy comstypes) is populated here, so
the primary-IP mapping below can resolve comtype='IP'.
2. Order of operations
Import in dependency order so foreign keys always resolve. Each step is a lookup-then-upsert loop (see section 5); rerunning any step is safe.
- Reference / lookup data first
- Vendors (
vendors) - Model types (
modeltypes) - from legacymachinetypes - Models (
models) - needs vendors + model types - Business units (
businessunits) - Location types, then Locations (
locations/types,locations) - the legacy LocationOnly machines land here, not as assets - Operating systems (
operatingsystems) - Asset statuses (
assets/statuses) - from legacymachinestatus - Relationship types (
assets/relationshiptypes) - from legacyrelationshiptypes - Notification types (
notifications/types) - Per-plugin subtypes: computer types (from
pctype), machine types, printer types, network device types, measuring-tool types - Support teams + support-team contacts (
supportteams,supportteams/{id}/contacts- see section 3.3); import these BEFORE applications becauseapplications.supportteamidpoints at them - Applications (
applications) and their versions; import legacytopicsas applications too (KB links to applications, section 3)
- Vendors (
- Assets, per type (each creates the core Asset row plus its extension):
computers, machines, printers, network devices, measuring tools, and USB
devices. Fan out the legacy
machinestable by category (section 3). - Communications: the primary IP is set through the asset payload's
ipaddressfield during step 2. There is no bulk-communications endpoint; see the mapping note. - Relationships (
assets/relationships): needs both endpoint assets and the relationship types to already exist. - Installed applications: attach apps to computers
(
computers/{id}/apps), needs computers + applications. - Knowledge base, notifications, warranties, USB checkouts (including backdated history).
- Custom fields: for any legacy column with no home in the target schema, define a custom field for the asset type and store the value per asset.
3. Full table-by-table mapping
Legend: -> maps to. Endpoints are relative to http://localhost:5001. "NK"
is the natural key used for the idempotent lookup (section 5).
3.1 The machines hub fans out into the asset plugins
machines (885 rows) is the central legacy asset table. Two columns drive the
fan-out: machinetypeid (what the asset physically is) and pctypeid (a
computer's sub-type). Route each row by machinetypeid:
legacy machinetypeid |
machinetypes.machinetype |
target plugin | subtype source |
|---|---|---|---|
| 1 | LocationOnly (also islocationonly=1) |
core Locations (NOT an asset) | locationtype |
| 33 | PC | computers | computertype <- pctype.typename via machines.pctypeid |
| 20 | Server | computers | computer type "Server" |
| 15 | Printer | printers | printertype |
| 16 Access Point / 17 IDF / 18 Camera / 19 Switch / 46 Firewall | network | networkdevicetype |
|
| 44 | USB Device | usb | (usb device) |
| 23 Measuring Machine / 3 CMM / 48 Spline Checker / 8 Eddy Current / 47 Inspection | measuringtools (gage-lab judgment call; ADR-005) | measuringtooltype |
|
| 2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45 (lathes, mills, welders, grinders, ...) | machines | machinetype |
This mapping is a recommended default, not a hard rule; a site may re-route a
machinetypeid (for example send CMM to machines rather than
measuringtools). Decide the routing table once, up front.
Common machines columns -> core Asset fields (same for every target plugin):
| legacy column | target field | notes |
|---|---|---|
machinenumber |
assetnumber |
the business identifier / NK |
alias or hostname |
name |
layperson label |
serialnumber |
serialnumber |
|
machinestatusid |
statusid |
remap via machinestatus -> asset statuses |
businessunitid |
businessunitid |
remap via imported business units |
mapleft |
mapx |
|
maptop |
mapy |
|
machinenotes |
notes |
|
dateadded |
createddate |
import mode only |
lastupdated |
modifieddate |
import mode only |
Per-plugin extension fields:
- computers (
POST /api/computers):hostname<-machines.hostname,osid<- remappedmachines.osid,computertypeid<- computer type frompctype,loggedinuser,lastboottime,vendorid,modelnumberid,ipaddress<-machines.ipaddress1(primary IP). NK:assetnumber. - machines (
POST /api/machines):machinetypeid,vendorid,modelnumberid,controllervendorid/controllermodelid(fromcontrollertypesremapped to vendors/models),requiresmanualconfig<-requires_manual_machine_config,islocationonly. NK:assetnumber. - printers (
POST /api/printers): see 3.2 (authoritative source is the legacyprinterstable). - network (
POST /api/network):networkdevicetypeid,hostname,vendorid,ipaddress<-machines.ipaddress1. NK:assetnumber. - measuringtools (
POST /api/measuringtools):measuringtooltypeid, calibration fields where known. NK:assetnumber.
3.2 Reference and lookup tables
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
vendors |
POST /api/vendors |
vendor -> vendor |
vendor |
machinetypes |
POST /api/modeltypes |
machinetype -> modeltype; set category (Equipment/Computer/...) |
modeltype |
models |
POST /api/models |
modelnumber, vendorid (remapped), machinetypeid -> modeltypeid, notes, image -> imageurl, documentationpath -> documentationurl |
modelnumber + vendor |
imageurl imports as a plain URL string (an external URL or a legacy
/images/models/* path). Binary photos are not part of the import payload;
upload them after import via POST /api/models/<modelid>/image (multipart
file), which stores the file under instance/modelimages/ and rewrites
imageurl to the served URL.
| businessunits | POST /api/businessunits | businessunit -> businessunit | businessunit |
| operatingsystems | POST /api/operatingsystems | operatingsystem -> osname | osname (+osversion) |
| machinestatus | POST /api/assets/statuses | machinestatus -> status | status |
| relationshiptypes | POST /api/assets/relationshiptypes | relationshiptype -> relationshiptype, description, isdirectional (bool, default true; false = symmetric connection) | relationshiptype |
| notificationtypes | POST /api/notifications/types | typename, typedescription, typecolor | typename |
| pctype | POST /api/computers/types | typename -> computertype, description | computertype |
| subnettypes | (see subnets) | used as subnettype string on subnets | - |
| subnets | POST /api/network/subnets | cidr, description -> name/description, vlan -> create VLAN first (POST /api/network/vlans) then vlanid, subnettypeid -> subnettype name | cidr |
| dashboarddefaults | POST /api/dashboarddefaults | fqdn (preferred key, stored lowercase), ipaddress (fallback key), displayrole (dashboard/lobby/partskiosk), businessunitid (remapped; only the dashboard role uses it), description | fqdn, else ipaddress |
| controllertypes | remap into vendors + models | e.g. "Fanuc" -> a Vendor; the controller model -> a Model; then set controllervendorid/controllermodelid on the machine | - |
| comstypes | communicationtypes (seeded, no API) | ensure flask seed reference-data created IP/Serial/USB/... before importing comms | - |
Resolution at runtime is FQDN-first with IP fallback (migration
7d31_dashboarddefault_fqdn); import both when the legacy source has them.
Note on communication types: the classic comstypes.typename values
(IP, Serial, Network_Interface, USB, Parallel, VNC, FTP, DNC) correspond to the
seeded communicationtypes.comtype. They are created by the reference-data seed,
not imported per-row.
3.3 Support teams, applications, topics, installed apps
Support teams and their contacts import BEFORE applications, because
applications.supportteamid references a team. The legacy appowners table
is folded into contacts: each legacy supportteams row carries one
appownerid, so import that owner as ONE contact on the team (legacy
appowner -> name, sso -> sso).
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
supportteams |
POST /api/supportteams |
teamname, teamurl (ServiceNow group deep link) |
teamname |
appowners (via each team's appownerid) |
POST /api/supportteams/{supportteamid}/contacts |
appowner -> name, sso -> sso, sortorder (default 0) |
(supportteamid, name) |
applications |
POST /api/applications |
appname, appdescription, supportteamid (remap by team teamname, GET /api/supportteams?teamname=...), isinstallable, applicationnotes, installpath, applicationlink, documentationpath, ishidden, isprinter, islicenced, image |
appname |
appversions |
POST /api/applications/{appid}/versions |
version, releasedate, notes |
version (per app) |
topics |
POST /api/applications |
topics is a near-clone of applications and knowledgebase.appid points at it; import each distinct topic as an Application (appname = topic name), so KB links resolve against applications |
appname |
installedapps |
POST /api/computers/{computerid}/apps |
body {appid, appversionid}; resolve machineid -> the imported computer, appid/appversionid -> imported app + version |
(computerid, appid) |
installedapps only makes sense for computer-class assets; skip rows whose
machineid did not map to a computer.
3.4 Communications
| legacy table | target | field mapping | notes |
|---|---|---|---|
communications (comstypeid=1, isprimary) |
asset ipaddress on create/update |
address -> ipaddress |
Sets the primary IP communication for the asset. |
communications (other comstypeids / secondary rows) |
none yet | No bulk-communication create endpoint exists. Import the primary IP only; capture extra interfaces as custom fields, or defer. |
3.5 Relationships
| legacy table | target endpoint | field mapping |
|---|---|---|
machinerelationships |
POST /api/assets/relationships |
machineid -> sourceassetid (the imported asset id), related_machineid -> targetassetid, relationshiptypeid -> relationshiptypeid remapped by name, relationship_notes -> notes |
Suggested legacy-name -> target relationship-type mapping (create these types
first, or map onto the canonical partof/controls/connectedto):
legacy relationshiptype |
recommended target |
|---|---|
| Controls | Controls |
| Controlled By | Controls (reverse the source/target) |
| Dualpath | Dualpath (or connectedto with label "dualpath") |
| Cluster Member | partof |
| Backup For | Backup For |
| Master-Slave | Controls |
| Contains | partof |
| Stored At | Stored At |
| Connected To | connectedto |
Resolve each machine id to the asset id you got back when you created that
asset (keep a legacy_machineid -> assetid map as you import).
3.6 Knowledge base, notifications, warranties, USB
| legacy table | target endpoint | field mapping | NK |
|---|---|---|---|
knowledgebase |
POST /api/knowledgebase |
shortdescription, linkurl, keywords, appid (remapped to the imported application/topic); lastupdated -> modifieddate in import mode |
linkurl (fallback shortdescription) |
notifications |
POST /api/notifications |
notification, notificationtypeid (remapped), businessunitid (remapped), starttime, endtime, ticketnumber, link, isshopfloor, employeesso; note legacy endtime sentinel 2099-00-03 09:52:32 is invalid - drop or clamp it |
ticketnumber when set, else append-only |
warranties |
POST /api/warranty |
warrantyname/servicelevel -> servicelevel, enddate -> enddate, link the covered asset via assetids: [assetid]; set vendor (required) from the source or "Dell"; servicetag if known |
servicetag + vendor |
usbcheckouts |
see below | historical checkout/checkin events | - |
USB devices and their history:
-
Create each USB device (legacy
machinesrows withmachinetypeid=44, or a dedicated device list) viaPOST /api/usbin selfhosted mode with body{device_id: <serial>, device_desc, locker_location}. NK:device_id. -
Replay each
usbcheckoutsrow as a checkout then (if returned) a checkin, with import-mode backdating:POST /api/usb/{device_id}/checkoutbody{badge: <sso>, reason: <checkout_reason>, checkouttime: <checkout_time>}- if
checkin_timeis set:POST /api/usb/{device_id}/checkinbody{badge: <sso>, sanitized: <was_wiped>, notes: <checkin_notes>, checkintime: <checkin_time>}
The
checkouttime/checkintimeoverrides are honored only in import mode.
Employee directory (people): only self-hosted mode (employee_directory_mode = selfhosted) owns people in this app; import them via the directory bulk-upsert
POST /api/employees/directory/import (CSV headers SSO,First_Name,Last_Name, Team,Role,Picture) or per-person POST /api/employees/directory. Photos:
- External mode: the photo is a URL/relative path supplied by the HR database
(
Picturecolumn); it is a read-only pass-through and cannot be uploaded here. - Self-hosted mode: the
PictureCSV field is a legacy text label and does not drive the displayed photo. Upload the real photo after import viaPOST /api/employees/<sso>/photo(multipartfile, png/jpg/jpeg/gif/webp), which stores it underinstance/employeephotos/and serves it publicly.
3.7 Anything unmappable -> custom fields
For a legacy column with no target field (for example machines.logicmonitorurl,
machines.fqdn, printers.printerpin), define a custom field on the asset type
and store the value per asset:
POST /api/customfieldsbody{assettypeid, label, datatype}(once per field)PUT /api/customfields/asset/{assetid}body{values: {<fieldid>: <value>}}
Custom-field values are not timestamped, so they carry no history.
4. Tables with no target yet
These legacy tables have no import target in the current schema. The dispositions below are DECIDED, not open questions.
DECIDED: not migrated
dncconfigandcommconfig- intentionally NOT migrated. DNC communication settings drift constantly, so a one-shot import of stale data has little value. The plan is a future DNC feature fed live by the GE-Enforce collector/reporting tool rather than a historical import. When that DNC support is eventually built, the expected ingestion pattern is one attribute at a time across the whole facility (for example, sweep every machine's baud rate in one pass, then ports, and so on) via the GE-Enforce collector, so the future design should favor per-field fleet-wide updates over per-machine full-record imports.
DECIDED: skip (structure only or low value)
compliance,compliancescans- 0 rows inprodscratch. No data to migrate; a future compliance plugin would own them. Skip.ednc_installations,ednc_logs- 0 rows, and they belong to the eDNC tooling rather than the asset catalog. Skip.distributiongroups(2 rows) - email distribution lists referenced bybusinessunits.distributiongroupid. No target; skip, or attach as a business unit custom field if a site needs it.functionalaccounts(7 rows) - service-account concept referenced bypctype/machinetypes; no equivalent in the new schema. Skip, or capture as a computer-type custom field.skilllevels(2 rows) - orphaned lookup (no FK frommachines). Skip.
5. Idempotency recipe and a worked importer
The endpoints are NOT upserts. The idempotent unit is a two-step recipe that composes with import mode:
- Look up the row by its natural key using the exact-match list filter.
- If found, PUT to update it; if not, POST to create it.
Each import-relevant list endpoint has an exact-match filter for its natural key (added for exactly this purpose):
| entity | lookup |
|---|---|
| assets (all 5 plugins) | GET /api/{plugin}?assetnumber=<n> |
| vendors | GET /api/vendors?vendor=<name> |
| models | GET /api/models?modelnumber=<m>&vendor=<vendorid> |
| model types | GET /api/modeltypes?modeltype=<name> |
| business units | GET /api/businessunits?businessunit=<name> |
| locations | GET /api/locations?locationname=<name> |
| operating systems | GET /api/operatingsystems?osname=<name> |
| applications | GET /api/applications?appname=<name> |
| knowledge base | GET /api/knowledgebase?linkurl=<url> |
| warranties | GET /api/warranty?servicetag=<tag>&vendor=<name> |
| notifications | GET /api/notifications?ticketnumber=<t> |
| USB devices | GET /api/usb/{device_id} (exact by id) |
Worked example
A small, dependency-free importer (requests) that authenticates with a PAT
(so a multi-hour run cannot expire mid-import), does the lookup-then-upsert loop
in import mode, supports a --dry-run flag, and reports errors without aborting
the whole run:
import argparse
import os
import requests
BASE = "http://localhost:5001"
class ImportClient:
def __init__(self, token=None, dryrun=False):
self.session = requests.Session()
self.dryrun = dryrun
# A personal API token (shopdb_pat_...) does not expire like a login
# JWT, so it survives a long import. See section 1 to mint one.
token = token or os.environ["SHOPDB_TOKEN"]
# X-Import-Mode makes createddate/modifieddate passthrough take effect.
self.session.headers.update({
"Authorization": f"Bearer {token}",
"X-Import-Mode": "true",
})
def lookup(self, path, params):
"""Return the first matching row, or None."""
resp = self.session.get(f"{BASE}{path}", params=params)
resp.raise_for_status()
rows = resp.json().get("data") or []
return rows[0] if rows else None
def upsert(self, path, idfield, lookupparams, payload):
"""Lookup by natural key; PUT if found, else POST. Returns the row."""
existing = self.lookup(path, lookupparams)
if self.dryrun:
verb = "PUT" if existing else "POST"
print(f"[dry-run] {verb} {path} {lookupparams}")
return existing or payload
if existing:
rowid = existing[idfield]
resp = self.session.put(f"{BASE}{path}/{rowid}", json=payload)
else:
resp = self.session.post(f"{BASE}{path}", json=payload)
if resp.status_code >= 400:
# report and keep going; a single bad row must not abort the run
print(f"ERROR {resp.status_code} {path}: {resp.text[:200]}")
return None
return resp.json()["data"]
def import_vendors(client, legacyrows):
for row in legacyrows:
client.upsert(
"/api/vendors",
idfield="vendorid",
lookupparams={"vendor": row["vendor"]},
payload={
"vendor": row["vendor"],
# legacy history preserved because X-Import-Mode is set
"createddate": row.get("dateadded"),
"modifieddate": row.get("lastupdated"),
},
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
# PAT from the SHOPDB_TOKEN env var, or pass --token explicitly.
parser.add_argument("--token", default=None)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
client = ImportClient(args.token, dryrun=args.dry_run)
# read legacy rows from prodscratch (read-only) and call the import_* fns
# in the order of section 2, keeping a legacy-id -> new-id map as you go.
Keep a legacy_id -> new_id dictionary for every entity as you import it; you
need it to remap foreign keys (a machine's businessunitid, a checkout's
machineid, a relationship's machineid/related_machineid, and so on).
6. Verification: row-count parity
After each phase, compare counts. Legacy side (read-only), for example:
docker exec dev-mysql mysql -uroot -prootpassword prodscratch \
-e "SELECT COUNT(*) FROM vendors;"
New side, via the API pagination metadata (meta.pagination.total):
curl -s "http://localhost:5001/api/vendors?per_page=1" \
-H "Authorization: Bearer <token>" | jq '.meta.pagination.total'
Suggested parity checks:
| entity | legacy count | new count |
|---|---|---|
| vendors | SELECT COUNT(*) FROM vendors |
GET /api/vendors total |
| models | SELECT COUNT(*) FROM models |
GET /api/models total |
| business units | SELECT COUNT(*) FROM businessunits |
GET /api/businessunits total |
| applications | SELECT COUNT(*) FROM applications |
GET /api/applications?showhidden=true total |
| knowledge base | SELECT COUNT(*) FROM knowledgebase WHERE isactive |
GET /api/knowledgebase total |
| computers | SELECT COUNT(*) FROM machines WHERE machinetypeid IN (33,20) |
GET /api/computers total |
| machines | SELECT COUNT(*) FROM machines WHERE machinetypeid IN (2,4,5,6,7,9,10,11,12,13,14,21,22,24,25,45) |
GET /api/machines total |
| printers | SELECT COUNT(*) FROM printers WHERE isactive |
GET /api/printers total |
| network devices | SELECT COUNT(*) FROM machines WHERE machinetypeid IN (16,17,18,19,46) |
GET /api/network total |
| USB devices | SELECT COUNT(*) FROM machines WHERE machinetypeid=44 |
GET /api/usb total |
| relationships | SELECT COUNT(*) FROM machinerelationships WHERE isactive |
per-asset GET /api/assets/{id}/relationships |
| USB checkouts | SELECT COUNT(*) FROM usbcheckouts |
GET /api/usb/checkouts |
Exact counts will differ where the fan-out routing table (section 3.1) sends a
machinetypeid to a different plugin than the example above; adjust the legacy
WHERE clause to match the routing you chose. Investigate any gap beyond that.