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

@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVe
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, require_role
from shopdb.api import require_permission, require_role, apply_import_timestamps
computers_bp = Blueprint('computers', __name__)
@@ -323,6 +323,10 @@ def list_computers():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter
if search := request.args.get('search'):
query = query.filter(
@@ -551,6 +555,9 @@ def create_computer():
# Remote-access protocols
_sync_access_methods(comp, data)
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
# Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber'])
@@ -655,6 +662,7 @@ def update_computer(computer_id: int):
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
entityname=comp.hostname or asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()

View File

@@ -16,7 +16,7 @@ from shopdb.api import (
from ..models import KnowledgeBase
from shopdb.api import require_permission, require_role
from shopdb.api import require_permission, require_role, apply_import_timestamps
knowledgebase_bp = Blueprint('knowledgebase', __name__)
@@ -42,6 +42,13 @@ def list_articles():
if appid := request.args.get('appid'):
query = query.filter(KnowledgeBase.appid == int(appid))
# Exact-match natural-key lookups for idempotent import. linkurl is the
# stable natural key; shortdescription (the title) is offered as a fallback.
if exactlinkurl := request.args.get('linkurl'):
query = query.filter(KnowledgeBase.linkurl == exactlinkurl)
if exacttitle := request.args.get('shortdescription'):
query = query.filter(KnowledgeBase.shortdescription == exacttitle)
# Sort options
sort = request.args.get('sort', 'clicks')
order = request.args.get('order', 'desc')
@@ -165,6 +172,7 @@ def create_article():
)
db.session.add(article)
apply_import_timestamps(article, data)
db.session.commit()
return success_response(article.to_dict(), message='Article created', http_code=201)
@@ -195,6 +203,7 @@ def update_article(link_id: int):
if key in data:
setattr(article, key, data[key])
apply_import_timestamps(article, data)
db.session.commit()
return success_response(article.to_dict(), message='Article updated')

View File

@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_re
from ..models import Machine, MachineType
from shopdb.api import require_permission, require_role
from shopdb.api import require_permission, require_role, apply_import_timestamps
machines_bp = Blueprint('machines', __name__)
@@ -170,6 +170,10 @@ def list_machines():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter
if search := request.args.get('search'):
query = query.filter(
@@ -337,6 +341,9 @@ def create_machine():
db.session.add(mach)
db.session.flush()
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
# Audit log
AuditLog.log('created', 'Machine', entityid=mach.machineid,
entityname=data['assetnumber'])
@@ -412,6 +419,7 @@ def update_machine(machine_id: int):
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
entityname=asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()

View File

@@ -19,7 +19,7 @@ from shopdb.api import (
db, Asset, AssetType, AuditLog,
success_response, error_response, paginated_response, ErrorCodes,
get_pagination_params, paginate_query,
require_permission,
require_permission, apply_import_timestamps,
)
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
@@ -165,6 +165,9 @@ def list_tools():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
if search := request.args.get('search'):
query = query.filter(db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
@@ -270,6 +273,7 @@ def create_tool():
db.session.add(tool)
db.session.flush()
apply_import_timestamps(asset, data)
AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid,
entityname=asset.assetnumber)
db.session.commit()
@@ -321,6 +325,7 @@ def update_tool(tool_id: int):
if changes:
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
entityname=asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
return success_response(_merged(tool), message='Measuring tool updated')

View File

@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response,
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from shopdb.api import require_permission, require_role
from shopdb.api import require_permission, require_role, apply_import_timestamps
network_bp = Blueprint('network', __name__)
@@ -172,6 +172,10 @@ def list_network_devices():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter
if search := request.args.get('search'):
query = query.filter(
@@ -376,6 +380,9 @@ def create_network_device():
db.session.add(netdev)
db.session.flush()
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
# Audit log
AuditLog.log('created', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=data.get('hostname') or data['assetnumber'])
@@ -458,6 +465,7 @@ def update_network_device(device_id: int):
AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=netdev.hostname or asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()

View File

@@ -288,6 +288,11 @@ def list_notifications():
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Notification.notificationtypeid == int(type_id))
# Exact-match lookup for idempotent import. Notifications have no strong
# natural key; ticketnumber is the best available when a ticket is set.
if exactticket := request.args.get('ticketnumber'):
query = query.filter(Notification.ticketnumber == exactticket)
# Current filter (active based on dates)
if request.args.get('current', 'false').lower() == 'true':
now = datetime.now(timezone.utc).replace(tzinfo=None)

View File

@@ -19,7 +19,7 @@ from ..services import (
logger = logging.getLogger(__name__)
from shopdb.api import require_permission, require_role
from shopdb.api import require_permission, require_role, apply_import_timestamps
printers_asset_bp = Blueprint('printers_asset', __name__)
@@ -235,6 +235,10 @@ def list_printers():
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter
if search := request.args.get('search'):
query = query.filter(
@@ -538,6 +542,9 @@ def create_printer():
)
db.session.add(comm)
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()
@@ -613,6 +620,7 @@ def update_printer(printer_id: int):
elif comm:
comm.ipaddress = None
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()

View File

@@ -21,6 +21,7 @@ from datetime import datetime, timezone
from shopdb.api import (
db, success_response, error_response, ErrorCodes,
get_pagination_params, paginated_response,
apply_import_timestamps, import_mode_active, parse_import_datetime,
)
from ..models import USBDevice, USBCheckout
@@ -147,6 +148,7 @@ def create_device(data):
storagelocation=data.get('locker_location'),
ischeckedout=False, isactive=True)
db.session.add(device)
apply_import_timestamps(device, data)
db.session.commit()
return success_response(_device_dict(device), message='Device created', http_code=201)
@@ -189,13 +191,20 @@ def checkout_device(device_id, data):
return error_response(ErrorCodes.CONFLICT, 'Device is already checked out', http_code=409)
name = _resolve_name(badge)
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Backdated import: an admin import request may pass the historical
# checkouttime so migrated usbcheckouts rows keep their real event time.
eventtime = now
if import_mode_active():
override = parse_import_datetime(data.get('checkouttime'))
if override is not None:
eventtime = override
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, machineid=0, sso=badge,
checkoutname=name, checkouttime=now,
checkoutname=name, checkouttime=eventtime,
checkoutreason=data.get('reason')))
device.ischeckedout = True
device.currentuserid = badge
device.currentusername = name
device.currentcheckoutdate = now
device.currentcheckoutdate = eventtime
if data.get('locker_location'):
device.storagelocation = data['locker_location']
db.session.commit()
@@ -215,7 +224,14 @@ def checkin_device(device_id, data):
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
# Backdated import: accept the historical checkintime from an admin
# import request so returned checkouts keep their real return time.
if import_mode_active():
override = parse_import_datetime(data.get('checkintime'))
if override is not None:
checkintime = override
open_checkout.checkintime = checkintime
open_checkout.waswiped = bool(data.get('sanitized'))
open_checkout.checkinnotes = data.get('notes')
device.ischeckedout = False

View File

@@ -77,6 +77,11 @@ def list_warranties():
query = Warranty.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter_by(isactive=True)
# Exact-match natural-key lookup for idempotent import (servicetag + vendor).
if exactservicetag := request.args.get('servicetag'):
query = query.filter(Warranty.servicetag == exactservicetag)
if exactvendor := request.args.get('vendor'):
query = query.filter(Warranty.vendor == exactvendor)
assetid = request.args.get('assetid', type=int)
if assetid:
query = (query.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)