Add Polaris FastAPI backend: models/schemas, pricing+scoring+order router, importer, Celery tasks, API routers, seed script

This commit is contained in:
drjones
2026-08-25 20:37:58 -07:00
parent 8573554bf9
commit 96b4fcaac9
32 changed files with 2211 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""API routers."""

View File

@@ -0,0 +1,160 @@
"""Admin router — JWT-protected operational endpoints.
GET /api/admin/dashboard
GET /api/admin/orders
POST /api/admin/orders/{id}/status
POST /api/admin/import
GET /api/admin/price-rules
POST /api/admin/recalc (recalc_prices task)
POST /api/admin/sync-inventory (sync_inventory task)
"""
from datetime import datetime, time, timezone
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import AuditLog, Order, PriceRule, Product, Supplier
from app.routers.auth import require_admin
from app.schemas import ImportRequest, OrderOut
router = APIRouter(dependencies=[Depends(require_admin)])
ORDER_STATUSES = {"new", "paid", "fraud_check", "supplier_order", "confirmed",
"shipped", "delivered", "cancelled", "refunded"}
class StatusUpdate(BaseModel):
status: str
def _f(value) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
@router.get("/dashboard")
def dashboard(db: Session = Depends(get_db)):
today_start = datetime.combine(datetime.now(timezone.utc).date(), time.min)
excluded = ["cancelled", "refunded"]
def kpis(query):
orders = query.all()
revenue = sum(_f(o.retail_total) for o in orders)
profit = sum(_f(o.profit) for o in orders)
count = len(orders)
return {
"orders": count,
"revenue": round(revenue, 2),
"profit": round(profit, 2),
"gross_profit": round(revenue - profit, 2),
"margin": round(profit / revenue * 100, 2) if revenue else 0.0,
"aov": round(revenue / count, 2) if count else 0.0,
"refunds": sum(1 for o in orders if o.status == "refunded"),
}
today_q = db.query(Order).filter(
Order.created_at >= today_start, ~Order.status.in_(excluded)
)
all_q = db.query(Order).filter(~Order.status.in_(excluded))
return {
"today": kpis(today_q),
"all_time": kpis(all_q),
"products_total": db.query(Product).count(),
}
@router.get("/orders", response_model=List[OrderOut])
def admin_orders(status: Optional[str] = None, db: Session = Depends(get_db)):
q = db.query(Order)
if status:
q = q.filter(Order.status == status)
return q.order_by(Order.created_at.desc()).all()
@router.post("/orders/{order_id}/status", response_model=OrderOut)
def set_order_status(order_id, body: StatusUpdate, db: Session = Depends(get_db)):
if body.status not in ORDER_STATUSES:
raise HTTPException(status_code=400, detail=f"invalid status; allowed: {sorted(ORDER_STATUSES)}")
order = db.query(Order).filter(Order.id == order_id).first()
if order is None:
raise HTTPException(status_code=404, detail="order not found")
old = order.status
order.status = body.status
db.add(
AuditLog(
actor="admin",
action="order_status_changed",
entity="order",
entity_id=order.order_number,
detail={"from": old, "to": body.status},
)
)
db.commit()
db.refresh(order)
return order
@router.post("/import")
def admin_import(body: ImportRequest, db: Session = Depends(get_db)):
from app.engines.importer import import_feed
supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first()
if supplier is None:
raise HTTPException(status_code=404, detail="supplier not found")
summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url)
db.add(
AuditLog(
actor="admin",
action="import_requested",
entity="supplier",
entity_id=str(supplier.id),
detail={"feed_type": body.feed_type},
)
)
db.commit()
return summary
@router.get("/price-rules")
def price_rules(db: Session = Depends(get_db)):
return [
{
"id": r.id,
"min_cost": float(r.min_cost),
"max_cost": float(r.max_cost) if r.max_cost is not None else None,
"markup_pct": float(r.markup_pct),
"min_margin_pct": float(r.min_margin_pct or 0),
"active": r.active,
}
for r in db.query(PriceRule).order_by(PriceRule.min_cost).all()
]
def _dispatch(task_name, *args):
"""Prefer Celery; fall back to running inline when the broker is unreachable."""
from app.tasks import recalc_prices, sync_inventory
fn = {"recalc_prices": recalc_prices, "sync_inventory": sync_inventory}[task_name]
try:
result = fn.delay(*args)
return {"dispatched": True, "task_id": result.id}
except Exception:
# broker down (e.g. running on host without redis DNS) — run inline
return {"dispatched": False, "inline_result": fn.run(*args)}
@router.post("/recalc")
def admin_recalc():
return _dispatch("recalc_prices")
@router.post("/sync-inventory")
def admin_sync_inventory(supplier_id: Optional[str] = None):
return _dispatch("sync_inventory", supplier_id)

View File

@@ -0,0 +1,123 @@
"""Analytics router.
GET /api/analytics/summary -> today revenue/orders/profit/margin/aov/conversion
GET /api/analytics/top-products
GET /api/analytics/supplier-performance
"""
from collections import defaultdict
from datetime import datetime, time, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Order, Supplier, SupplierPerformance
router = APIRouter()
EXCLUDED = ["cancelled", "refunded"]
def _f(value) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
@router.get("/summary")
def summary(db: Session = Depends(get_db)):
today_start = datetime.combine(datetime.now(timezone.utc).date(), time.min)
today_q = db.query(Order).filter(
Order.created_at >= today_start, ~Order.status.in_(EXCLUDED)
)
all_q = db.query(Order).filter(~Order.status.in_(EXCLUDED))
def _kpis(query):
orders = query.all()
revenue = sum(_f(o.retail_total) for o in orders)
profit = sum(_f(o.profit) for o in orders)
count = len(orders)
return {
"revenue": round(revenue, 2),
"orders": count,
"profit": round(profit, 2),
"gross_profit": round(revenue - profit, 2),
"margin": round(profit / revenue * 100, 2) if revenue else 0.0,
"aov": round(revenue / count, 2) if count else 0.0,
# no traffic/visitor tracking table yet — conversion is not measurable
"conversion": 0.0,
}
refunds_today = (
db.query(func.count(Order.id))
.filter(Order.created_at >= today_start, Order.status.in_(["cancelled", "refunded"]))
.scalar()
or 0
)
return {
"today": {**_kpis(today_q), "refunds": refunds_today},
"all_time": _kpis(all_q),
}
@router.get("/top-products")
def top_products(limit: int = 10, db: Session = Depends(get_db)):
orders = (
db.query(Order)
.filter(~Order.status.in_(EXCLUDED))
.order_by(Order.created_at.desc())
.limit(500)
.all()
)
stats = defaultdict(lambda: {"sku": None, "units": 0, "revenue": 0.0})
for order in orders:
for item in order.items or []:
pid = item.get("product_id")
if not pid:
continue
entry = stats[pid]
entry["sku"] = item.get("sku")
entry["units"] += int(item.get("qty") or 0)
entry["revenue"] += float(item.get("unit_price") or 0) * int(item.get("qty") or 0)
ranked = sorted(stats.items(), key=lambda kv: (-kv[1]["revenue"], -kv[1]["units"]))
return [
{"product_id": pid, "sku": s["sku"], "units": s["units"], "revenue": round(s["revenue"], 2)}
for pid, s in ranked[:limit]
]
@router.get("/supplier-performance")
def supplier_performance(db: Session = Depends(get_db)):
out = []
for supplier in db.query(Supplier).all():
perf = (
db.query(SupplierPerformance)
.filter(SupplierPerformance.supplier_id == supplier.id)
.first()
)
orders = (
db.query(Order)
.filter(Order.supplier_id == supplier.id, ~Order.status.in_(EXCLUDED))
.all()
)
out.append(
{
"supplier_id": str(supplier.id),
"name": supplier.name,
"adapter_type": supplier.adapter_type,
"status": supplier.status,
"reliability_score": _f(supplier.reliability_score),
"fulfillment_rate": _f(perf.fulfillment_rate) if perf else 100.0,
"avg_shipping_days": _f(perf.avg_shipping_days) if perf and perf.avg_shipping_days is not None else None,
"cancellation_rate": _f(perf.cancellation_rate) if perf else 0.0,
"stock_accuracy": _f(perf.stock_accuracy) if perf else 100.0,
"return_rate": _f(perf.return_rate) if perf else 0.0,
"orders": len(orders),
"profit": round(sum(_f(o.profit) for o in orders), 2),
}
)
return out

View File

@@ -0,0 +1,84 @@
"""Auth router — simple JWT for admin login + customer registration.
POST /api/auth/login (admin, env ADMIN_EMAIL / ADMIN_PASSWORD)
POST /api/auth/register (customer)
TODO(security): hash ADMIN_PASSWORD (bcrypt/argon2) instead of plain compare —
acceptable for MVP per task instructions. Customers table has no password
column yet, so registration is identity-only.
"""
import os
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Customer
from app.schemas import CustomerRegister, LoginRequest, TokenResponse
router = APIRouter()
SECRET_KEY = os.getenv("SECRET_KEY", "polaris-dev-secret-change-me")
ALGORITHM = "HS256"
TOKEN_TTL_HOURS = 24
def _make_token(email: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": email,
"role": role,
"iat": now,
"exp": now + timedelta(hours=TOKEN_TTL_HOURS),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest):
admin_email = os.getenv("ADMIN_EMAIL", "admin@polaris.local")
# contract mentions ADMIN_PASSWORD_HASH; MVP uses plain compare (see TODO above)
admin_password = os.getenv("ADMIN_PASSWORD") or os.getenv("ADMIN_PASSWORD_HASH", "admin")
if body.email.strip().lower() != admin_email.strip().lower():
raise HTTPException(status_code=401, detail="invalid credentials")
if body.password != admin_password:
raise HTTPException(status_code=401, detail="invalid credentials")
return TokenResponse(access_token=_make_token(body.email, "admin"))
@router.post("/register")
def register(body: CustomerRegister, db: Session = Depends(get_db)):
email = body.email.strip().lower()
existing = db.query(Customer).filter(Customer.email == email).first()
if existing is not None:
raise HTTPException(status_code=409, detail="email already registered")
customer = Customer(email=email, name=body.name, shipping_addr=body.shipping_addr or {})
db.add(customer)
db.commit()
db.refresh(customer)
return {
"id": str(customer.id),
"email": customer.email,
"name": customer.name,
"access_token": _make_token(customer.email, "customer"),
"token_type": "bearer",
}
def require_admin(authorization: str = Header(default="")):
"""FastAPI dependency — requires a valid admin JWT in the Authorization header."""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
try:
payload = jwt.decode(authorization[7:], SECRET_KEY, algorithms=[ALGORITHM])
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="invalid token")
if payload.get("role") != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return payload

View File

@@ -0,0 +1,42 @@
"""Customers router.
GET /api/customers
GET /api/customers/{id}
POST /api/customers
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Customer
from app.schemas import CustomerOut, CustomerRegister
router = APIRouter()
@router.get("", response_model=List[CustomerOut])
def list_customers(db: Session = Depends(get_db)):
return db.query(Customer).order_by(Customer.created_at.desc()).all()
@router.get("/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id, db: Session = Depends(get_db)):
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer is None:
raise HTTPException(status_code=404, detail="customer not found")
return customer
@router.post("", response_model=CustomerOut)
def create_customer(body: CustomerRegister, db: Session = Depends(get_db)):
email = body.email.strip().lower()
existing = db.query(Customer).filter(Customer.email == email).first()
if existing is not None:
raise HTTPException(status_code=409, detail="email already registered")
customer = Customer(email=email, name=body.name, shipping_addr=body.shipping_addr or {})
db.add(customer)
db.commit()
db.refresh(customer)
return customer

View File

@@ -0,0 +1,23 @@
"""GET /api/health -> {status, version}"""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.database import get_db
from app.version import VERSION
router = APIRouter()
@router.get("/health")
def health(db: Session = Depends(get_db)):
db_status = "ok"
try:
db.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
db_status = "error"
return {
"status": "ok" if db_status == "ok" else "degraded",
"version": VERSION,
"db": db_status,
}

View File

@@ -0,0 +1,113 @@
"""Orders router.
POST /api/orders (body: {customer_email, items:[{product_id,qty}]})
-> creates order, routes to highest-scoring supplier, records profit
GET /api/orders
GET /api/orders/{id}
POST /api/orders/{id}/tracking (simulate supplier tracking push)
"""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.engines.order_router import route_order
from app.models import AuditLog, Customer, Order, Product
from app.schemas import OrderCreate, OrderOut, TrackingIn
router = APIRouter()
TRACKING_STATUSES = {"new", "paid", "fraud_check", "supplier_order", "confirmed"}
def _get_order_or_404(db, order_id):
order = db.query(Order).filter(Order.id == order_id).first()
if order is None:
raise HTTPException(status_code=404, detail="order not found")
return order
def _new_order_number() -> str:
return f"POL-{datetime.utcnow():%Y%m%d}-{uuid.uuid4().hex[:6].upper()}"
@router.post("", response_model=OrderOut)
def create_order(body: OrderCreate, db: Session = Depends(get_db)):
email = body.customer_email.strip().lower()
customer = db.query(Customer).filter(Customer.email == email).first()
if customer is None:
customer = Customer(email=email, name=email.split("@")[0])
db.add(customer)
db.flush()
items = []
retail_total = Decimal("0")
for item in body.items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail=f"product {item.product_id} not found")
price = product.retail_price
if price is None or Decimal(str(price)) <= 0:
raise HTTPException(status_code=400, detail=f"product {product.sku} has no retail price")
items.append(
{
"product_id": str(product.id),
"sku": product.sku,
"qty": item.qty,
"unit_price": float(price),
}
)
retail_total += Decimal(str(price)) * item.qty
order = Order(
order_number=_new_order_number(),
customer_id=customer.id,
items=items,
retail_total=retail_total,
status="new",
)
db.add(order)
db.flush()
result = route_order(db, order)
db.refresh(order)
return order
@router.get("", response_model=List[OrderOut])
def list_orders(status: Optional[str] = None, db: Session = Depends(get_db)):
q = db.query(Order)
if status:
q = q.filter(Order.status == status)
return q.order_by(Order.created_at.desc()).all()
@router.get("/{order_id}", response_model=OrderOut)
def get_order(order_id, db: Session = Depends(get_db)):
return _get_order_or_404(db, order_id)
@router.post("/{order_id}/tracking", response_model=OrderOut)
def add_tracking(order_id, body: TrackingIn, db: Session = Depends(get_db)):
order = _get_order_or_404(db, order_id)
order.tracking = body.tracking
if body.carrier:
order.carrier = body.carrier
if order.status in TRACKING_STATUSES:
order.status = "shipped"
db.add(
AuditLog(
actor="api",
action="tracking_received",
entity="order",
entity_id=order.order_number,
detail={"tracking": body.tracking, "carrier": body.carrier},
)
)
db.commit()
db.refresh(order)
return order

