Spaces:
Runtime error
Runtime error
Sync from GitHub f4738e2
Browse files- .env.example +6 -0
- auth.py +313 -0
- docker-compose.yml +113 -0
- main.py +137 -0
- models.py +3 -0
- requirements.txt +4 -0
- tests/test_api.py +41 -22
.env.example
CHANGED
|
@@ -7,6 +7,12 @@ DATABASE_URL=sqlite:///./marketplace.db
|
|
| 7 |
# PostgreSQL example: postgresql://user:password@localhost/marketplace
|
| 8 |
# MySQL example: mysql+pymysql://user:password@localhost/marketplace
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# Model from Hugging Face (Transformers)
|
| 11 |
MODEL_REPO_ID=unsloth/Qwen3-4B-Instruct-2507
|
| 12 |
# HF token for gated/private models (optional)
|
|
|
|
| 7 |
# PostgreSQL example: postgresql://user:password@localhost/marketplace
|
| 8 |
# MySQL example: mysql+pymysql://user:password@localhost/marketplace
|
| 9 |
|
| 10 |
+
# Authentication (JWT)
|
| 11 |
+
# IMPORTANT: Change JWT_SECRET_KEY in production! Use a strong random secret.
|
| 12 |
+
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
|
| 13 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 14 |
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 15 |
+
|
| 16 |
# Model from Hugging Face (Transformers)
|
| 17 |
MODEL_REPO_ID=unsloth/Qwen3-4B-Instruct-2507
|
| 18 |
# HF token for gated/private models (optional)
|
auth.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
Authentication and authorization for AI Marketplace Platform
|
| 5 |
+
|
| 6 |
+
Features:
|
| 7 |
+
- JWT token generation and validation
|
| 8 |
+
- Password hashing with bcrypt
|
| 9 |
+
- Role-based access control (user, supplier, admin)
|
| 10 |
+
- Token refresh mechanism
|
| 11 |
+
- FastAPI dependencies for protected endpoints
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
from auth import get_current_user, create_access_token
|
| 15 |
+
|
| 16 |
+
@app.post("/protected")
|
| 17 |
+
def protected_route(current_user: dict = Depends(get_current_user)):
|
| 18 |
+
return {"user": current_user}
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
from datetime import datetime, timedelta
|
| 23 |
+
from typing import Optional, Dict, Any
|
| 24 |
+
from passlib.context import CryptContext
|
| 25 |
+
from jose import JWTError, jwt
|
| 26 |
+
from fastapi import Depends, HTTPException, status
|
| 27 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 28 |
+
from pydantic import BaseModel, EmailStr
|
| 29 |
+
|
| 30 |
+
# JWT Configuration
|
| 31 |
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
|
| 32 |
+
ALGORITHM = "HS256"
|
| 33 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
| 34 |
+
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
| 35 |
+
|
| 36 |
+
# Password hashing
|
| 37 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 38 |
+
|
| 39 |
+
# Security scheme
|
| 40 |
+
security = HTTPBearer()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Pydantic models
|
| 44 |
+
class Token(BaseModel):
|
| 45 |
+
access_token: str
|
| 46 |
+
refresh_token: str
|
| 47 |
+
token_type: str = "bearer"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TokenData(BaseModel):
|
| 51 |
+
email: Optional[str] = None
|
| 52 |
+
role: Optional[str] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class UserLogin(BaseModel):
|
| 56 |
+
email: EmailStr
|
| 57 |
+
password: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class UserRegisterAuth(BaseModel):
|
| 61 |
+
email: EmailStr
|
| 62 |
+
password: str
|
| 63 |
+
name: str
|
| 64 |
+
role: str = "user" # user, supplier, admin
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# Password utilities
|
| 68 |
+
def hash_password(password: str) -> str:
|
| 69 |
+
"""Hash a password for storing."""
|
| 70 |
+
return pwd_context.hash(password)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 74 |
+
"""Verify a stored password against one provided by user."""
|
| 75 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Token utilities
|
| 79 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 80 |
+
"""
|
| 81 |
+
Create JWT access token.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
data: Dictionary with user data (email, role, etc.)
|
| 85 |
+
expires_delta: Optional token expiration time
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Encoded JWT token string
|
| 89 |
+
"""
|
| 90 |
+
to_encode = data.copy()
|
| 91 |
+
if expires_delta:
|
| 92 |
+
expire = datetime.utcnow() + expires_delta
|
| 93 |
+
else:
|
| 94 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 95 |
+
|
| 96 |
+
to_encode.update({"exp": expire})
|
| 97 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 98 |
+
return encoded_jwt
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def create_refresh_token(data: dict) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Create JWT refresh token with longer expiration.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
data: Dictionary with user data
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
Encoded JWT refresh token string
|
| 110 |
+
"""
|
| 111 |
+
to_encode = data.copy()
|
| 112 |
+
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 113 |
+
to_encode.update({"exp": expire, "type": "refresh"})
|
| 114 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 115 |
+
return encoded_jwt
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def verify_token(token: str) -> Dict[str, Any]:
|
| 119 |
+
"""
|
| 120 |
+
Verify and decode JWT token.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
token: JWT token string
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Decoded token payload
|
| 127 |
+
|
| 128 |
+
Raises:
|
| 129 |
+
HTTPException: If token is invalid or expired
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 133 |
+
email: str = payload.get("email")
|
| 134 |
+
if email is None:
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 137 |
+
detail="Could not validate credentials",
|
| 138 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 139 |
+
)
|
| 140 |
+
return payload
|
| 141 |
+
except JWTError:
|
| 142 |
+
raise HTTPException(
|
| 143 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 144 |
+
detail="Could not validate credentials",
|
| 145 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# FastAPI dependencies
|
| 150 |
+
async def get_current_user(
|
| 151 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 152 |
+
) -> Dict[str, Any]:
|
| 153 |
+
"""
|
| 154 |
+
FastAPI dependency to get current authenticated user.
|
| 155 |
+
|
| 156 |
+
Usage:
|
| 157 |
+
@app.get("/protected")
|
| 158 |
+
def protected_route(current_user: dict = Depends(get_current_user)):
|
| 159 |
+
return {"user": current_user}
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
User data from token payload
|
| 163 |
+
|
| 164 |
+
Raises:
|
| 165 |
+
HTTPException: If token is invalid
|
| 166 |
+
"""
|
| 167 |
+
token = credentials.credentials
|
| 168 |
+
payload = verify_token(token)
|
| 169 |
+
return payload
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def get_current_active_user(
|
| 173 |
+
current_user: dict = Depends(get_current_user)
|
| 174 |
+
) -> Dict[str, Any]:
|
| 175 |
+
"""
|
| 176 |
+
Get current active user (additional checks can be added here).
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
current_user: User from token
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
User data if active
|
| 183 |
+
|
| 184 |
+
Raises:
|
| 185 |
+
HTTPException: If user is inactive
|
| 186 |
+
"""
|
| 187 |
+
# Add additional checks here (e.g., is_active flag from database)
|
| 188 |
+
return current_user
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
async def require_role(required_role: str):
|
| 192 |
+
"""
|
| 193 |
+
Dependency factory for role-based access control.
|
| 194 |
+
|
| 195 |
+
Usage:
|
| 196 |
+
@app.post("/admin/users", dependencies=[Depends(require_role("admin"))])
|
| 197 |
+
def admin_only_route():
|
| 198 |
+
return {"message": "Admin access"}
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
required_role: Required role (user, supplier, admin)
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
Dependency function
|
| 205 |
+
"""
|
| 206 |
+
async def role_checker(current_user: dict = Depends(get_current_user)):
|
| 207 |
+
user_role = current_user.get("role", "user")
|
| 208 |
+
if user_role != required_role and user_role != "admin":
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 211 |
+
detail=f"Insufficient permissions. Required role: {required_role}"
|
| 212 |
+
)
|
| 213 |
+
return current_user
|
| 214 |
+
|
| 215 |
+
return role_checker
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# Helper for protected endpoints
|
| 219 |
+
def require_admin(current_user: dict = Depends(get_current_user)):
|
| 220 |
+
"""Require admin role for endpoint."""
|
| 221 |
+
if current_user.get("role") != "admin":
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 224 |
+
detail="Admin access required"
|
| 225 |
+
)
|
| 226 |
+
return current_user
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def require_supplier(current_user: dict = Depends(get_current_user)):
|
| 230 |
+
"""Require supplier role for endpoint."""
|
| 231 |
+
user_role = current_user.get("role")
|
| 232 |
+
if user_role not in ["supplier", "admin"]:
|
| 233 |
+
raise HTTPException(
|
| 234 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 235 |
+
detail="Supplier access required"
|
| 236 |
+
)
|
| 237 |
+
return current_user
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# Utility functions for user authentication
|
| 241 |
+
def authenticate_user(email: str, password: str, db_user: Dict[str, Any]) -> bool:
|
| 242 |
+
"""
|
| 243 |
+
Authenticate user with email and password.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
email: User email
|
| 247 |
+
password: Plain password
|
| 248 |
+
db_user: User data from database (must include 'hashed_password' key)
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
True if authentication successful, False otherwise
|
| 252 |
+
"""
|
| 253 |
+
if not db_user:
|
| 254 |
+
return False
|
| 255 |
+
if not verify_password(password, db_user.get("hashed_password", "")):
|
| 256 |
+
return False
|
| 257 |
+
return True
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def create_tokens_for_user(email: str, role: str = "user", **extra_data) -> Token:
|
| 261 |
+
"""
|
| 262 |
+
Create access and refresh tokens for user.
|
| 263 |
+
|
| 264 |
+
Args:
|
| 265 |
+
email: User email
|
| 266 |
+
role: User role (user, supplier, admin)
|
| 267 |
+
**extra_data: Additional data to include in token
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
Token object with access_token, refresh_token, and token_type
|
| 271 |
+
"""
|
| 272 |
+
token_data = {"email": email, "role": role, **extra_data}
|
| 273 |
+
|
| 274 |
+
access_token = create_access_token(token_data)
|
| 275 |
+
refresh_token = create_refresh_token(token_data)
|
| 276 |
+
|
| 277 |
+
return Token(
|
| 278 |
+
access_token=access_token,
|
| 279 |
+
refresh_token=refresh_token,
|
| 280 |
+
token_type="bearer"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# Example: Refresh token endpoint logic
|
| 285 |
+
def refresh_access_token(refresh_token: str) -> str:
|
| 286 |
+
"""
|
| 287 |
+
Generate new access token from refresh token.
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
refresh_token: Valid refresh token
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
New access token
|
| 294 |
+
|
| 295 |
+
Raises:
|
| 296 |
+
HTTPException: If refresh token is invalid or not a refresh type
|
| 297 |
+
"""
|
| 298 |
+
payload = verify_token(refresh_token)
|
| 299 |
+
|
| 300 |
+
# Check if it's a refresh token
|
| 301 |
+
if payload.get("type") != "refresh":
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 304 |
+
detail="Invalid token type"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
# Create new access token
|
| 308 |
+
token_data = {
|
| 309 |
+
"email": payload.get("email"),
|
| 310 |
+
"role": payload.get("role")
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return create_access_token(token_data)
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# PostgreSQL database
|
| 5 |
+
postgres:
|
| 6 |
+
image: postgres:15-alpine
|
| 7 |
+
container_name: marketplace-db
|
| 8 |
+
environment:
|
| 9 |
+
POSTGRES_USER: marketplace
|
| 10 |
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme123}
|
| 11 |
+
POSTGRES_DB: marketplace
|
| 12 |
+
volumes:
|
| 13 |
+
- postgres_data:/var/lib/postgresql/data
|
| 14 |
+
ports:
|
| 15 |
+
- "5432:5432"
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD-SHELL", "pg_isready -U marketplace"]
|
| 18 |
+
interval: 10s
|
| 19 |
+
timeout: 5s
|
| 20 |
+
retries: 5
|
| 21 |
+
restart: unless-stopped
|
| 22 |
+
|
| 23 |
+
# Redis cache (optional, for future features)
|
| 24 |
+
redis:
|
| 25 |
+
image: redis:7-alpine
|
| 26 |
+
container_name: marketplace-redis
|
| 27 |
+
ports:
|
| 28 |
+
- "6379:6379"
|
| 29 |
+
volumes:
|
| 30 |
+
- redis_data:/data
|
| 31 |
+
healthcheck:
|
| 32 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 33 |
+
interval: 10s
|
| 34 |
+
timeout: 5s
|
| 35 |
+
retries: 5
|
| 36 |
+
restart: unless-stopped
|
| 37 |
+
|
| 38 |
+
# FastAPI application
|
| 39 |
+
app:
|
| 40 |
+
build:
|
| 41 |
+
context: .
|
| 42 |
+
dockerfile: Dockerfile
|
| 43 |
+
container_name: marketplace-app
|
| 44 |
+
environment:
|
| 45 |
+
# Server
|
| 46 |
+
PORT: 3000
|
| 47 |
+
|
| 48 |
+
# Database
|
| 49 |
+
DATABASE_URL: postgresql://marketplace:${POSTGRES_PASSWORD:-changeme123}@postgres:5432/marketplace
|
| 50 |
+
|
| 51 |
+
# Model
|
| 52 |
+
MODEL_REPO_ID: unsloth/Qwen3-4B-Instruct-2507
|
| 53 |
+
HF_TOKEN: ${HF_TOKEN:-}
|
| 54 |
+
EAGER_LOAD_MODEL: ${EAGER_LOAD_MODEL:-1}
|
| 55 |
+
|
| 56 |
+
# Inference
|
| 57 |
+
MAX_TOKENS: 4096
|
| 58 |
+
TEMPERATURE: 0.7
|
| 59 |
+
DEVICE_MAP: auto
|
| 60 |
+
TORCH_DTYPE: auto
|
| 61 |
+
|
| 62 |
+
# Authentication
|
| 63 |
+
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-jwt-key-change-in-production}
|
| 64 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
| 65 |
+
REFRESH_TOKEN_EXPIRE_DAYS: 7
|
| 66 |
+
|
| 67 |
+
# Session persistence
|
| 68 |
+
PERSIST_SESSIONS: 1
|
| 69 |
+
SESSIONS_DB_PATH: sessions.db
|
| 70 |
+
SESSIONS_TTL_SECONDS: 600
|
| 71 |
+
|
| 72 |
+
# Redis (future)
|
| 73 |
+
REDIS_URL: redis://redis:6379/0
|
| 74 |
+
ports:
|
| 75 |
+
- "3000:3000"
|
| 76 |
+
volumes:
|
| 77 |
+
- ./marketplace.db:/app/marketplace.db
|
| 78 |
+
- ./sessions.db:/app/sessions.db
|
| 79 |
+
- hf_cache:/app/hf-cache
|
| 80 |
+
depends_on:
|
| 81 |
+
postgres:
|
| 82 |
+
condition: service_healthy
|
| 83 |
+
redis:
|
| 84 |
+
condition: service_healthy
|
| 85 |
+
healthcheck:
|
| 86 |
+
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
| 87 |
+
interval: 30s
|
| 88 |
+
timeout: 10s
|
| 89 |
+
retries: 3
|
| 90 |
+
start_period: 120s
|
| 91 |
+
restart: unless-stopped
|
| 92 |
+
|
| 93 |
+
# Nginx reverse proxy (optional, for production)
|
| 94 |
+
nginx:
|
| 95 |
+
image: nginx:alpine
|
| 96 |
+
container_name: marketplace-nginx
|
| 97 |
+
ports:
|
| 98 |
+
- "80:80"
|
| 99 |
+
- "443:443"
|
| 100 |
+
volumes:
|
| 101 |
+
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
| 102 |
+
- ./ssl:/etc/nginx/ssl:ro
|
| 103 |
+
depends_on:
|
| 104 |
+
- app
|
| 105 |
+
restart: unless-stopped
|
| 106 |
+
|
| 107 |
+
volumes:
|
| 108 |
+
postgres_data:
|
| 109 |
+
driver: local
|
| 110 |
+
redis_data:
|
| 111 |
+
driver: local
|
| 112 |
+
hf_cache:
|
| 113 |
+
driver: local
|
main.py
CHANGED
|
@@ -1900,6 +1900,143 @@ def cancel_session(session_id: str):
|
|
| 1900 |
return JSONResponse({"ok": True, "session_id": session_id})
|
| 1901 |
|
| 1902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
# ============================================================================
|
| 1904 |
# MARKETPLACE API ENDPOINTS
|
| 1905 |
# ============================================================================
|
|
|
|
| 1900 |
return JSONResponse({"ok": True, "session_id": session_id})
|
| 1901 |
|
| 1902 |
|
| 1903 |
+
# ============================================================================
|
| 1904 |
+
# AUTHENTICATION API ENDPOINTS
|
| 1905 |
+
# ============================================================================
|
| 1906 |
+
|
| 1907 |
+
from auth import (
|
| 1908 |
+
hash_password,
|
| 1909 |
+
verify_password,
|
| 1910 |
+
create_tokens_for_user,
|
| 1911 |
+
get_current_user,
|
| 1912 |
+
get_current_active_user,
|
| 1913 |
+
require_admin,
|
| 1914 |
+
require_supplier,
|
| 1915 |
+
Token,
|
| 1916 |
+
UserLogin,
|
| 1917 |
+
refresh_access_token
|
| 1918 |
+
)
|
| 1919 |
+
|
| 1920 |
+
class AuthUserRegister(BaseModel):
|
| 1921 |
+
name: str
|
| 1922 |
+
email: str
|
| 1923 |
+
password: str
|
| 1924 |
+
city: Optional[str] = None
|
| 1925 |
+
latitude: Optional[float] = None
|
| 1926 |
+
longitude: Optional[float] = None
|
| 1927 |
+
role: str = "user" # user, supplier, admin
|
| 1928 |
+
|
| 1929 |
+
|
| 1930 |
+
@app.post("/api/auth/register", tags=["auth"], response_model=Token)
|
| 1931 |
+
def register_auth_user(user: AuthUserRegister, db: Session = Depends(get_db)):
|
| 1932 |
+
"""
|
| 1933 |
+
Register new user with authentication.
|
| 1934 |
+
|
| 1935 |
+
Creates user account with hashed password and returns JWT tokens.
|
| 1936 |
+
Role can be: user, supplier, or admin.
|
| 1937 |
+
"""
|
| 1938 |
+
# Check if email exists
|
| 1939 |
+
existing = db.query(User).filter(User.email == user.email).first()
|
| 1940 |
+
if existing:
|
| 1941 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 1942 |
+
|
| 1943 |
+
# Hash password
|
| 1944 |
+
hashed_password = hash_password(user.password)
|
| 1945 |
+
|
| 1946 |
+
# Create user
|
| 1947 |
+
db_user = User(
|
| 1948 |
+
name=user.name,
|
| 1949 |
+
email=user.email,
|
| 1950 |
+
hashed_password=hashed_password,
|
| 1951 |
+
city=user.city,
|
| 1952 |
+
latitude=user.latitude,
|
| 1953 |
+
longitude=user.longitude,
|
| 1954 |
+
role=user.role,
|
| 1955 |
+
ai_access_enabled=(user.role in ["supplier", "admin"]) # Premium by default
|
| 1956 |
+
)
|
| 1957 |
+
db.add(db_user)
|
| 1958 |
+
db.commit()
|
| 1959 |
+
db.refresh(db_user)
|
| 1960 |
+
|
| 1961 |
+
# Create tokens
|
| 1962 |
+
tokens = create_tokens_for_user(
|
| 1963 |
+
email=db_user.email,
|
| 1964 |
+
role=db_user.role,
|
| 1965 |
+
user_id=db_user.id
|
| 1966 |
+
)
|
| 1967 |
+
|
| 1968 |
+
return tokens
|
| 1969 |
+
|
| 1970 |
+
|
| 1971 |
+
@app.post("/api/auth/login", tags=["auth"], response_model=Token)
|
| 1972 |
+
def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
| 1973 |
+
"""
|
| 1974 |
+
Login user and return JWT tokens.
|
| 1975 |
+
|
| 1976 |
+
Authenticates user with email and password, returns access and refresh tokens.
|
| 1977 |
+
"""
|
| 1978 |
+
# Find user
|
| 1979 |
+
user = db.query(User).filter(User.email == credentials.email).first()
|
| 1980 |
+
|
| 1981 |
+
if not user or not user.hashed_password:
|
| 1982 |
+
raise HTTPException(
|
| 1983 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 1984 |
+
detail="Incorrect email or password"
|
| 1985 |
+
)
|
| 1986 |
+
|
| 1987 |
+
# Verify password
|
| 1988 |
+
if not verify_password(credentials.password, user.hashed_password):
|
| 1989 |
+
raise HTTPException(
|
| 1990 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 1991 |
+
detail="Incorrect email or password"
|
| 1992 |
+
)
|
| 1993 |
+
|
| 1994 |
+
# Create tokens
|
| 1995 |
+
tokens = create_tokens_for_user(
|
| 1996 |
+
email=user.email,
|
| 1997 |
+
role=user.role,
|
| 1998 |
+
user_id=user.id
|
| 1999 |
+
)
|
| 2000 |
+
|
| 2001 |
+
return tokens
|
| 2002 |
+
|
| 2003 |
+
|
| 2004 |
+
@app.post("/api/auth/refresh", tags=["auth"])
|
| 2005 |
+
def refresh_token(refresh_token: str):
|
| 2006 |
+
"""
|
| 2007 |
+
Refresh access token using refresh token.
|
| 2008 |
+
|
| 2009 |
+
Returns new access token without requiring re-authentication.
|
| 2010 |
+
"""
|
| 2011 |
+
try:
|
| 2012 |
+
new_access_token = refresh_access_token(refresh_token)
|
| 2013 |
+
return {"access_token": new_access_token, "token_type": "bearer"}
|
| 2014 |
+
except HTTPException as e:
|
| 2015 |
+
raise e
|
| 2016 |
+
|
| 2017 |
+
|
| 2018 |
+
@app.get("/api/auth/me", tags=["auth"])
|
| 2019 |
+
def get_me(current_user: dict = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
| 2020 |
+
"""
|
| 2021 |
+
Get current authenticated user profile.
|
| 2022 |
+
|
| 2023 |
+
Requires valid JWT token in Authorization header.
|
| 2024 |
+
"""
|
| 2025 |
+
user = db.query(User).filter(User.email == current_user["email"]).first()
|
| 2026 |
+
if not user:
|
| 2027 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 2028 |
+
|
| 2029 |
+
return {
|
| 2030 |
+
"id": user.id,
|
| 2031 |
+
"name": user.name,
|
| 2032 |
+
"email": user.email,
|
| 2033 |
+
"city": user.city,
|
| 2034 |
+
"role": user.role,
|
| 2035 |
+
"ai_access_enabled": user.ai_access_enabled,
|
| 2036 |
+
"created_at": user.created_at
|
| 2037 |
+
}
|
| 2038 |
+
|
| 2039 |
+
|
| 2040 |
# ============================================================================
|
| 2041 |
# MARKETPLACE API ENDPOINTS
|
| 2042 |
# ============================================================================
|
models.py
CHANGED
|
@@ -26,6 +26,7 @@ class Supplier(Base):
|
|
| 26 |
name = Column(String(255), nullable=False)
|
| 27 |
business_name = Column(String(255), nullable=False)
|
| 28 |
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
| 29 |
phone = Column(String(50))
|
| 30 |
address = Column(Text)
|
| 31 |
latitude = Column(Float, nullable=False) # For location-based search
|
|
@@ -75,11 +76,13 @@ class User(Base):
|
|
| 75 |
id = Column(Integer, primary_key=True, index=True)
|
| 76 |
name = Column(String(255), nullable=False)
|
| 77 |
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
| 78 |
phone = Column(String(50))
|
| 79 |
latitude = Column(Float)
|
| 80 |
longitude = Column(Float)
|
| 81 |
city = Column(String(100))
|
| 82 |
province = Column(String(100))
|
|
|
|
| 83 |
ai_access_enabled = Column(Boolean, default=False) # Premium feature flag
|
| 84 |
preferences = Column(Text) # JSON string for user preferences
|
| 85 |
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
| 26 |
name = Column(String(255), nullable=False)
|
| 27 |
business_name = Column(String(255), nullable=False)
|
| 28 |
email = Column(String(255), unique=True, nullable=False, index=True)
|
| 29 |
+
hashed_password = Column(String(255)) # For authentication
|
| 30 |
phone = Column(String(50))
|
| 31 |
address = Column(Text)
|
| 32 |
latitude = Column(Float, nullable=False) # For location-based search
|
|
|
|
| 76 |
id = Column(Integer, primary_key=True, index=True)
|
| 77 |
name = Column(String(255), nullable=False)
|
| 78 |
email = Column(String(255), unique=True, nullable=False, index=True)
|
| 79 |
+
hashed_password = Column(String(255)) # For authentication
|
| 80 |
phone = Column(String(50))
|
| 81 |
latitude = Column(Float)
|
| 82 |
longitude = Column(Float)
|
| 83 |
city = Column(String(100))
|
| 84 |
province = Column(String(100))
|
| 85 |
+
role = Column(String(20), default="user") # user, supplier, admin
|
| 86 |
ai_access_enabled = Column(Boolean, default=False) # Premium feature flag
|
| 87 |
preferences = Column(Text) # JSON string for user preferences
|
| 88 |
created_at = Column(DateTime, default=datetime.utcnow)
|
requirements.txt
CHANGED
|
@@ -7,6 +7,10 @@ python-multipart>=0.0.6
|
|
| 7 |
sqlalchemy>=2.0.0
|
| 8 |
alembic>=1.12.0
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# HF ecosystem
|
| 11 |
transformers>=4.44.0
|
| 12 |
accelerate>=0.33.0
|
|
|
|
| 7 |
sqlalchemy>=2.0.0
|
| 8 |
alembic>=1.12.0
|
| 9 |
|
| 10 |
+
# Authentication
|
| 11 |
+
python-jose[cryptography]>=3.3.0
|
| 12 |
+
passlib[bcrypt]>=1.7.4
|
| 13 |
+
|
| 14 |
# HF ecosystem
|
| 15 |
transformers>=4.44.0
|
| 16 |
accelerate>=0.33.0
|
tests/test_api.py
CHANGED
|
@@ -660,28 +660,32 @@ def test_stream_resume_data_integrity_with_unicode():
|
|
| 660 |
assert actual == expected, f"Content mismatch: '{actual}' != '{expected}'"
|
| 661 |
|
| 662 |
def test_ktp_ocr_success():
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
|
|
|
|
|
|
|
|
|
| 684 |
|
|
|
|
| 685 |
client = get_client()
|
| 686 |
with open("image.jpg", "rb") as f:
|
| 687 |
files = {"image": ("image.jpg", f, "image/jpeg")}
|
|
@@ -690,4 +694,19 @@ def test_ktp_ocr_success():
|
|
| 690 |
assert r.status_code == 200
|
| 691 |
body = r.json()
|
| 692 |
assert body["nik"] == "1234567890123456"
|
| 693 |
-
assert body["nama"] == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
assert actual == expected, f"Content mismatch: '{actual}' != '{expected}'"
|
| 661 |
|
| 662 |
def test_ktp_ocr_success():
|
| 663 |
+
# Mock RapidOCR to return test text lines that should parse to expected KTP data
|
| 664 |
+
test_ocr_texts = [
|
| 665 |
+
"NIK : 1234567890123456",
|
| 666 |
+
"Nama : JOHN DOE",
|
| 667 |
+
"Tempat/Tgl Lahir : JAKARTA, 01-01-1990",
|
| 668 |
+
"Jenis Kelamin : LAKI-LAKI",
|
| 669 |
+
"Alamat : JL. JEND. SUDIRMAN KAV. 52-53",
|
| 670 |
+
"RT/RW : 001/001",
|
| 671 |
+
"Kel/Desa : SENAYAN",
|
| 672 |
+
"Kecamatan : KEBAYORAN BARU",
|
| 673 |
+
"Agama : ISLAM",
|
| 674 |
+
"Status Perkawinan : KAWIN",
|
| 675 |
+
"Pekerjaan : PEGAWAI SWASTA",
|
| 676 |
+
"Kewarganegaraan : WNI",
|
| 677 |
+
"Berlaku Hingga : SEUMUR HIDUP"
|
| 678 |
+
]
|
| 679 |
+
|
| 680 |
+
# Mock the OCR result format: [[(bbox, text, confidence), ...]]
|
| 681 |
+
mock_ocr_result = [[(None, text, 0.9) for text in test_ocr_texts]]
|
| 682 |
+
|
| 683 |
+
# Patch get_ocr_engine to return a mock OCR engine
|
| 684 |
+
original_get_ocr_engine = main.get_ocr_engine
|
| 685 |
+
mock_engine = lambda img: mock_ocr_result
|
| 686 |
+
main.get_ocr_engine = lambda: mock_engine
|
| 687 |
|
| 688 |
+
try:
|
| 689 |
client = get_client()
|
| 690 |
with open("image.jpg", "rb") as f:
|
| 691 |
files = {"image": ("image.jpg", f, "image/jpeg")}
|
|
|
|
| 694 |
assert r.status_code == 200
|
| 695 |
body = r.json()
|
| 696 |
assert body["nik"] == "1234567890123456"
|
| 697 |
+
assert body["nama"] == "John Doe"
|
| 698 |
+
assert body["tempat_lahir"] == "Jakarta"
|
| 699 |
+
assert body["tgl_lahir"] == "01-01-1990"
|
| 700 |
+
assert body["jenis_kelamin"] == "LAKI-LAKI"
|
| 701 |
+
assert body["alamat"]["name"] == "JL. JEND. SUDIRMAN KAV. 52-53"
|
| 702 |
+
assert body["alamat"]["rt_rw"] == "001/001"
|
| 703 |
+
assert body["alamat"]["kel_desa"] == "Senayan"
|
| 704 |
+
assert body["alamat"]["kecamatan"] == "Kebayoran Baru"
|
| 705 |
+
assert body["agama"] == "Islam"
|
| 706 |
+
assert body["status_perkawinan"] == "Kawin"
|
| 707 |
+
assert body["pekerjaan"] == "Pegawai Swasta"
|
| 708 |
+
assert body["kewarganegaraan"] == "Wni"
|
| 709 |
+
assert body["berlaku_hingga"] == "Seumur Hidup"
|
| 710 |
+
finally:
|
| 711 |
+
# Restore original function
|
| 712 |
+
main.get_ocr_engine = original_get_ocr_engine
|