Files
shopdb-flask/shopdb/utils/import_mode.py
cproudlock 46e50c07ff
All checks were successful
CI / backend (push) Successful in 1m4s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
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>
2026-07-11 20:10:46 -04:00

105 lines
4.0 KiB
Python

"""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