#!/usr/bin/env python3 """ Seed freight contracts across every flow variant, driven end-to-end over HTTP. For each flow the script logs in, creates a DRAFT contract, and pushes it through the lifecycle up to and INCLUDING both signatures (customer sign + staff counter-sign). It STOPS after the staff counter-sign — no clearance, no booking. Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built): movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT) kind : one-time(ONE_TIME) | general(GENERAL) customs : without | with (customsClearingEnabled) freight : bulk(BULK) | container(CONTAINER) intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose break is downstream in clearance). Each is created twice → 40 contracts on `all`. CLI (filter by movement, pass one or many): python create_contracts.py # all 20 flows python create_contracts.py all # all 20 flows python create_contracts.py intercity # only DOMESTIC flows python create_contracts.py import # only IMPORT flows python create_contracts.py import export # IMPORT + EXPORT flows Config comes from .env (see .env.example). Requires: requests, psycopg, python-dotenv (see requirements.txt). """ from __future__ import annotations import base64 import os import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Any import psycopg import requests from dotenv import load_dotenv HERE = Path(__file__).resolve().parent load_dotenv(HERE / ".env") # --------------------------------------------------------------------------- # # Config # --------------------------------------------------------------------------- # API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/") CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "") CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "") ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") # Phone the sign-OTP is sent to and read back from Postgres. Resolved at runtime # from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an # optional override / fallback used only when the customer has no phone on file. # The sign endpoint keys the OTP purely on this number, so it just has to be the # same value for "send" and "read". OTP_PHONE = os.getenv("OTP_PHONE", "") OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000") # DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications. DB_HOST = os.getenv("DB_HOST", "localhost") DB_PORT = os.getenv("DB_PORT", "5432") DB_NAME = os.getenv("DB_NAME", "edr_dev") DB_USER = os.getenv("DB_USER", "postgres") DB_PASSWORD = os.getenv("DB_PASSWORD", "") DB_SCHEMA = os.getenv("DB_SCHEMA", "freight") WAAFI = HERE / "waafi.jpeg" VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365")) CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2")) REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60")) # --------------------------------------------------------------------------- # # Flow matrix — the 20 real flows # --------------------------------------------------------------------------- # @dataclass(frozen=True) class Flow: movement: str # intercity | import | export trade_direction: str # DOMESTIC | IMPORT | EXPORT kind: str # ONE_TIME | GENERAL customs: bool # customsClearingEnabled freight: str # BULK | CONTAINER @property def label(self) -> str: return ( f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}" f"+{'with' if self.customs else 'no'}-customs" f"+{self.freight.lower()}" ) def build_flow_matrix() -> list[Flow]: movements = [ ("intercity", "DOMESTIC"), ("import", "IMPORT"), ("export", "EXPORT"), ] kinds = ["ONE_TIME", "GENERAL"] freights = ["BULK", "CONTAINER"] flows: list[Flow] = [] for movement, direction in movements: # DOMESTIC ignores customs (no clearance gate) → customs=True is not a # real combo. Only build without-customs for intercity. customs_options = [False] if direction == "DOMESTIC" else [False, True] for kind in kinds: for customs in customs_options: for freight in freights: flows.append(Flow(movement, direction, kind, customs, freight)) return flows # --------------------------------------------------------------------------- # # HTTP client # --------------------------------------------------------------------------- # class ApiError(RuntimeError): def __init__(self, method: str, path: str, resp: requests.Response): body = resp.text try: body = resp.json() except Exception: pass super().__init__(f"{method} {path} -> {resp.status_code}: {body}") self.status_code = resp.status_code class Client: """Thin wrapper that carries a bearer token.""" def __init__(self, name: str, token: str | None = None): self.name = name self.token = token def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: h: dict[str, str] = {} if self.token: h["Authorization"] = f"Bearer {self.token}" if extra: h.update(extra) return h def get(self, path: str, params: dict | None = None) -> Any: r = requests.get( f"{API_URL}{path}", headers=self._headers(), params=params, timeout=REQUEST_TIMEOUT, ) if not r.ok: raise ApiError("GET", path, r) return r.json() if r.content else None def post_json(self, path: str, body: dict | None = None) -> Any: r = requests.post( f"{API_URL}{path}", headers=self._headers({"Content-Type": "application/json"}), json=body or {}, timeout=REQUEST_TIMEOUT, ) if not r.ok: raise ApiError("POST", path, r) return r.json() if r.content else None def post_multipart( self, path: str, data: dict[str, str], files: list[tuple] | None = None ) -> Any: r = requests.post( f"{API_URL}{path}", headers=self._headers(), # requests sets multipart Content-Type data=data, files=files or [], timeout=REQUEST_TIMEOUT, ) if not r.ok: raise ApiError("POST", path, r) return r.json() if r.content else None def login(email: str, password: str, who: str) -> Client: r = requests.post( f"{API_URL}/auth/login", json={"email": email, "password": password}, timeout=REQUEST_TIMEOUT, ) if not r.ok: raise ApiError("POST", "/auth/login", r) payload = r.json() if payload.get("mfaRequired"): raise RuntimeError( f"{who} login requires MFA — this script cannot complete an MFA login. " "Disable MFA for the seed account or supply a non-MFA account." ) token = payload.get("token") if not token: raise RuntimeError(f"{who} login returned no token: {payload}") return Client(who, token) # --------------------------------------------------------------------------- # # OTP — send + read from Postgres # --------------------------------------------------------------------------- # def resolve_otp_phone(customer: Client) -> str: """Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone (GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK. The value only has to be consistent between send + DB read.""" phone = "" try: me = customer.get("/me") or {} phone = (me.get("phoneNumber") or "").strip() except Exception: pass phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK if not phone: raise RuntimeError( "Could not resolve an OTP phone (customer has none, and neither " "OTP_PHONE nor OTP_PHONE_FALLBACK is set)." ) return phone def send_otp(customer: Client, phone: str) -> None: # POST /api/otp/send is @Public — no token needed, but sending one is harmless. customer.post_json("/otp/send", {"phone": phone}) def read_otp_from_db(phone: str) -> str: """Read the freshest plaintext OTP for `phone` from freight.otp_verifications.""" dsn = ( f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} " f"user={DB_USER} password={DB_PASSWORD}" ) with psycopg.connect(dsn) as conn: with conn.cursor() as cur: cur.execute( f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications ' "WHERE phone = %s ORDER BY updated_at DESC LIMIT 1", (phone,), ) row = cur.fetchone() if not row: raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications") return str(row[0]) # --------------------------------------------------------------------------- # # Reference-data lookups (yards / service types / cargo types) # --------------------------------------------------------------------------- # @dataclass class RefData: yards: list[dict] = field(default_factory=list) service_types: list[dict] = field(default_factory=list) cargo_types: list[dict] = field(default_factory=list) def _as_items(resp: Any) -> list[dict]: if isinstance(resp, list): return resp if isinstance(resp, dict): return resp.get("items") or resp.get("data") or [] return [] def load_ref_data(client: Client) -> RefData: ref = RefData( yards=_as_items(client.get("/yards")), service_types=_as_items(client.get("/service-types")), cargo_types=_as_items(client.get("/cargo-types")), ) if len(ref.yards) < 2: raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.") if not ref.service_types: raise RuntimeError("No service types found. Seed service types first.") if not ref.cargo_types: raise RuntimeError("No cargo types found. Seed cargo types first.") return ref def pick_service_type(ref: RefData, wants_customs: bool) -> str: """Prefer a service type whose includesCustoms matches the flow's customs need.""" for st in ref.service_types: if bool(st.get("includesCustoms")) == wants_customs: return st["id"] # Fall back to any — customsClearingEnabled on the contract still drives the flow. return ref.service_types[0]["id"] # --------------------------------------------------------------------------- # # Contract payload builder # --------------------------------------------------------------------------- # def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]: """Return multipart form fields. Booleans as 'true'/'false' strings; nested arrays as JSON strings (implicit conversion is off in the API).""" import json origin = ref.yards[0]["id"] destination = ref.yards[1]["id"] service_type_id = pick_service_type(ref, flow.customs) # Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row. if flow.freight == "CONTAINER": cargo_scope = [{"containerSize": "20ft"}] if flow.kind == "GENERAL": cargo_scope[0]["quantityCap"] = 10 else: # BULK cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}] if flow.kind == "GENERAL": cargo_scope[0]["quantityCap"] = 1000 # Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine). routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}] fields: dict[str, str] = { "contractKind": flow.kind, "tradeDirection": flow.trade_direction, "freightType": flow.freight, "serviceTypeId": service_type_id, "paymentCurrency": "ETB", "customsClearingEnabled": "true" if flow.customs else "false", "contractType": "SPOT", "cargoScope": json.dumps(cargo_scope), "routes": json.dumps(routes), } if flow.customs: fields["customsClearingAgent"] = "Seed Agent" return fields def signature_b64() -> str: return base64.b64encode(WAAFI.read_bytes()).decode() # --------------------------------------------------------------------------- # # Lifecycle driver — create → submit → accept → approve → generate → sign x2 # --------------------------------------------------------------------------- # def waafi_file_tuple(field_name: str) -> tuple: return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg")) def drive_flow( flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData, otp_phone: str ) -> dict[str, Any]: result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None} # S1 — create (customer, multipart, waafi attached as intake doc) fields = build_create_payload(flow, ref, idx) contract = customer.post_multipart( "/contracts", data=fields, files=[waafi_file_tuple("intake_document")] ) cid = contract["id"] result["contractId"] = cid result["reference"] = contract.get("reference") # S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm. contract = customer.post_json(f"/contracts/{cid}/submit") if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM": contract = customer.post_json(f"/contracts/{cid}/confirm-submit") # S3 — staff accept (admin) → PENDING_APPROVAL + approval chain admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS}) # S4 — approve every pending step IN ORDER with its exact requiredRole (admin) approve_all_steps(admin, cid) # S5 — generate contract document (admin) → CONTRACT_READY admin.post_json(f"/contracts/{cid}/contract/generate") # S6 — customer sign (needs OTP) → SIGNED_CUSTOMER send_otp(customer, otp_phone) time.sleep(1.0) # let the OTP row land otp = read_otp_from_db(otp_phone) customer.post_json( f"/contracts/{cid}/contract/sign", { "role": "CUSTOMER", "signatureImageBase64": signature_b64(), "signerDisplayName": "Seed Customer", "consentText": "I agree.", "otp": otp, "otpPhone": otp_phone, }, ) # S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE / # AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE. signed = admin.post_json( f"/contracts/{cid}/contract/sign", { "role": "STAFF", "signatureImageBase64": signature_b64(), "signerDisplayName": "Seed Staff", "consentText": "Countersigned.", }, ) result["status"] = (signed or {}).get("status") return result def approve_all_steps(admin: Client, cid: str) -> None: """Read the contract, approve each PENDING approval step in order. Superadmin can approve any role, but the endpoint still checks step.requiredRole == body, so we echo the step's own requiredRole back.""" guard = 0 while True: guard += 1 if guard > 12: raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}") contract = admin.get(f"/contracts/{cid}") steps = contract.get("approvalSteps") or [] pending = [s for s in steps if s.get("status") == "PENDING"] if not pending: return # findNextPendingApprovalStep orders by sequence; sort the same way. pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0))) step = pending[0] admin.post_json( f"/contracts/{cid}/approval-steps/{step['id']}/approve", {"requiredRole": step["requiredRole"]}, ) # --------------------------------------------------------------------------- # # Main # --------------------------------------------------------------------------- # VALID_FILTERS = {"all", "intercity", "import", "export"} def parse_filters(argv: list[str]) -> set[str]: args = [a.lower() for a in argv[1:]] if not args or "all" in args: return {"intercity", "import", "export"} unknown = set(args) - VALID_FILTERS if unknown: raise SystemExit( f"Unknown filter(s): {', '.join(sorted(unknown))}. " f"Valid: {', '.join(sorted(VALID_FILTERS))}" ) return set(args) def require_config() -> None: missing = [ name for name, val in [ ("CUSTOMER_EMAIL", CUSTOMER_EMAIL), ("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD), ("ADMIN_EMAIL", ADMIN_EMAIL), ("ADMIN_PASSWORD", ADMIN_PASSWORD), ] if not val ] if missing: raise SystemExit(f"Missing required .env keys: {', '.join(missing)}") if not WAAFI.exists(): raise SystemExit(f"Missing signature/upload image: {WAAFI}") def main() -> None: require_config() wanted = parse_filters(sys.argv) flows = [f for f in build_flow_matrix() if f.movement in wanted] total = len(flows) * CONTRACTS_PER_FLOW print(f"API : {API_URL}") print(f"Filters : {', '.join(sorted(wanted))}") print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n") print("Logging in...") customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer") admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin") otp_phone = resolve_otp_phone(customer) print(f"OTP phone: {otp_phone}") print("Loading reference data...") ref = load_ref_data(admin) results: list[dict] = [] for flow in flows: for n in range(1, CONTRACTS_PER_FLOW + 1): tag = f"[{flow.label} #{n}]" try: res = drive_flow(flow, n, customer, admin, ref, otp_phone) print(f" OK {tag} {res['reference']} -> {res['status']}") results.append(res) except Exception as exc: # noqa: BLE001 — report and continue print(f" FAIL {tag} {exc}") results.append({"flow": flow.label, "n": n, "error": str(exc)}) ok = [r for r in results if not r.get("error")] bad = [r for r in results if r.get("error")] print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.") if bad: sys.exit(1) if __name__ == "__main__": main()