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.
12 KiB
Import API: loading a site's data through HTTP
Four import pages. The HTTP wire contract: this page. Spreadsheet and no developer: CSV-IMPORT. How to shape a per-site loader around this contract: IMPORT-ADOPTION. Source is the classic-ASP shopdb: IMPORT-LEGACY-ASP on top of this page.
This page is schema-agnostic: it says how to authenticate, how to preserve a legacy row's real timestamps, and how to make a run rerunnable. It does NOT map anybody's source columns - that is the loader's job.
Every row is created through a documented endpoint, so authorization, validation, auditing and plugin hooks run exactly as they do for a human operator. No direct writes to the application database are needed or wanted.
Contents: 1 Authentication | 2 Import mode | 3 Reference-data seed | 4 Order of operations | 5 The idempotent upsert recipe | 6 Verifying a run
1. Authentication
Examples on this page use http://localhost:5001, a development instance.
Substitute your own base URL; every import call targets /api/... on it.
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: Authorization: Bearer shopdb_pat_.... Create one as an admin, from Settings > API Tokens 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'
Revoke it from the same Settings page (or DELETE /api/apitokens/<id>) when
the import is done. Minting one needs the apitokens.create permission, which
admins have by default.
Use an unscoped token. 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, which is the default.
2. 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.
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 (every
type plugin), vendors, models, modeltypes, businessunits, locations, operating
systems, applications, knowledge base, 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.
3. 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 is populated here, so a primary-IP mapping can resolve
comtype='IP'. There is no CRUD endpoint for it; the seed is the only way in.
4. Order of operations
Import in dependency order so foreign keys always resolve. Each step is a lookup-then-upsert loop (section 5); rerunning any step is safe.
- Reference and lookup data first
- Vendors (
/api/vendors) - Model types (
/api/modeltypes) - Models (
/api/models) - needs vendors and model types - Business units (
/api/businessunits) - Location types, then locations (
/api/locations/types,/api/locations) - Operating systems (
/api/operatingsystems) - Asset statuses (
/api/assets/statuses) - Relationship types (
/api/assets/relationshiptypes) - Notification types (
/api/notifications/types) - Per-plugin subtypes: computer, machine, printer and network-device types
- Support teams and their contacts (
/api/supportteams,/api/supportteams/{id}/contacts) - BEFORE applications, becauseapplications.supportteamidpoints at a team - Applications (
/api/applications) and their versions
- Vendors (
- Assets, per type. Each create makes the core Asset row plus its extension: computers, machines, printers, network devices.
- Communications. The primary IP is set through the asset payload's
ipaddressfield in step 2. There is no bulk-communications endpoint, so secondary interfaces have no target; carry them as custom fields or defer them. - Relationships (
/api/assets/relationships): needs both endpoint assets and the relationship types to exist already. - Installed applications (
/api/computers/{id}/apps): needs computers and applications. - Knowledge base, notifications and warranties, including backdated history.
- Custom fields for any source column with no home in the target schema:
POST /api/customfieldsonce per field, thenPUT /api/customfields/asset/{assetid}. Custom-field values are not timestamped, so they carry no history.
A map position needs its level
mapx/mapy are absolute pixels in the native coordinate space of ONE level
(ADR-017), so an import that sends coordinates without levelid produces
markers the map renders as "level unknown" rather than drawing them on the
default level. A source that predates levels has one floor plan, so the right
value is that site's default level:
curl -s http://server/api/maplevels | python -c "import json,sys; print(json.load(sys.stdin)['data']['defaultlevelid'])"
Send that levelid on every row carrying coordinates. If an import already ran
without it, the levels migration's backfill is not retroactive - assign the rows
with POST /api/mappositions/positions, which requires levelid per row for
exactly this reason. Loading and recalibrating the plans themselves is
FLOOR-MAP.md.
5. The idempotent upsert recipe
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> |
Worked example
The loop, with the two headers that matter and a dry run. A single bad row reports and keeps going: aborting a multi-hour run on row 4000 is worse than finishing with a list of failures.
import os
import requests
BASE = "http://localhost:5001"
class ImportClient:
def __init__(self, token=None, dryrun=False):
self.session = requests.Session()
self.dryrun = dryrun
self.session.headers.update({
# A PAT (shopdb_pat_...) does not expire like a login JWT, so it
# survives a long import. X-Import-Mode makes the createddate /
# modifieddate passthrough take effect.
"Authorization": f"Bearer {token or os.environ['SHOPDB_TOKEN']}",
"X-Import-Mode": "true",
})
def lookup(self, path, params):
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:
print(f"[dry-run] {'PUT' if existing else 'POST'} {path} {lookupparams}")
return existing or payload
if existing:
resp = self.session.put(f"{BASE}{path}/{existing[idfield]}", json=payload)
else:
resp = self.session.post(f"{BASE}{path}", json=payload)
if resp.status_code >= 400:
print(f"ERROR {resp.status_code} {path}: {resp.text[:200]}")
return None
return resp.json()["data"]
# One entity, in the order of section 4. Legacy history is preserved because
# X-Import-Mode is set.
client.upsert(
"/api/vendors",
idfield="vendorid",
lookupparams={"vendor": row["vendor"]},
payload={"vendor": row["vendor"],
"createddate": row.get("dateadded"),
"modifieddate": row.get("lastupdated")},
)
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. Verifying a run
Compare counts after each phase. The new side exposes a total in the pagination metadata, so one call per entity answers it:
curl -s "http://localhost:5001/api/vendors?per_page=1" \
-H "Authorization: Bearer <token>" | jq '.meta.pagination.total'
Count the same thing on the source however that source is queried, and investigate any gap. Where your loader routes one source table to several target plugins, the source-side count has to be filtered the same way the routing was, or the two numbers are not measuring the same set.
Beyond counts: resolve-rate on foreign keys (nothing silently landed NULL), then a UI spot-check - log in, open a list, the map, and one detail page.
The wider API surface and its auth model is the generated spec at GET /api/docs
(docs/openapi.json); llms.txt is the short version of the envelope
and the auth model.