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.
284 lines
12 KiB
Markdown
284 lines
12 KiB
Markdown
<!-- docmeta
|
|
purpose: reference
|
|
audience: developer, integrator, adopter
|
|
status: current
|
|
summary: The stable HTTP import contract - PAT auth, X-Import-Mode timestamps, and the lookup-then-upsert recipe.
|
|
loadwith:
|
|
-->
|
|
|
|
# Import API: loading a site's data through HTTP
|
|
|
|
**Four import pages.** The HTTP wire contract: this page. Spreadsheet and no
|
|
developer: [CSV-IMPORT](CSV-IMPORT.md). How to shape a per-site loader around
|
|
this contract: [IMPORT-ADOPTION](IMPORT-ADOPTION.md). Source is the classic-ASP
|
|
shopdb: [IMPORT-LEGACY-ASP](IMPORT-LEGACY-ASP.md) 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](#1-authentication) |
|
|
[2 Import mode](#2-import-mode-the-x-import-mode-header) |
|
|
[3 Reference-data seed](#3-reference-data-seed) |
|
|
[4 Order of operations](#4-order-of-operations) |
|
|
[5 The idempotent upsert recipe](#5-the-idempotent-upsert-recipe) |
|
|
[6 Verifying a run](#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:
|
|
|
|
```bash
|
|
# 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
|
|
`createddate` and `modifieddate` fields in the JSON body and store them
|
|
verbatim (naive UTC). Both `2020-01-05T12:00:00` (ISO) and the legacy
|
|
`2020-01-05 12:00:00` (MySQL) forms are parsed. A bare `2020-01-05` works 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):
|
|
|
|
```bash
|
|
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.
|
|
|
|
1. **Reference and lookup data first**
|
|
1. Vendors (`/api/vendors`)
|
|
2. Model types (`/api/modeltypes`)
|
|
3. Models (`/api/models`) - needs vendors and model types
|
|
4. Business units (`/api/businessunits`)
|
|
5. Location types, then locations (`/api/locations/types`, `/api/locations`)
|
|
6. Operating systems (`/api/operatingsystems`)
|
|
7. Asset statuses (`/api/assets/statuses`)
|
|
8. Relationship types (`/api/assets/relationshiptypes`)
|
|
9. Notification types (`/api/notifications/types`)
|
|
10. Per-plugin subtypes: computer, machine, printer and network-device types
|
|
11. Support teams and their contacts (`/api/supportteams`,
|
|
`/api/supportteams/{id}/contacts`) - BEFORE applications, because
|
|
`applications.supportteamid` points at a team
|
|
12. Applications (`/api/applications`) and their versions
|
|
2. **Assets, per type.** Each create makes the core Asset row plus its
|
|
extension: computers, machines, printers, network devices.
|
|
3. **Communications.** The primary IP is set through the asset payload's
|
|
`ipaddress` field in step 2. There is no bulk-communications endpoint, so
|
|
secondary interfaces have no target; carry them as custom fields or defer
|
|
them.
|
|
4. **Relationships** (`/api/assets/relationships`): needs both endpoint assets
|
|
and the relationship types to exist already.
|
|
5. **Installed applications** (`/api/computers/{id}/apps`): needs computers and
|
|
applications.
|
|
6. **Knowledge base, notifications and warranties**, including backdated
|
|
history.
|
|
7. **Custom fields** for any source column with no home in the target schema:
|
|
`POST /api/customfields` once per field, then
|
|
`PUT /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:
|
|
|
|
```bash
|
|
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](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:
|
|
|
|
1. **Look up** the row by its natural key using the exact-match list filter.
|
|
2. 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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```bash
|
|
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](llms.txt) is the short version of the envelope
|
|
and the auth model.
|