Files
boc/quixzoom-shop/main.py
T

746 lines
29 KiB
Python
Raw Normal View History

"""
quiXzoom Shop Service
E-handel för quiXzoom Field Gear Collection
quiXzoom Field Program:
- 09 uppdrag: Contributor (digital profil)
- 10+ uppdrag: Field Contributor (låser upp Field Gear Store)
- 100+ uppdrag med 95% godkännandegrad: Verified Field Contributor (låser upp Field ID Kit)
"""
import os
from datetime import datetime
from typing import List, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Depends, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
import asyncpg
import redis.asyncio as redis
import stripe
import qrcode
import io
import base64
# --- Config ---
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/quixzoom_shop")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
JWT_SECRET = os.getenv("JWT_SECRET", "quixzoom_secret")
SHOP_MISSION_THRESHOLD = int(os.getenv("SHOP_MISSION_THRESHOLD", "10"))
VERIFIED_MISSION_THRESHOLD = int(os.getenv("VERIFIED_MISSION_THRESHOLD", "100"))
VERIFIED_APPROVAL_RATE = float(os.getenv("VERIFIED_APPROVAL_RATE", "0.95"))
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
if STRIPE_SECRET_KEY:
stripe.api_key = STRIPE_SECRET_KEY
# --- Database ---
pool: asyncpg.Pool = None
redis_client: redis.Redis = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool, redis_client
pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
await init_db()
yield
await pool.close()
await redis_client.close()
async def init_db():
"""Create shop tables if not exist"""
async with pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS shop_products (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
price_sek INTEGER NOT NULL,
image_url VARCHAR(500),
category VARCHAR(100),
sizes JSONB DEFAULT '[]',
colors JSONB DEFAULT '[]',
stock_quantity INTEGER DEFAULT 0,
min_missions INTEGER DEFAULT 10,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS shop_orders (
id SERIAL PRIMARY KEY,
user_id VARCHAR(100) NOT NULL,
user_email VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
total_amount INTEGER NOT NULL,
shipping_address JSONB NOT NULL,
items JSONB NOT NULL,
stripe_payment_intent_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_shop_orders_user ON shop_orders(user_id)
""")
# Create zoomer_profiles table if not exists (for verification)
await conn.execute("""
CREATE TABLE IF NOT EXISTS zoomer_profiles (
user_id VARCHAR(100) PRIMARY KEY,
display_name VARCHAR(255),
contributor_id VARCHAR(50) UNIQUE,
completed_missions INTEGER DEFAULT 0,
identity_verified BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
""")
# Seed products if empty
count = await conn.fetchval("SELECT COUNT(*) FROM shop_products")
if count == 0:
await seed_products(conn)
async def seed_products(conn: asyncpg.Connection):
"""Seed initial Field Gear Collection"""
products = [
("VEST-001", "Field Vest", "Taktisk väst med flera fickor för utrustning", 129900, "vest.png", "kläder", '["S","M","L","XL"]', '["Olive","Black"]', 50, 10),
("JACKET-001", "Mission Shell", "Vattentät skaljacka för alla väder", 249900, "jacket.png", "kläder", '["S","M","L","XL"]', '["Olive","Black","Tan"]', 30, 10),
("PANTS-001", "Field Pants", "Robusta fältbyxor med förstärkta knän", 179900, "pants.png", "kläder", '["S","M","L","XL","XXL"]', '["Olive","Black","Tan"]', 40, 10),
("BASE-001", "Base Layer", "Sömlös underställströja", 89900, "base.png", "kläder", '["S","M","L","XL"]', '["Black","Grey"]', 60, 10),
("BEANIE-001", "Field Beanie", "Varm mössa med quiXzoom-logo", 49900, "beanie.png", "accessoarer", '["One Size"]', '["Olive","Black","Grey"]', 100, 10),
("GLOVES-001", "Field Gloves", "Taktiska handskar med touch-funktion", 79900, "gloves.png", "accessoarer", '["S","M","L","XL"]', '["Black","Tan"]', 80, 10),
("STRAP-001", "Capture Strap", "Justerbar kamerarem", 59900, "strap.png", "utrustning", '["One Size"]', '["Black","Tan"]', 75, 10),
("POUCH-001", "Utility Pouch", "Modulär väska för tillbehör", 69900, "pouch.png", "utrustning", '["One Size"]', '["Olive","Black"]', 60, 10),
("COIN-001", "Ref. Coin", "quiXzoom referensmynt", 19900, "coin.png", "accessoarer", '["One Size"]', '["Silver","Black"]', 200, 10),
("LIGHT-001", "Pocket Light", "Kompakt ficklampa", 39900, "light.png", "utrustning", '["One Size"]', '["Black"]', 90, 10),
("POWER-001", "Power Module", "Powerbank 10000mAh", 149900, "power.png", "utrustning", '["One Size"]', '["Black"]', 45, 10),
("CABLE-001", "Cable Kit", "Kabelset USB-C/Lightning", 59900, "cable.png", "utrustning", '["One Size"]', '["Black"]', 100, 10),
("RAIN-001", "Rain Cover", "Regnskydd för utrustning", 79900, "rain.png", "utrustning", '["One Size"]', '["Olive","Black"]', 55, 10),
("BAG-001", "Field Bag", "Stor fältväska", 199900, "bag.png", "utrustning", '["One Size"]', '["Olive","Black","Tan"]', 25, 10),
("BELT-001", "Tactical Belt", "Taktiskt bälte", 89900, "belt.png", "accessoarer", '["S","M","L","XL"]', '["Black","Tan"]', 50, 10),
("SOCKS-001", "Field Socks", "Trepack strumpor", 39900, "socks.png", "kläder", '["S","M","L"]', '["Black","Grey"]', 120, 10),
("PATCH-001", "quiXzoom Patch", "Velcro-märke", 29900, "patch.png", "accessoarer", '["One Size"]', '["Olive","Black"]', 150, 10),
("BOTTLE-001", "Field Bottle", "Isolerad vattenflaska", 69900, "bottle.png", "utrustning", '["One Size"]', '["Olive","Black","Tan"]', 70, 10),
# Verified Field Contributor — Field ID Kit (min_missions = 100)
("ID-KIT-001", "Field ID Kit", "Personligt verifierat ID-kort med hållare och lanyard", 0, "id-kit.png", "verified", '["One Size"]', '["Black"]', 999, 100),
]
await conn.executemany("""
INSERT INTO shop_products (sku, name, description, price_sek, image_url, category, sizes, colors, stock_quantity, min_missions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
""", products)
# --- Auth ---
from jose import jwt, JWTError
async def get_current_user(token: str = None):
"""Validate JWT and return user info"""
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
async def get_user_stats(user_id: str) -> dict:
"""Get user mission stats for tier calculation"""
cache_key = f"user_stats:{user_id}"
cached = await redis_client.get(cache_key)
if cached:
import json
return json.loads(cached)
async with pool.acquire() as conn:
# Get completed missions count
completed = await conn.fetchval("""
SELECT COUNT(*) FROM mission_submissions
WHERE user_id = $1 AND status = 'approved'
""", user_id) or 0
# Get approval rate for last 100 missions
last_100 = await conn.fetch("""
SELECT status FROM mission_submissions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 100
""", user_id)
approved_last_100 = sum(1 for r in last_100 if r['status'] == 'approved')
total_last_100 = len(last_100)
approval_rate = approved_last_100 / total_last_100 if total_last_100 > 0 else 1.0
# Check identity verification status
identity_verified = await conn.fetchval("""
SELECT identity_verified FROM zoomer_profiles
WHERE user_id = $1
""", user_id) or False
# Check for policy violations
violations = await conn.fetchval("""
SELECT COUNT(*) FROM policy_violations
WHERE user_id = $1 AND severity = 'serious'
""", user_id) or 0
stats = {
"completed_missions": completed,
"approval_rate": approval_rate,
"identity_verified": identity_verified,
"serious_violations": violations,
}
await redis_client.setex(cache_key, 300, json.dumps(stats))
return stats
async def check_mission_eligibility(user_id: str) -> bool:
"""Check if user has completed enough missions for Field Gear Store"""
stats = await get_user_stats(user_id)
return stats["completed_missions"] >= SHOP_MISSION_THRESHOLD
async def check_verified_eligibility(user_id: str) -> bool:
"""Check if user qualifies for Verified Field Contributor (Field ID Kit)"""
stats = await get_user_stats(user_id)
return (
stats["completed_missions"] >= VERIFIED_MISSION_THRESHOLD and
stats["approval_rate"] >= VERIFIED_APPROVAL_RATE and
stats["identity_verified"] and
stats["serious_violations"] == 0
)
# --- Pydantic Models ---
class ProductOut(BaseModel):
id: int
sku: str
name: str
description: Optional[str]
price_sek: int
image_url: Optional[str]
category: str
sizes: List[str]
colors: List[str]
stock_quantity: int
min_missions: int
is_active: bool
class CartItem(BaseModel):
product_id: int
quantity: int = Field(ge=1, le=10)
size: Optional[str] = None
color: Optional[str] = None
class ShippingAddress(BaseModel):
name: str
street: str
city: str
postal_code: str
country: str = "SE"
phone: Optional[str] = None
class OrderCreate(BaseModel):
items: List[CartItem]
shipping_address: ShippingAddress
class OrderOut(BaseModel):
id: int
status: str
total_amount: int
items: list
created_at: datetime
class PaymentIntentRequest(BaseModel):
order_id: int
class PaymentIntentResponse(BaseModel):
client_secret: str
publishable_key: str
class UserTierResponse(BaseModel):
tier: str # "contributor", "field_contributor", "verified_field_contributor"
completed_missions: int
approval_rate: float
identity_verified: bool
serious_violations: int
unlocked_features: List[str]
# --- FastAPI App ---
app = FastAPI(
title="quiXzoom Shop",
description="E-handel för quiXzoom Field Gear Collection",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://quixzoom.se",
"https://www.quixzoom.se",
"https://quixzoom.com",
"https://www.quixzoom.com",
"http://localhost:3000",
"http://localhost:8080",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# --- API Endpoints ---
@app.get("/health")
async def health():
return {"status": "ok", "service": "quixzoom-shop"}
@app.get("/api/eligibility")
async def check_eligibility(request: Request):
"""Check if user is eligible to shop (legacy, use /api/tier instead)"""
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
if not token:
return {"eligible": False, "missions": 0, "required": SHOP_MISSION_THRESHOLD}
try:
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
stats = await get_user_stats(user_id)
return {
"eligible": stats["completed_missions"] >= SHOP_MISSION_THRESHOLD,
"missions": stats["completed_missions"],
"required": SHOP_MISSION_THRESHOLD
}
except Exception:
return {"eligible": False, "missions": 0, "required": SHOP_MISSION_THRESHOLD}
@app.get("/api/tier", response_model=UserTierResponse)
async def get_user_tier(request: Request):
"""Get user's Field Program tier and unlocked features"""
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
stats = await get_user_stats(user_id)
# Determine tier
if await check_verified_eligibility(user_id):
tier = "verified_field_contributor"
unlocked = ["field_gear_store", "field_id_kit", "verified_badge", "public_profile"]
elif await check_mission_eligibility(user_id):
tier = "field_contributor"
unlocked = ["field_gear_store"]
else:
tier = "contributor"
unlocked = ["digital_profile"]
return {
"tier": tier,
"completed_missions": stats["completed_missions"],
"approval_rate": stats["approval_rate"],
"identity_verified": stats["identity_verified"],
"serious_violations": stats["serious_violations"],
"unlocked_features": unlocked
}
@app.get("/api/products", response_model=List[ProductOut])
async def list_products(category: Optional[str] = None, request: Request = None):
"""List all active products. Verified-only products filtered based on user tier."""
# Check user tier for verified products
show_verified = False
if request:
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
if token:
try:
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
show_verified = await check_verified_eligibility(user_id)
except Exception:
pass
async with pool.acquire() as conn:
if category:
rows = await conn.fetch(
"SELECT * FROM shop_products WHERE is_active = true AND category = $1 ORDER BY category, name",
category
)
else:
rows = await conn.fetch(
"SELECT * FROM shop_products WHERE is_active = true ORDER BY category, name"
)
products = [parse_product(r) for r in rows]
# Filter verified-only products if user is not verified
if not show_verified:
products = [p for p in products if p["category"] != "verified"]
return products
def parse_product(row):
"""Parse product row with JSON fields"""
import json
d = dict(row)
if isinstance(d.get('sizes'), str):
d['sizes'] = json.loads(d['sizes'])
if isinstance(d.get('colors'), str):
d['colors'] = json.loads(d['colors'])
return d
@app.get("/api/products/{product_id}", response_model=ProductOut)
async def get_product(product_id: int):
"""Get single product"""
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM shop_products WHERE id = $1 AND is_active = true", product_id)
if not row:
raise HTTPException(status_code=404, detail="Product not found")
return parse_product(row)
@app.post("/api/orders", response_model=OrderOut)
async def create_order(order: OrderCreate, request: Request):
"""Create new order (requires auth + mission eligibility)"""
# Get auth token from header
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
user_email = user.get("email", "")
# Check mission eligibility
eligible = await check_mission_eligibility(user_id)
if not eligible:
raise HTTPException(
status_code=403,
detail=f"Du måste ha slutfört minst {SHOP_MISSION_THRESHOLD} uppdrag för att handla i shoppen."
)
# Validate items and calculate total
total = 0
order_items = []
async with pool.acquire() as conn:
async with conn.transaction():
for item in order.items:
product = await conn.fetchrow(
"SELECT * FROM shop_products WHERE id = $1 AND is_active = true",
item.product_id
)
if not product:
raise HTTPException(status_code=400, detail=f"Product {item.product_id} not found")
if product["stock_quantity"] < item.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough stock for {product['name']}. Available: {product['stock_quantity']}"
)
# Check size/color availability
sizes = product["sizes"] or []
colors = product["colors"] or []
if item.size and sizes and item.size not in sizes:
raise HTTPException(status_code=400, detail=f"Size {item.size} not available")
if item.color and colors and item.color not in colors:
raise HTTPException(status_code=400, detail=f"Color {item.color} not available")
item_total = product["price_sek"] * item.quantity
total += item_total
order_items.append({
"product_id": item.product_id,
"sku": product["sku"],
"name": product["name"],
"quantity": item.quantity,
"size": item.size,
"color": item.color,
"unit_price": product["price_sek"],
"total": item_total
})
# Update stock
await conn.execute(
"UPDATE shop_products SET stock_quantity = stock_quantity - $1 WHERE id = $2",
item.quantity, item.product_id
)
# Create order
order_id = await conn.fetchval("""
INSERT INTO shop_orders (user_id, user_email, total_amount, shipping_address, items, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING id
""", user_id, user_email, total,
dict(order.shipping_address),
order_items)
# Clear cache
await redis_client.delete(f"missions:{user_id}")
return {"id": order_id, "status": "pending", "total_amount": total, "items": order_items, "created_at": datetime.now()}
@app.get("/api/orders", response_model=List[OrderOut])
async def list_orders(request: Request):
"""List user's orders"""
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, status, total_amount, items, created_at FROM shop_orders WHERE user_id = $1 ORDER BY created_at DESC",
user_id
)
return [dict(r) for r in rows]
@app.get("/api/orders/{order_id}", response_model=OrderOut)
async def get_order(order_id: int, request: Request):
"""Get single order"""
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, status, total_amount, items, created_at FROM shop_orders WHERE id = $1 AND user_id = $2",
order_id, user_id
)
if not row:
raise HTTPException(status_code=404, detail="Order not found")
return dict(row)
# --- Web Pages ---
@app.get("/", response_class=HTMLResponse)
async def shop_home(request: Request):
"""Shop homepage"""
async with pool.acquire() as conn:
products = await conn.fetch("SELECT * FROM shop_products WHERE is_active = true ORDER BY category, name")
return templates.TemplateResponse("shop.html", {
"request": request,
"products": [dict(r) for r in products],
"categories": list(set(p["category"] for p in products))
})
@app.get("/product/{product_id}", response_class=HTMLResponse)
async def product_page(product_id: int, request: Request):
"""Product detail page"""
async with pool.acquire() as conn:
product = await conn.fetchrow("SELECT * FROM shop_products WHERE id = $1 AND is_active = true", product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return templates.TemplateResponse("product.html", {
"request": request,
"product": dict(product)
})
@app.get("/checkout", response_class=HTMLResponse)
async def checkout_page(request: Request):
"""Checkout page with Stripe payment"""
return templates.TemplateResponse("checkout.html", {"request": request})
@app.get("/verify/{contributor_id}", response_class=HTMLResponse)
async def verify_page(contributor_id: str, request: Request):
"""Public verification page for Field Contributor ID cards"""
async with pool.acquire() as conn:
# Look up user by contributor ID
user = await conn.fetchrow("""
SELECT user_id, display_name, created_at, identity_verified
FROM zoomer_profiles
WHERE contributor_id = $1
""", contributor_id)
if not user:
return templates.TemplateResponse("verify.html", {
"request": request,
"contributor_id": contributor_id,
"contributor": None
})
# Get stats
stats = await get_user_stats(user["user_id"])
# Check if still valid
is_valid = (
stats["completed_missions"] >= VERIFIED_MISSION_THRESHOLD and
stats["approval_rate"] >= VERIFIED_APPROVAL_RATE and
user["identity_verified"] and
stats["serious_violations"] == 0
)
# Get initials from name
name = user["display_name"] or "Contributor"
initials = "".join(word[0].upper() for word in name.split()[:2])
contributor = {
"valid": is_valid,
"id": contributor_id,
"name": name,
"initials": initials,
"completed_missions": stats["completed_missions"],
"approval_rate": stats["approval_rate"],
"member_since": user["created_at"].strftime("%Y") if user["created_at"] else "N/A"
}
return templates.TemplateResponse("verify.html", {
"request": request,
"contributor_id": contributor_id,
"contributor": contributor
})
# --- Stripe Payment Endpoints ---
@app.post("/api/payment/create-intent", response_model=PaymentIntentResponse)
async def create_payment_intent(req: PaymentIntentRequest, request: Request):
"""Create Stripe PaymentIntent for an order"""
if not STRIPE_SECRET_KEY:
raise HTTPException(status_code=500, detail="Stripe not configured")
auth_header = request.headers.get("authorization", "")
token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else None
user = await get_current_user(token)
user_id = user.get("sub") or user.get("user_id")
# Get order
async with pool.acquire() as conn:
order = await conn.fetchrow(
"SELECT * FROM shop_orders WHERE id = $1 AND user_id = $2 AND status = 'pending'",
req.order_id, user_id
)
if not order:
raise HTTPException(status_code=404, detail="Order not found or already paid")
# Create PaymentIntent
try:
intent = stripe.PaymentIntent.create(
amount=order["total_amount"],
currency="sek",
metadata={
"order_id": str(req.order_id),
"user_id": user_id,
},
automatic_payment_methods={"enabled": True},
)
# Save intent ID to order
async with pool.acquire() as conn:
await conn.execute(
"UPDATE shop_orders SET stripe_payment_intent_id = $1 WHERE id = $2",
intent.id, req.order_id
)
return {
"client_secret": intent.client_secret,
"publishable_key": os.getenv("STRIPE_PUBLISHABLE_KEY", "pk_test_...")
}
except stripe.error.StripeError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/payment/webhook")
async def stripe_webhook(request: Request):
"""Handle Stripe webhooks"""
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=500, detail="Webhook secret not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
# Handle payment success
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
order_id = int(intent["metadata"]["order_id"])
async with pool.acquire() as conn:
await conn.execute(
"UPDATE shop_orders SET status = 'paid', updated_at = NOW() WHERE id = $1",
order_id
)
elif event["type"] == "payment_intent.payment_failed":
intent = event["data"]["object"]
order_id = int(intent["metadata"]["order_id"])
async with pool.acquire() as conn:
await conn.execute(
"UPDATE shop_orders SET status = 'payment_failed', updated_at = NOW() WHERE id = $1",
order_id
)
return {"status": "ok"}
# --- Error Handlers ---
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)}
)
# --- QR Code Endpoint ---
@app.get("/api/verify/qrcode/{contributor_id}")
async def get_qrcode(contributor_id: str):
"""Generate QR code for contributor verification"""
verify_url = f"https://shop.quixzoom.com/verify/{contributor_id}"
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=4,
)
qr.add_data(verify_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode()
return {
"qr_code": f"data:image/png;base64,{img_str}",
"verify_url": verify_url
}
# --- Error Handlers ---
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)}
)
if __name__ == "__main__":
import uvicorn
port = int(os.getenv('PORT', 8080))
host = os.getenv('HOST', '0.0.0.0')
uvicorn.run(app, host=host, port=port)