Add the API import surface for legacy migrations (contract 0.8.0)
All checks were successful
CI / backend (push) Successful in 1m4s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Goal: an LLM or script can migrate an entire legacy database using only
the HTTP API - original history preserved, safely re-runnable.

- X-Import-Mode header (admin only): create/update endpoints across 15
  timestamped entity types accept original createddate/modifieddate;
  helper exposed via shopdb.api (contract 0.7.0 -> 0.8.0).
- Exact-match natural-key lookup filters on 13 list endpoints for the
  lookup-then-upsert recipe.
- Selfhosted USB checkout/checkin accept backdated event times in
  import mode.
- docs/IMPORT-API.md: operator manual grounded in the real legacy
  schema - order of operations, full table-by-table mapping including
  the machines fan-out, idempotent Python importer with dry-run, parity
  checks, and decided dispositions for unmigrated tables (DNC config
  stays live-fed via the collector; supportteams/appowners map to the
  upcoming supportteams model).

635 tests pass; naming green; frontend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 20:10:46 -04:00
parent f8e5109255
commit 46e50c07ff
24 changed files with 1006 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.import_mode import apply_import_timestamps
def _computer_models():
@@ -78,6 +79,10 @@ def list_applications():
installable = request.args.get('installable').lower() == 'true'
query = query.filter(Application.isinstallable == installable)
# Exact-match natural-key lookup for idempotent import (app name).
if exactappname := request.args.get('appname'):
query = query.filter(Application.appname == exactappname)
if search := request.args.get('search'):
query = query.filter(
db.or_(
@@ -174,6 +179,7 @@ def create_application():
)
db.session.add(app)
apply_import_timestamps(app, data)
db.session.flush()
AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname)
@@ -224,6 +230,7 @@ def update_application(app_id: int):
AuditLog.log('updated', 'Application', entityid=app.appid,
entityname=app.appname, changes=changes)
apply_import_timestamps(app, data)
db.session.commit()
return success_response(app.to_dict(), message='Application updated')

View File

@@ -15,6 +15,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
assets_bp = Blueprint('assets', __name__)
@@ -470,6 +471,7 @@ def create_asset():
)
db.session.add(asset)
apply_import_timestamps(asset, data)
db.session.commit()
return success_response(asset.to_dict(), message='Asset created', http_code=201)
@@ -512,6 +514,7 @@ def update_asset(asset_id: int):
if key in data:
setattr(asset, key, data[key])
apply_import_timestamps(asset, data)
db.session.commit()
return success_response(asset.to_dict(), message='Asset updated')
@@ -657,6 +660,7 @@ def create_asset_relationship():
)
db.session.add(rel)
apply_import_timestamps(rel, data)
db.session.commit()
return success_response(rel.to_dict(), message='Relationship created', http_code=201)

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
businessunits_bp = Blueprint('businessunits', __name__)
@@ -29,6 +30,10 @@ def list_businessunits():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(BusinessUnit.isactive == True)
# Exact-match natural-key lookup for idempotent import (unit name).
if exactunit := request.args.get('businessunit'):
query = query.filter(BusinessUnit.businessunit == exactunit)
if search := request.args.get('search'):
query = query.filter(
db.or_(
@@ -90,6 +95,7 @@ def create_businessunit():
)
db.session.add(bu)
apply_import_timestamps(bu, data)
db.session.commit()
return success_response(bu.to_dict(), message='Business unit created', http_code=201)
@@ -125,6 +131,7 @@ def update_businessunit(bu_id: int):
if key in data:
setattr(bu, key, data[key])
apply_import_timestamps(bu, data)
db.session.commit()
return success_response(bu.to_dict(), message='Business unit updated')

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
locations_bp = Blueprint('locations', __name__)
@@ -110,6 +111,10 @@ def list_locations():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Location.isactive == True)
# Exact-match natural-key lookup for idempotent import (location name).
if exactname := request.args.get('locationname'):
query = query.filter(Location.locationname == exactname)
if search := request.args.get('search'):
query = query.filter(
db.or_(
@@ -173,6 +178,7 @@ def create_location():
)
db.session.add(loc)
apply_import_timestamps(loc, data)
db.session.commit()
return success_response(loc.to_dict(), message='Location created', http_code=201)
@@ -210,6 +216,7 @@ def update_location(location_id: int):
if key in data:
setattr(loc, key, data[key])
apply_import_timestamps(loc, data)
db.session.commit()
return success_response(loc.to_dict(), message='Location updated')

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
models_bp = Blueprint('models', __name__)
@@ -35,6 +36,11 @@ def list_models():
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
# Exact-match natural-key lookup for idempotent import. Natural key is
# modelnumber + vendor; pair this with ?vendor=<id> to disambiguate.
if exactmodelnumber := request.args.get('modelnumber'):
query = query.filter(Model.modelnumber == exactmodelnumber)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
@@ -105,6 +111,7 @@ def create_model():
)
db.session.add(m)
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model created', http_code=201)
@@ -132,6 +139,7 @@ def update_model(model_id: int):
if key in data:
setattr(m, key, data[key])
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model updated')

