746 lines
29 KiB
Python
746 lines
29 KiB
Python
|
|
"""
|
|||
|
|
quiXzoom Shop Service
|
|||
|
|
E-handel för quiXzoom Field Gear Collection
|
|||
|
|
|
|||
|
|
quiXzoom Field Program:
|
|||
|
|
- 0–9 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)
|