Add the API import surface for legacy migrations (contract 0.8.0)
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:
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user