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