360 lines
16 KiB
Python
360 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Polaris mock API — serves /api endpoints for FRONTEND VERIFICATION ONLY.
|
|
|
|
Run: python3 mock_api.py [port] (default 8000)
|
|
|
|
Not part of the frontend Docker image (.dockerignore excludes mock/).
|
|
Mimics the contract in CONTRACT.md against db/schema.sql shapes.
|
|
"""
|
|
import json
|
|
import random
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
ADMIN_EMAIL = "admin@polaris.shop"
|
|
ADMIN_PASSWORD = "polaris-admin"
|
|
TOKEN = "mock-jwt-payload-not-for-production"
|
|
|
|
CATS = ["Home", "Gadgets", "Office", "Audio"]
|
|
|
|
PRODUCT_SEEDS = [
|
|
("Aurora Desk Lamp", "Home", "AuroraLight", 12.50, 38.50),
|
|
("Nimbus Smart Scale", "Home", "NimbusLab", 18.20, 52.00),
|
|
("Polaris Pro Keyboard", "Gadgets", "Keystroke", 24.90, 79.00),
|
|
("Stellar Mouse MX", "Gadgets", "Keystroke", 11.30, 44.00),
|
|
("Comet USB-C Hub", "Gadgets", "PortWorks", 15.60, 59.00),
|
|
("Galaxy Standing Desk", "Office", "WorkSpan", 145.00, 389.00),
|
|
("Nova Ergonomic Chair", "Office", "WorkSpan", 98.00, 279.00),
|
|
("Lunar Notebook Set", "Office", "PaperNest", 4.80, 16.50),
|
|
("Eclipse Headphones", "Audio", "SoundOrbit", 32.00, 129.00),
|
|
("Pulsar Bluetooth Speaker", "Audio", "SoundOrbit", 21.50, 74.00),
|
|
("Quasar Webcam 4K", "Gadgets", "ClearSight", 39.00, 119.00),
|
|
("Vega Monitor Arm", "Office", "WorkSpan", 27.00, 89.00),
|
|
]
|
|
|
|
LIFECYCLE = [
|
|
"discovered", "analyzing", "approved", "imported", "content_generated",
|
|
"published", "published", "published", "published", "published",
|
|
"monitored", "paused",
|
|
]
|
|
|
|
STATUS_FLOW = ["new", "paid", "fraud_check", "supplier_order", "confirmed", "shipped", "delivered"]
|
|
|
|
|
|
def now_iso(days_ago=0, hours_ago=0):
|
|
return (datetime.now(timezone.utc) - timedelta(days=days_ago, hours=hours_ago)).isoformat()
|
|
|
|
|
|
def build_products():
|
|
products = []
|
|
for i, (title, cat, brand, cost, retail) in enumerate(PRODUCT_SEEDS, start=1):
|
|
products.append({
|
|
"id": f"prd-{i:03d}",
|
|
"sku": f"POL-{i:04d}",
|
|
"title": title,
|
|
"description": f"{title} — sourced, vetted and dynamically priced by the Polaris pipeline. Ships from a top-scoring supplier with live tracking.",
|
|
"category": cat,
|
|
"brand": brand,
|
|
"manufacturer": brand,
|
|
"upc_ean_gtin": f"8{i:013d}",
|
|
"images": [],
|
|
"dimensions": {"l_cm": 20, "w_cm": 14, "h_cm": 8},
|
|
"weight_grams": 300 + i * 40,
|
|
"cost": cost,
|
|
"retail_price": retail,
|
|
"min_price": round(cost * 1.8, 2),
|
|
"max_price": round(retail * 1.35, 2),
|
|
"status": LIFECYCLE[i % len(LIFECYCLE)],
|
|
"lifecycle": {"steps_completed": i % 8},
|
|
"created_at": now_iso(days_ago=20 - i % 14),
|
|
"updated_at": now_iso(hours_ago=i),
|
|
})
|
|
return products
|
|
|
|
|
|
PRODUCTS = build_products()
|
|
|
|
SUPPLIERS = [
|
|
{"id": "sup-001", "name": "Starline Wholesale", "adapter_type": "csv_http", "api_endpoint": "https://feeds.starline.example/products.csv", "reliability_score": 0.94, "status": "active", "created_at": now_iso(days_ago=40)},
|
|
{"id": "sup-002", "name": "NorthBridge Supply", "adapter_type": "xml_ftp", "api_endpoint": "ftp://nb.example/feed.xml", "reliability_score": 0.87, "status": "active", "created_at": now_iso(days_ago=31)},
|
|
{"id": "sup-003", "name": "Meridian Goods", "adapter_type": "json_api", "api_endpoint": "https://api.meridian.example/v2", "reliability_score": 0.71, "status": "paused", "created_at": now_iso(days_ago=12)},
|
|
]
|
|
|
|
SUPPLIER_PERF = [
|
|
{"supplier_id": "sup-001", "supplier_name": "Starline Wholesale", "fulfillment_rate": 0.96, "avg_shipping_days": 2.1, "cancellation_rate": 0.02, "stock_accuracy": 0.93, "return_rate": 0.03, "updated_at": now_iso()},
|
|
{"supplier_id": "sup-002", "supplier_name": "NorthBridge Supply", "fulfillment_rate": 0.89, "avg_shipping_days": 3.4, "cancellation_rate": 0.07, "stock_accuracy": 0.84, "return_rate": 0.05, "updated_at": now_iso()},
|
|
{"supplier_id": "sup-003", "supplier_name": "Meridian Goods", "fulfillment_rate": 0.72, "avg_shipping_days": 5.2, "cancellation_rate": 0.16, "stock_accuracy": 0.66, "return_rate": 0.09, "updated_at": now_iso()},
|
|
]
|
|
|
|
ORDERS = [
|
|
{
|
|
"id": "ord-1001", "order_number": "POL-1001", "customer_id": "cus-1", "customer_email": "ada@example.com",
|
|
"items": [{"product_id": "prd-001", "qty": 2, "unit_price": 38.50, "title": "Aurora Desk Lamp"}],
|
|
"retail_total": 77.00, "supplier_id": "sup-001", "supplier_cost": 25.00, "shipping_cost": 6.99, "fees": 1.5,
|
|
"profit": 43.51, "status": "delivered", "tracking": "1Z999AA10123456784", "carrier": "UPS",
|
|
"created_at": now_iso(days_ago=6), "updated_at": now_iso(days_ago=3),
|
|
},
|
|
{
|
|
"id": "ord-1002", "order_number": "POL-1002", "customer_id": "cus-2", "customer_email": "grace@example.com",
|
|
"items": [{"product_id": "prd-003", "qty": 1, "unit_price": 79.00, "title": "Polaris Pro Keyboard"}, {"product_id": "prd-004", "qty": 1, "unit_price": 44.00, "title": "Stellar Mouse MX"}],
|
|
"retail_total": 123.00, "supplier_id": "sup-002", "supplier_cost": 36.20, "shipping_cost": 0.0, "fees": 2.4,
|
|
"profit": 84.40, "status": "shipped", "tracking": "9400100000000000000001", "carrier": "USPS",
|
|
"created_at": now_iso(days_ago=1, hours_ago=5), "updated_at": now_iso(hours_ago=2),
|
|
},
|
|
{
|
|
"id": "ord-1003", "order_number": "POL-1003", "customer_id": "cus-3", "customer_email": "linus@example.com",
|
|
"items": [{"product_id": "prd-009", "qty": 1, "unit_price": 129.00, "title": "Eclipse Headphones"}],
|
|
"retail_total": 129.00, "supplier_id": "sup-001", "supplier_cost": 32.00, "shipping_cost": 0.0, "fees": 2.5,
|
|
"profit": 94.50, "status": "confirmed", "tracking": None, "carrier": None,
|
|
"created_at": now_iso(hours_ago=9), "updated_at": now_iso(hours_ago=4),
|
|
},
|
|
{
|
|
"id": "ord-1004", "order_number": "POL-1004", "customer_id": "cus-4", "customer_email": "katherine@example.com",
|
|
"items": [{"product_id": "prd-010", "qty": 2, "unit_price": 74.00, "title": "Pulsar Bluetooth Speaker"}],
|
|
"retail_total": 148.00, "supplier_id": "sup-002", "supplier_cost": 43.00, "shipping_cost": 7.99, "fees": 2.9,
|
|
"profit": 94.11, "status": "fraud_check", "tracking": None, "carrier": None,
|
|
"created_at": now_iso(hours_ago=1), "updated_at": now_iso(hours_ago=1),
|
|
},
|
|
]
|
|
|
|
|
|
def summary_payload():
|
|
return {
|
|
"revenue": 4210.44,
|
|
"orders": 17,
|
|
"gross_profit": 987.12,
|
|
"net_profit": 812.90,
|
|
"margin": 19.3,
|
|
"aov": 247.67,
|
|
"conversion": 3.1,
|
|
"refunds": 2,
|
|
"refunded_orders": 2,
|
|
}
|
|
|
|
|
|
def top_products_payload():
|
|
rows = [
|
|
("prd-009", "Eclipse Headphones", "POL-0009", 6, 774.00, 582.00),
|
|
("prd-003", "Polaris Pro Keyboard", "POL-0003", 5, 395.00, 270.50),
|
|
("prd-001", "Aurora Desk Lamp", "POL-0001", 8, 308.00, 208.00),
|
|
("prd-006", "Galaxy Standing Desk", "POL-0006", 1, 389.00, 244.00),
|
|
("prd-010", "Pulsar Bluetooth Speaker", "POL-0010", 4, 296.00, 210.00),
|
|
]
|
|
return [
|
|
{"product_id": pid, "title": t, "sku": sku, "units_sold": u, "revenue": r, "profit": p}
|
|
for pid, t, sku, u, r, p in rows
|
|
]
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "PolarisMock/0.1"
|
|
|
|
def log_message(self, fmt, *args): # quieter logs
|
|
pass
|
|
|
|
# ── helpers ──
|
|
def send_json(self, obj, status=200):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def read_body(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if not length:
|
|
return {}
|
|
try:
|
|
return json.loads(self.rfile.read(length))
|
|
except Exception:
|
|
return {}
|
|
|
|
def not_found(self):
|
|
self.send_json({"detail": "Not found"}, 404)
|
|
|
|
# ── routing ──
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, Cache-Control")
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
u = urlparse(self.path)
|
|
path = u.path.rstrip("/") or "/"
|
|
q = parse_qs(u.query)
|
|
|
|
if path == "/api/health":
|
|
return self.send_json({"status": "ok", "service": "polaris-api-mock", "version": "0.1.0"})
|
|
|
|
if path == "/api/products":
|
|
status = (q.get("status") or [None])[0]
|
|
cat = (q.get("category") or [None])[0]
|
|
search = (q.get("search") or [None])[0]
|
|
try:
|
|
limit = int((q.get("limit") or ["100"])[0])
|
|
except ValueError:
|
|
limit = 100
|
|
rows = [p for p in PRODUCTS
|
|
if not status or p["status"] == status]
|
|
if cat:
|
|
rows = [p for p in rows if p["category"] == cat]
|
|
if search:
|
|
s = search.lower()
|
|
rows = [p for p in rows if s in p["title"].lower() or s in p["sku"].lower()]
|
|
return self.send_json(rows[:limit])
|
|
|
|
if path.startswith("/api/products/") and path.count("/") == 3 and path.endswith("/price-history"):
|
|
pid = path.split("/")[3]
|
|
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
|
|
if not prod:
|
|
return self.not_found()
|
|
retail = prod["retail_price"] or 50
|
|
history = []
|
|
for i in range(8):
|
|
history.append({
|
|
"price": round(retail * (0.86 + 0.14 * (i / 7)) + random.uniform(-1.5, 1.5), 2),
|
|
"reason": "market reprice" if i % 2 else "initial pricing",
|
|
"created_at": now_iso(days_ago=7 - i),
|
|
})
|
|
return self.send_json(history)
|
|
|
|
if path.startswith("/api/products/"):
|
|
pid = path.split("/")[3]
|
|
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
|
|
return self.send_json(prod) if prod else self.not_found()
|
|
|
|
if path == "/api/suppliers":
|
|
return self.send_json(SUPPLIERS)
|
|
|
|
if path == "/api/orders":
|
|
return self.send_json(ORDERS)
|
|
|
|
if path.startswith("/api/orders/"):
|
|
oid = path.split("/")[3]
|
|
order = next((o for o in ORDERS if o["id"] == oid or o["order_number"] == oid), None)
|
|
return self.send_json(order) if order else self.not_found()
|
|
|
|
if path == "/api/analytics/summary":
|
|
return self.send_json(summary_payload())
|
|
|
|
if path == "/api/analytics/top-products":
|
|
return self.send_json(top_products_payload())
|
|
|
|
if path == "/api/analytics/supplier-performance":
|
|
return self.send_json(SUPPLIER_PERF)
|
|
|
|
return self.not_found()
|
|
|
|
def do_POST(self):
|
|
u = urlparse(self.path)
|
|
path = u.path.rstrip("/") or "/"
|
|
body = self.read_body()
|
|
|
|
if path == "/api/auth/login":
|
|
email = (body.get("email") or "").strip().lower()
|
|
password = body.get("password") or ""
|
|
if email == ADMIN_EMAIL and password == ADMIN_PASSWORD:
|
|
return self.send_json({"access_token": TOKEN, "token_type": "bearer", "email": email})
|
|
return self.send_json({"detail": "Invalid email or password"}, 401)
|
|
|
|
if path == "/api/auth/register":
|
|
return self.send_json({"access_token": TOKEN, "token_type": "bearer", "email": body.get("email")})
|
|
|
|
if path == "/api/orders":
|
|
email = body.get("customer_email") or "guest@example.com"
|
|
items = []
|
|
total = 0.0
|
|
for it in body.get("items") or []:
|
|
prod = next((p for p in PRODUCTS if p["id"] == it.get("product_id")), None)
|
|
qty = max(1, int(it.get("qty") or 1))
|
|
price = (prod["retail_price"] if prod else 29.99)
|
|
total += price * qty
|
|
items.append({"product_id": it.get("product_id"), "qty": qty, "unit_price": price, "title": prod["title"] if prod else None})
|
|
order = {
|
|
"id": f"ord-{random.randint(2000, 9999)}",
|
|
"order_number": f"POL-{random.randint(100000, 999999)}",
|
|
"customer_id": None,
|
|
"customer_email": email,
|
|
"items": items,
|
|
"retail_total": round(total, 2),
|
|
"supplier_id": "sup-001",
|
|
"supplier_cost": None,
|
|
"shipping_cost": 0.0,
|
|
"fees": 0.0,
|
|
"profit": None,
|
|
"status": "new",
|
|
"tracking": None,
|
|
"carrier": None,
|
|
"created_at": now_iso(),
|
|
"updated_at": now_iso(),
|
|
}
|
|
ORDERS.insert(0, order)
|
|
return self.send_json(order, 201)
|
|
|
|
if path.startswith("/api/orders/") and path.endswith("/tracking"):
|
|
oid = path.split("/")[3]
|
|
order = next((o for o in ORDERS if o["id"] == oid or o["order_number"] == oid), None)
|
|
if not order:
|
|
return self.not_found()
|
|
idx = STATUS_FLOW.index(order["status"]) if order["status"] in STATUS_FLOW else 0
|
|
order["status"] = STATUS_FLOW[min(idx + 1, len(STATUS_FLOW) - 1)]
|
|
if order["status"] in ("shipped", "delivered") and not order["tracking"]:
|
|
order["tracking"] = f"1Z{random.randint(10**15, 10**16 - 1)}"
|
|
order["carrier"] = "UPS"
|
|
order["updated_at"] = now_iso()
|
|
return self.send_json(order)
|
|
|
|
if path.startswith("/api/products/") and path.count("/") == 3 and (
|
|
path.endswith("/publish") or path.endswith("/pause")
|
|
):
|
|
pid = path.split("/")[3]
|
|
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
|
|
if not prod:
|
|
return self.not_found()
|
|
prod["status"] = "published" if path.endswith("/publish") else "paused"
|
|
prod["updated_at"] = now_iso()
|
|
return self.send_json(prod)
|
|
|
|
if path == "/api/products/import":
|
|
imported = random.randint(3, 9)
|
|
for i in range(imported):
|
|
title = f"Imported Feed Item {len(PRODUCTS) + 1}"
|
|
PRODUCTS.append({
|
|
"id": f"prd-{100 + len(PRODUCTS)}",
|
|
"sku": f"IMP-{len(PRODUCTS):04d}",
|
|
"title": title,
|
|
"description": "Imported via feed — awaiting pipeline analysis.",
|
|
"category": random.choice(CATS),
|
|
"brand": body.get("supplier_id") or "FeedImport",
|
|
"manufacturer": None,
|
|
"upc_ean_gtin": None,
|
|
"images": [],
|
|
"dimensions": None,
|
|
"weight_grams": None,
|
|
"cost": 0.0,
|
|
"retail_price": None,
|
|
"min_price": None,
|
|
"max_price": None,
|
|
"status": "discovered",
|
|
"lifecycle": {},
|
|
"created_at": now_iso(),
|
|
"updated_at": now_iso(),
|
|
})
|
|
return self.send_json({"message": f"Feed accepted — {imported} products staged for the pipeline.", "imported": imported}, 201)
|
|
|
|
return self.not_found()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(__import__("sys").argv[1]) if len(__import__("sys").argv) > 1 else 8000
|
|
print(f"Polaris mock API on :{port} (admin: {ADMIN_EMAIL} / {ADMIN_PASSWORD})")
|
|
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
|