View File

@@ -0,0 +1,132 @@
"""Products router.
GET /api/products (filters: status, category, search, limit, offset)
GET /api/products/{id}
POST /api/products/{id}/publish
POST /api/products/{id}/pause
GET /api/products/{id}/price-history
POST /api/products/import (body: {supplier_id, feed_type, data_or_url})
NOTE: /import is declared before /{id} so it isn't captured by the path param.
"""
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.engines.importer import import_feed
from app.models import AuditLog, PriceHistory, Product, Supplier
from app.schemas import ImportRequest, PriceHistoryOut, ProductOut
router = APIRouter()
PUBLISHABLE_FROM = {"imported", "price_calculated", "content_generated", "quality_check", "paused"}
def _get_product_or_404(db, product_id):
product = db.query(Product).filter(Product.id == product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="product not found")
return product
@router.get("", response_model=List[ProductOut])
def list_products(
status: Optional[str] = None,
category: Optional[str] = None,
search: Optional[str] = None,
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
q = db.query(Product)
if status:
q = q.filter(Product.status == status)
if category:
q = q.filter(Product.category == category)
if search:
like = f"%{search}%"
q = q.filter(or_(Product.title.ilike(like), Product.sku.ilike(like)))
return q.order_by(Product.created_at.desc()).offset(offset).limit(limit).all()
@router.post("/import")
def import_products(body: ImportRequest, db: Session = Depends(get_db)):
supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first()
if supplier is None:
raise HTTPException(status_code=404, detail="supplier not found")
try:
summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
db.add(
AuditLog(
actor="api",
action="import_requested",
entity="supplier",
entity_id=str(supplier.id),
detail={"feed_type": body.feed_type, "received": summary["received"]},
)
)
db.commit()
return summary
@router.get("/{product_id}", response_model=ProductOut)
def get_product(product_id, db: Session = Depends(get_db)):
return _get_product_or_404(db, product_id)
@router.post("/{product_id}/publish", response_model=ProductOut)
def publish_product(product_id, db: Session = Depends(get_db)):
product = _get_product_or_404(db, product_id)
if product.status not in PUBLISHABLE_FROM:
raise HTTPException(status_code=400, detail=f"cannot publish product in status '{product.status}'")
if product.retail_price is None or Decimal(str(product.retail_price)) <= 0:
raise HTTPException(status_code=400, detail="product has no calculated retail price")
product.status = "published"
db.add(
AuditLog(
actor="api",
action="product_published",
entity="product",
entity_id=str(product.id),
detail={"sku": product.sku, "retail_price": float(product.retail_price)},
)
)
db.commit()
db.refresh(product)
return product
@router.post("/{product_id}/pause", response_model=ProductOut)
def pause_product(product_id, db: Session = Depends(get_db)):
product = _get_product_or_404(db, product_id)
product.status = "paused"
db.add(
AuditLog(
actor="api",
action="product_paused",
entity="product",
entity_id=str(product.id),
detail={"sku": product.sku},
)
)
db.commit()
db.refresh(product)
return product
@router.get("/{product_id}/price-history", response_model=List[PriceHistoryOut])
def price_history(product_id, db: Session = Depends(get_db)):
_get_product_or_404(db, product_id)
rows = (
db.query(PriceHistory)
.filter(PriceHistory.product_id == product_id)
.order_by(PriceHistory.created_at.desc())
.all()
)
return rows

View File

@@ -0,0 +1,76 @@
"""Suppliers router.
GET /api/suppliers
POST /api/suppliers
GET /api/suppliers/{id}/performance
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Supplier, SupplierPerformance
from app.schemas import SupplierCreate, SupplierOut, SupplierPerformanceOut
router = APIRouter()
@router.get("", response_model=List[SupplierOut])
def list_suppliers(status: Optional[str] = None, db: Session = Depends(get_db)):
q = db.query(Supplier)
if status:
q = q.filter(Supplier.status == status)
return q.order_by(Supplier.created_at.desc()).all()
@router.post("", response_model=SupplierOut)
def create_supplier(body: SupplierCreate, db: Session = Depends(get_db)):
supplier = Supplier(
name=body.name,
adapter_type=body.adapter_type,
api_endpoint=body.api_endpoint,
account_id=body.account_id,
fulfillment_caps=body.fulfillment_caps or {},
shipping_regions=body.shipping_regions or [],
reliability_score=body.reliability_score,
status=body.status,
)
db.add(supplier)
db.commit()
db.refresh(supplier)
return supplier
@router.get("/{supplier_id}/performance", response_model=SupplierPerformanceOut)
def supplier_performance(supplier_id, db: Session = Depends(get_db)):
supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first()
if supplier is None:
raise HTTPException(status_code=404, detail="supplier not found")
perf = (
db.query(SupplierPerformance)
.filter(SupplierPerformance.supplier_id == supplier_id)
.first()
)
if perf is None:
# synthesize defaults; nothing measured yet
return SupplierPerformanceOut(
supplier_id=supplier_id,
supplier_name=supplier.name,
fulfillment_rate=100.0,
avg_shipping_days=None,
cancellation_rate=0.0,
stock_accuracy=100.0,
return_rate=0.0,
)
return SupplierPerformanceOut(
supplier_id=supplier_id,
supplier_name=supplier.name,
fulfillment_rate=float(perf.fulfillment_rate or 0),
avg_shipping_days=float(perf.avg_shipping_days) if perf.avg_shipping_days is not None else None,
cancellation_rate=float(perf.cancellation_rate or 0),
stock_accuracy=float(perf.stock_accuracy or 0),
return_rate=float(perf.return_rate or 0),
updated_at=perf.updated_at,
)