View File

@@ -18,6 +18,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
modeltypes_bp = Blueprint('modeltypes', __name__)
@@ -36,6 +37,10 @@ def list_modeltypes():
if category := request.args.get('category'):
query = query.filter(ModelType.category == category)
# Exact-match natural-key lookup for idempotent import (type name).
if exactmodeltype := request.args.get('modeltype'):
query = query.filter(ModelType.modeltype == exactmodeltype)
if search := request.args.get('search'):
query = query.filter(ModelType.modeltype.ilike(f'%{search}%'))
@@ -88,6 +93,7 @@ def create_modeltype():
)
db.session.add(mt)
apply_import_timestamps(mt, data)
db.session.commit()
return success_response(mt.to_dict(), message='Model type created', http_code=201)
@@ -124,6 +130,7 @@ def update_modeltype(type_id: int):
if key in data:
setattr(mt, key, data[key])
apply_import_timestamps(mt, data)
db.session.commit()
return success_response(mt.to_dict(), message='Model type updated')

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
operatingsystems_bp = Blueprint('operatingsystems', __name__)
@@ -29,6 +30,13 @@ def list_operatingsystems():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(OperatingSystem.isactive == True)
# Exact-match natural-key lookup for idempotent import. Legacy OS rows have
# only a name; pair with ?osversion= when versions are tracked separately.
if exactosname := request.args.get('osname'):
query = query.filter(OperatingSystem.osname == exactosname)
if exactosversion := request.args.get('osversion'):
query = query.filter(OperatingSystem.osversion == exactosversion)
if search := request.args.get('search'):
query = query.filter(OperatingSystem.osname.ilike(f'%{search}%'))
@@ -85,6 +93,7 @@ def create_operatingsystem():
)
db.session.add(os)
apply_import_timestamps(os, data)
db.session.commit()
return success_response(os.to_dict(), message='Operating system created', http_code=201)
@@ -112,6 +121,7 @@ def update_operatingsystem(os_id: int):
if key in data:
setattr(os, key, data[key])
apply_import_timestamps(os, data)
db.session.commit()
return success_response(os.to_dict(), message='Operating system updated')

View File

@@ -14,6 +14,7 @@ from shopdb.utils.responses import (
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.import_mode import apply_import_timestamps
vendors_bp = Blueprint('vendors', __name__)
@@ -29,6 +30,10 @@ def list_vendors():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Vendor.isactive == True)
# Exact-match natural-key lookup for idempotent import (vendor name).
if exactvendor := request.args.get('vendor'):
query = query.filter(Vendor.vendor == exactvendor)
if search := request.args.get('search'):
query = query.filter(Vendor.vendor.ilike(f'%{search}%'))
@@ -83,6 +88,7 @@ def create_vendor():
)
db.session.add(v)
apply_import_timestamps(v, data)
db.session.commit()
return success_response(v.to_dict(), message='Vendor created', http_code=201)
@@ -118,6 +124,7 @@ def update_vendor(vendor_id: int):
if key in data:
setattr(v, key, data[key])
apply_import_timestamps(v, data)
db.session.commit()
return success_response(v.to_dict(), message='Vendor updated')