Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
111
shopdb/utils/import_mode.py
Normal file
111
shopdb/utils/import_mode.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Import-mode helpers: let admins replay legacy history through the API.
|
||||
|
||||
A migration script 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 the IMPORT-API wiki page for the operator manual.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
||||
|
||||
|
||||
# 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
|
||||
if user is None or not user.hasrole('admin'):
|
||||
return False
|
||||
# A scoped PAT never gets import mode: import mode is an admin-role
|
||||
# capability and a scoped token suspends the admin bypass. get_jwt is safe
|
||||
# here - an admin user means a valid JWT was decoded above.
|
||||
if get_jwt().get('patscopes') is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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