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:
@@ -23,7 +23,7 @@ from .plugins import plugin_manager
|
||||
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
||||
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
||||
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
||||
__contract_version__ = '0.7.0'
|
||||
__contract_version__ = '0.8.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -58,6 +58,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_role
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
apply_import_timestamps,
|
||||
import_mode_active,
|
||||
parse_import_datetime,
|
||||
)
|
||||
|
||||
# Legacy employee directory lookup (read-only) used by notifications
|
||||
from shopdb.utils.employee_db import employee_connection
|
||||
|
||||
@@ -239,6 +246,10 @@ __all__ = [
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_role',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
'parse_import_datetime',
|
||||
# Legacy employee directory
|
||||
'employee_connection',
|
||||
# CMMC USB check-in/out database
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
104
shopdb/utils/import_mode.py
Normal file
104
shopdb/utils/import_mode.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Import-mode helpers: let admins replay legacy history through the API.
|
||||
|
||||
A migration script (or an LLM driving one) importing the classic ASP shopdb
|
||||
needs two things the normal API withholds:
|
||||
|
||||
1. Preserve each row's original createddate/modifieddate instead of stamping
|
||||
"now" on insert/update.
|
||||
2. Backdate event history such as USB checkouts to when they really happened.
|
||||
|
||||
Both are gated. The caller must BOTH be an admin AND send the request header
|
||||
`X-Import-Mode: true`. Outside import mode every helper here is a no-op, so
|
||||
wiring a call into a normal create/update path never changes behavior for
|
||||
regular users. See docs/IMPORT-API.md for the operator manual.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user
|
||||
|
||||
|
||||
# Request header a migration client sets to opt a request into import mode.
|
||||
IMPORT_MODE_HEADER = 'X-Import-Mode'
|
||||
|
||||
# strptime formats accepted for import timestamps, tried in order. Covers the
|
||||
# ISO 'T' form and the legacy MySQL 'space' form, with and without fractions,
|
||||
# plus a bare date.
|
||||
_IMPORT_DATETIME_FORMATS = (
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M',
|
||||
'%Y-%m-%d %H:%M:%S.%f',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%d %H:%M',
|
||||
'%Y-%m-%d',
|
||||
)
|
||||
|
||||
|
||||
def import_mode_active():
|
||||
"""True when the request is an admin-authenticated import request.
|
||||
|
||||
Needs header `X-Import-Mode: true` AND an admin caller. Safe to call from
|
||||
any request context: verify_jwt_in_request is optional here so a missing or
|
||||
bad token yields False instead of raising, and it is idempotent when the
|
||||
route already ran @jwt_required."""
|
||||
header = (request.headers.get(IMPORT_MODE_HEADER) or '').strip().lower()
|
||||
if header != 'true':
|
||||
return False
|
||||
verify_jwt_in_request(optional=True)
|
||||
user = current_user
|
||||
return bool(user is not None and user.hasrole('admin'))
|
||||
|
||||
|
||||
def parse_import_datetime(value):
|
||||
"""Parse an import timestamp into naive UTC, or None.
|
||||
|
||||
Accepts ISO '2020-01-05T12:00:00', legacy 'YYYY-MM-DD HH:MM:SS', a bare
|
||||
date, or an already-parsed datetime. A trailing 'Z' is treated as UTC.
|
||||
Timezone-aware input is converted to UTC then stripped to naive, matching
|
||||
the repo convention of storing naive-UTC in DB DateTime columns."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text[-1] in ('Z', 'z'):
|
||||
text = text[:-1]
|
||||
parsed = None
|
||||
for fmt in _IMPORT_DATETIME_FORMATS:
|
||||
try:
|
||||
parsed = datetime.strptime(text, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed is None:
|
||||
# last resort: let fromisoformat try (handles offsets like +00:00)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def apply_import_timestamps(instance, data):
|
||||
"""Stamp createddate/modifieddate on a row from the payload, in import mode.
|
||||
|
||||
Call this in a create/update path after building the instance and before
|
||||
commit. It is a no-op unless ALL hold: import mode is active (admin +
|
||||
header), the payload carries the field, and the model actually has the
|
||||
column. Explicitly setting modifieddate also suppresses the column's
|
||||
onupdate=now default on updates, so legacy history survives edits too."""
|
||||
if not data or not import_mode_active():
|
||||
return
|
||||
created = parse_import_datetime(data.get('createddate'))
|
||||
if created is not None and hasattr(instance, 'createddate'):
|
||||
instance.createddate = created
|
||||
modified = parse_import_datetime(data.get('modifieddate'))
|
||||
if modified is not None and hasattr(instance, 'modifieddate'):
|
||||
instance.modifieddate = modified
|
||||
Reference in New Issue
Block a user