Refactoring code for modular approach
This commit is contained in:
2
.idea/Letzshop-Import-v2.iml
generated
2
.idea/Letzshop-Import-v2.iml
generated
@@ -4,7 +4,7 @@
|
|||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.10 (Letzshop-Import-v2)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.10 (Letzshop-Import-v1)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PyDocumentationSettings">
|
<component name="PyDocumentationSettings">
|
||||||
|
|||||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -3,5 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="Python 3.10 (Letz-backend)" />
|
<option name="sdkName" value="Python 3.10 (Letz-backend)" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (Letzshop-Import-v2)" project-jdk-type="Python SDK" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (Letzshop-Import-v1)" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
40
app/api/deps.py
Normal file
40
app/api/deps.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from models.database_models import User, Shop
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
from middleware.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get current authenticated user"""
|
||||||
|
return auth_manager.get_current_user(db, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Require admin user"""
|
||||||
|
return auth_manager.require_admin(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_shop(
|
||||||
|
shop_code: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get shop and verify user ownership"""
|
||||||
|
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this shop")
|
||||||
|
|
||||||
|
return shop
|
||||||
13
app/api/main.py
Normal file
13
app/api/main.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.api.v1 import auth, products, stock, shops, marketplace, admin, stats
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# Include all route modules
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||||
|
api_router.include_router(products.router, prefix="/products", tags=["products"])
|
||||||
|
api_router.include_router(stock.router, prefix="/stock", tags=["stock"])
|
||||||
|
api_router.include_router(shops.router, prefix="/shops", tags=["shops"])
|
||||||
|
api_router.include_router(marketplace.router, prefix="/marketplace", tags=["marketplace"])
|
||||||
|
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(stats.router, prefix="/stats", tags=["statistics"])
|
||||||
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
153
app/api/v1/admin.py
Normal file
153
app/api/v1/admin.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
||||||
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Admin-only routes
|
||||||
|
@router.get("/admin/users", response_model=List[UserResponse])
|
||||||
|
def get_all_users(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Get all users (Admin only)"""
|
||||||
|
users = db.query(User).offset(skip).limit(limit).all()
|
||||||
|
return [UserResponse.model_validate(user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/users/{user_id}/status")
|
||||||
|
def toggle_user_status(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Toggle user active status (Admin only)"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user.id == current_admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
status = "activated" if user.is_active else "deactivated"
|
||||||
|
return {"message": f"User {user.username} has been {status}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/shops", response_model=ShopListResponse)
|
||||||
|
def get_all_shops_admin(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Get all shops with admin view (Admin only)"""
|
||||||
|
total = db.query(Shop).count()
|
||||||
|
shops = db.query(Shop).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return ShopListResponse(
|
||||||
|
shops=shops,
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/shops/{shop_id}/verify")
|
||||||
|
def verify_shop(
|
||||||
|
shop_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Verify/unverify shop (Admin only)"""
|
||||||
|
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
shop.is_verified = not shop.is_verified
|
||||||
|
shop.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
|
||||||
|
status = "verified" if shop.is_verified else "unverified"
|
||||||
|
return {"message": f"Shop {shop.shop_code} has been {status}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/shops/{shop_id}/status")
|
||||||
|
def toggle_shop_status(
|
||||||
|
shop_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Toggle shop active status (Admin only)"""
|
||||||
|
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
shop.is_active = not shop.is_active
|
||||||
|
shop.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
|
||||||
|
status = "activated" if shop.is_active else "deactivated"
|
||||||
|
return {"message": f"Shop {shop.shop_code} has been {status}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse])
|
||||||
|
def get_all_marketplace_import_jobs(
|
||||||
|
marketplace: Optional[str] = Query(None),
|
||||||
|
shop_name: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_user)
|
||||||
|
):
|
||||||
|
"""Get all marketplace import jobs (Admin only)"""
|
||||||
|
|
||||||
|
query = db.query(MarketplaceImportJob)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
if status:
|
||||||
|
query = query.filter(MarketplaceImportJob.status == status)
|
||||||
|
|
||||||
|
# Order by creation date and apply pagination
|
||||||
|
jobs = query.order_by(MarketplaceImportJob.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MarketplaceImportJobResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
status=job.status,
|
||||||
|
marketplace=job.marketplace,
|
||||||
|
shop_name=job.shop_name,
|
||||||
|
imported=job.imported_count or 0,
|
||||||
|
updated=job.updated_count or 0,
|
||||||
|
total_processed=job.total_processed or 0,
|
||||||
|
error_count=job.error_count or 0,
|
||||||
|
error_message=job.error_message,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
completed_at=job.completed_at
|
||||||
|
) for job in jobs
|
||||||
|
]
|
||||||
70
app/api/v1/auth.py
Normal file
70
app/api/v1/auth.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from models.api_models import UserRegister, UserLogin, UserResponse, LoginResponse
|
||||||
|
from models.database_models import User
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Authentication Routes
|
||||||
|
@router.post("/register", response_model=UserResponse)
|
||||||
|
def register_user(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||||
|
"""Register a new user"""
|
||||||
|
# Check if email already exists
|
||||||
|
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
||||||
|
if existing_email:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
existing_username = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if existing_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
|
|
||||||
|
# Hash password and create user
|
||||||
|
hashed_password = auth_manager.hash_password(user_data.password)
|
||||||
|
new_user = User(
|
||||||
|
email=user_data.email,
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
role="user",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
logger.info(f"New user registered: {new_user.username}")
|
||||||
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
def login_user(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
"""Login user and return JWT token"""
|
||||||
|
user = auth_manager.authenticate_user(db, user_credentials.username, user_credentials.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
token_data = auth_manager.create_access_token(user)
|
||||||
|
|
||||||
|
logger.info(f"User logged in: {user.username}")
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=token_data["access_token"],
|
||||||
|
token_type=token_data["token_type"],
|
||||||
|
expires_in=token_data["expires_in"],
|
||||||
|
user=UserResponse.model_validate(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get current user information"""
|
||||||
|
return UserResponse.model_validate(current_user)
|
||||||
146
app/api/v1/marketplace.py
Normal file
146
app/api/v1/marketplace.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
||||||
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Marketplace Import Routes (Protected)
|
||||||
|
@router.post("/import-from-marketplace", response_model=MarketplaceImportJobResponse)
|
||||||
|
@rate_limit(max_requests=10, window_seconds=3600) # Limit marketplace imports
|
||||||
|
async def import_products_from_marketplace(
|
||||||
|
request: MarketplaceImportRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Import products from marketplace CSV with background processing (Protected)"""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}")
|
||||||
|
|
||||||
|
# Verify shop exists and user has access
|
||||||
|
shop = db.query(Shop).filter(Shop.shop_code == request.shop_code).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
# Check permissions: admin can import for any shop, others only for their own
|
||||||
|
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this shop")
|
||||||
|
|
||||||
|
# Create marketplace import job record
|
||||||
|
import_job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
source_url=request.url,
|
||||||
|
marketplace=request.marketplace,
|
||||||
|
shop_code=request.shop_code,
|
||||||
|
user_id=current_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(import_job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(import_job)
|
||||||
|
|
||||||
|
# Process in background
|
||||||
|
background_tasks.add_task(
|
||||||
|
process_marketplace_import,
|
||||||
|
import_job.id,
|
||||||
|
request.url,
|
||||||
|
request.marketplace,
|
||||||
|
request.shop_code,
|
||||||
|
request.batch_size or 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketplaceImportJobResponse(
|
||||||
|
job_id=import_job.id,
|
||||||
|
status="pending",
|
||||||
|
marketplace=request.marketplace,
|
||||||
|
shop_code=request.shop_code,
|
||||||
|
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
||||||
|
f"/marketplace-import-status/{import_job.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marketplace-import-status/{job_id}", response_model=MarketplaceImportJobResponse)
|
||||||
|
def get_marketplace_import_status(
|
||||||
|
job_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get status of marketplace import job (Protected)"""
|
||||||
|
job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Marketplace import job not found")
|
||||||
|
|
||||||
|
# Users can only see their own jobs, admins can see all
|
||||||
|
if current_user.role != "admin" and job.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to this import job")
|
||||||
|
|
||||||
|
return MarketplaceImportJobResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
status=job.status,
|
||||||
|
marketplace=job.marketplace,
|
||||||
|
shop_name=job.shop_name,
|
||||||
|
imported=job.imported_count or 0,
|
||||||
|
updated=job.updated_count or 0,
|
||||||
|
total_processed=job.total_processed or 0,
|
||||||
|
error_count=job.error_count or 0,
|
||||||
|
error_message=job.error_message,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
completed_at=job.completed_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse])
|
||||||
|
def get_marketplace_import_jobs(
|
||||||
|
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||||
|
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get marketplace import jobs with filtering (Protected)"""
|
||||||
|
|
||||||
|
query = db.query(MarketplaceImportJob)
|
||||||
|
|
||||||
|
# Users can only see their own jobs, admins can see all
|
||||||
|
if current_user.role != "admin":
|
||||||
|
query = query.filter(MarketplaceImportJob.user_id == current_user.id)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
# Order by creation date (newest first) and apply pagination
|
||||||
|
jobs = query.order_by(MarketplaceImportJob.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MarketplaceImportJobResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
status=job.status,
|
||||||
|
marketplace=job.marketplace,
|
||||||
|
shop_name=job.shop_name,
|
||||||
|
imported=job.imported_count or 0,
|
||||||
|
updated=job.updated_count or 0,
|
||||||
|
total_processed=job.total_processed or 0,
|
||||||
|
error_count=job.error_count or 0,
|
||||||
|
error_message=job.error_message,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
completed_at=job.completed_at
|
||||||
|
) for job in jobs
|
||||||
|
]
|
||||||
261
app/api/v1/products.py
Normal file
261
app/api/v1/products.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from models.api_models import (ProductListResponse, ProductResponse, ProductCreate, ProductDetailResponse,
|
||||||
|
StockLocationResponse, StockSummaryResponse, ProductUpdate)
|
||||||
|
from models.database_models import User, Product, Stock
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from utils.data_processing import GTINProcessor, PriceProcessor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize processors
|
||||||
|
gtin_processor = GTINProcessor()
|
||||||
|
price_processor = PriceProcessor()
|
||||||
|
|
||||||
|
|
||||||
|
# Enhanced Product Routes with Marketplace Support
|
||||||
|
@router.get("/products", response_model=ProductListResponse)
|
||||||
|
def get_products(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
brand: Optional[str] = Query(None),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
availability: Optional[str] = Query(None),
|
||||||
|
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||||
|
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get products with advanced filtering including marketplace and shop (Protected)"""
|
||||||
|
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if brand:
|
||||||
|
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
||||||
|
if category:
|
||||||
|
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
||||||
|
if availability:
|
||||||
|
query = query.filter(Product.availability == availability)
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
if search:
|
||||||
|
# Search in title, description, and marketplace
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(Product.title.ilike(search_term)) |
|
||||||
|
(Product.description.ilike(search_term)) |
|
||||||
|
(Product.marketplace.ilike(search_term)) |
|
||||||
|
(Product.shop_name.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count for pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
products = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return ProductListResponse(
|
||||||
|
products=products,
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products", response_model=ProductResponse)
|
||||||
|
def create_product(
|
||||||
|
product: ProductCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Create a new product with validation and marketplace support (Protected)"""
|
||||||
|
|
||||||
|
# Check if product_id already exists
|
||||||
|
existing = db.query(Product).filter(Product.product_id == product.product_id).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Product with this ID already exists")
|
||||||
|
|
||||||
|
# Process and validate GTIN if provided
|
||||||
|
if product.gtin:
|
||||||
|
normalized_gtin = gtin_processor.normalize(product.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
product.gtin = normalized_gtin
|
||||||
|
|
||||||
|
# Process price if provided
|
||||||
|
if product.price:
|
||||||
|
parsed_price, currency = price_processor.parse_price_currency(product.price)
|
||||||
|
if parsed_price:
|
||||||
|
product.price = parsed_price
|
||||||
|
product.currency = currency
|
||||||
|
|
||||||
|
# Set default marketplace if not provided
|
||||||
|
if not product.marketplace:
|
||||||
|
product.marketplace = "Letzshop"
|
||||||
|
|
||||||
|
db_product = Product(**product.dict())
|
||||||
|
db.add(db_product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_product)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created product {db_product.product_id} for marketplace {db_product.marketplace}, "
|
||||||
|
f"shop {db_product.shop_name}")
|
||||||
|
return db_product
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
|
||||||
|
def get_product(product_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get product with stock information (Protected)"""
|
||||||
|
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
# Get stock information if GTIN exists
|
||||||
|
stock_info = None
|
||||||
|
if product.gtin:
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == product.gtin).all()
|
||||||
|
if stock_entries:
|
||||||
|
total_quantity = sum(entry.quantity for entry in stock_entries)
|
||||||
|
locations = [
|
||||||
|
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
||||||
|
for entry in stock_entries
|
||||||
|
]
|
||||||
|
stock_info = StockSummaryResponse(
|
||||||
|
gtin=product.gtin,
|
||||||
|
total_quantity=total_quantity,
|
||||||
|
locations=locations
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProductDetailResponse(
|
||||||
|
product=product,
|
||||||
|
stock_info=stock_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/products/{product_id}", response_model=ProductResponse)
|
||||||
|
def update_product(
|
||||||
|
product_id: str,
|
||||||
|
product_update: ProductUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Update product with validation and marketplace support (Protected)"""
|
||||||
|
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
update_data = product_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate GTIN if being updated
|
||||||
|
if "gtin" in update_data and update_data["gtin"]:
|
||||||
|
normalized_gtin = gtin_processor.normalize(update_data["gtin"])
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
update_data["gtin"] = normalized_gtin
|
||||||
|
|
||||||
|
# Process price if being updated
|
||||||
|
if "price" in update_data and update_data["price"]:
|
||||||
|
parsed_price, currency = price_processor.parse_price_currency(update_data["price"])
|
||||||
|
if parsed_price:
|
||||||
|
update_data["price"] = parsed_price
|
||||||
|
update_data["currency"] = currency
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(product, key, value)
|
||||||
|
|
||||||
|
product.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}")
|
||||||
|
def delete_product(
|
||||||
|
product_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Delete product and associated stock (Protected)"""
|
||||||
|
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
# Delete associated stock entries if GTIN exists
|
||||||
|
if product.gtin:
|
||||||
|
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
|
||||||
|
|
||||||
|
db.delete(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Product and associated stock deleted successfully"}
|
||||||
|
|
||||||
|
# Export with streaming for large datasets (Protected)
|
||||||
|
@router.get("/export-csv")
|
||||||
|
async def export_csv(
|
||||||
|
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||||
|
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Export products as CSV with streaming and marketplace filtering (Protected)"""
|
||||||
|
|
||||||
|
def generate_csv():
|
||||||
|
# Stream CSV generation for memory efficiency
|
||||||
|
yield "product_id,title,description,link,image_link,availability,price,currency,brand,gtin,marketplace,shop_name\n"
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
# Apply marketplace filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
products = query.offset(offset).limit(batch_size).all()
|
||||||
|
if not products:
|
||||||
|
break
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
# Create CSV row with marketplace fields
|
||||||
|
row = (f'"{product.product_id}","{product.title or ""}","{product.description or ""}",'
|
||||||
|
f'"{product.link or ""}","{product.image_link or ""}","{product.availability or ""}",'
|
||||||
|
f'"{product.price or ""}","{product.currency or ""}","{product.brand or ""}",'
|
||||||
|
f'"{product.gtin or ""}","{product.marketplace or ""}","{product.shop_name or ""}"\n')
|
||||||
|
yield row
|
||||||
|
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
filename = "products_export"
|
||||||
|
if marketplace:
|
||||||
|
filename += f"_{marketplace}"
|
||||||
|
if shop_name:
|
||||||
|
filename += f"_{shop_name}"
|
||||||
|
filename += ".csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_csv(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
188
app/api/v1/shops.py
Normal file
188
app/api/v1/shops.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
||||||
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Shop Management Routes
|
||||||
|
@router.post("/shops", response_model=ShopResponse)
|
||||||
|
def create_shop(
|
||||||
|
shop_data: ShopCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Create a new shop (Protected)"""
|
||||||
|
# Check if shop code already exists
|
||||||
|
existing_shop = db.query(Shop).filter(Shop.shop_code == shop_data.shop_code).first()
|
||||||
|
if existing_shop:
|
||||||
|
raise HTTPException(status_code=400, detail="Shop code already exists")
|
||||||
|
|
||||||
|
# Create shop
|
||||||
|
new_shop = Shop(
|
||||||
|
**shop_data.dict(),
|
||||||
|
owner_id=current_user.id,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=(current_user.role == "admin") # Auto-verify if admin creates shop
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_shop)
|
||||||
|
|
||||||
|
logger.info(f"New shop created: {new_shop.shop_code} by {current_user.username}")
|
||||||
|
return new_shop
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shops", response_model=ShopListResponse)
|
||||||
|
def get_shops(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
verified_only: bool = Query(False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get shops with filtering (Protected)"""
|
||||||
|
query = db.query(Shop)
|
||||||
|
|
||||||
|
# Non-admin users can only see active and verified shops, plus their own
|
||||||
|
if current_user.role != "admin":
|
||||||
|
query = query.filter(
|
||||||
|
(Shop.is_active == True) &
|
||||||
|
((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Admin can apply filters
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(Shop.is_active == True)
|
||||||
|
if verified_only:
|
||||||
|
query = query.filter(Shop.is_verified == True)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
shops = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return ShopListResponse(
|
||||||
|
shops=shops,
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shops/{shop_code}", response_model=ShopResponse)
|
||||||
|
def get_shop(shop_code: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get shop details (Protected)"""
|
||||||
|
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
# Non-admin users can only see active verified shops or their own shops
|
||||||
|
if current_user.role != "admin":
|
||||||
|
if not shop.is_active or (not shop.is_verified and shop.owner_id != current_user.id):
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
return shop
|
||||||
|
|
||||||
|
|
||||||
|
# Shop Product Management
|
||||||
|
@router.post("/shops/{shop_code}/products", response_model=ShopProductResponse)
|
||||||
|
def add_product_to_shop(
|
||||||
|
shop_code: str,
|
||||||
|
shop_product: ShopProductCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Add existing product to shop catalog with shop-specific settings (Protected)"""
|
||||||
|
|
||||||
|
# Get and verify shop
|
||||||
|
shop = get_user_shop(shop_code, current_user, db)
|
||||||
|
|
||||||
|
# Check if product exists
|
||||||
|
product = db.query(Product).filter(Product.product_id == shop_product.product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found in marketplace catalog")
|
||||||
|
|
||||||
|
# Check if product already in shop
|
||||||
|
existing_shop_product = db.query(ShopProduct).filter(
|
||||||
|
ShopProduct.shop_id == shop.id,
|
||||||
|
ShopProduct.product_id == product.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_shop_product:
|
||||||
|
raise HTTPException(status_code=400, detail="Product already in shop catalog")
|
||||||
|
|
||||||
|
# Create shop-product association
|
||||||
|
new_shop_product = ShopProduct(
|
||||||
|
shop_id=shop.id,
|
||||||
|
product_id=product.id,
|
||||||
|
**shop_product.dict(exclude={'product_id'})
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_shop_product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_shop_product)
|
||||||
|
|
||||||
|
# Return with product details
|
||||||
|
response = ShopProductResponse.model_validate(new_shop_product)
|
||||||
|
response.product = product
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shops/{shop_code}/products")
|
||||||
|
def get_shop_products(
|
||||||
|
shop_code: str,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
featured_only: bool = Query(False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get products in shop catalog (Protected)"""
|
||||||
|
|
||||||
|
# Get shop (public can view active/verified shops)
|
||||||
|
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
||||||
|
if not shop:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
# Non-owners can only see active verified shops
|
||||||
|
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||||
|
if not shop.is_active or not shop.is_verified:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
# Query shop products
|
||||||
|
query = db.query(ShopProduct).filter(ShopProduct.shop_id == shop.id)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(ShopProduct.is_active == True)
|
||||||
|
if featured_only:
|
||||||
|
query = query.filter(ShopProduct.is_featured == True)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
shop_products = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
# Format response
|
||||||
|
products = []
|
||||||
|
for sp in shop_products:
|
||||||
|
product_response = ShopProductResponse.model_validate(sp)
|
||||||
|
product_response.product = sp.product
|
||||||
|
products.append(product_response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"products": products,
|
||||||
|
"total": total,
|
||||||
|
"skip": skip,
|
||||||
|
"limit": limit,
|
||||||
|
"shop": ShopResponse.model_validate(shop)
|
||||||
|
}
|
||||||
84
app/api/v1/stats.py
Normal file
84
app/api/v1/stats.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
||||||
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Enhanced Statistics with Marketplace Support
|
||||||
|
@router.get("/stats", response_model=StatsResponse)
|
||||||
|
def get_stats(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get comprehensive statistics with marketplace data (Protected)"""
|
||||||
|
|
||||||
|
# Use more efficient queries with proper indexes
|
||||||
|
total_products = db.query(Product).count()
|
||||||
|
|
||||||
|
unique_brands = db.query(Product.brand).filter(
|
||||||
|
Product.brand.isnot(None),
|
||||||
|
Product.brand != ""
|
||||||
|
).distinct().count()
|
||||||
|
|
||||||
|
unique_categories = db.query(Product.google_product_category).filter(
|
||||||
|
Product.google_product_category.isnot(None),
|
||||||
|
Product.google_product_category != ""
|
||||||
|
).distinct().count()
|
||||||
|
|
||||||
|
# New marketplace statistics
|
||||||
|
unique_marketplaces = db.query(Product.marketplace).filter(
|
||||||
|
Product.marketplace.isnot(None),
|
||||||
|
Product.marketplace != ""
|
||||||
|
).distinct().count()
|
||||||
|
|
||||||
|
unique_shops = db.query(Product.shop_name).filter(
|
||||||
|
Product.shop_name.isnot(None),
|
||||||
|
Product.shop_name != ""
|
||||||
|
).distinct().count()
|
||||||
|
|
||||||
|
# Stock statistics
|
||||||
|
total_stock_entries = db.query(Stock).count()
|
||||||
|
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
|
||||||
|
|
||||||
|
return StatsResponse(
|
||||||
|
total_products=total_products,
|
||||||
|
unique_brands=unique_brands,
|
||||||
|
unique_categories=unique_categories,
|
||||||
|
unique_marketplaces=unique_marketplaces,
|
||||||
|
unique_shops=unique_shops,
|
||||||
|
total_stock_entries=total_stock_entries,
|
||||||
|
total_inventory_quantity=total_inventory
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marketplace-stats", response_model=List[MarketplaceStatsResponse])
|
||||||
|
def get_marketplace_stats(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get statistics broken down by marketplace (Protected)"""
|
||||||
|
|
||||||
|
# Query to get stats per marketplace
|
||||||
|
marketplace_stats = db.query(
|
||||||
|
Product.marketplace,
|
||||||
|
func.count(Product.id).label('total_products'),
|
||||||
|
func.count(func.distinct(Product.shop_name)).label('unique_shops'),
|
||||||
|
func.count(func.distinct(Product.brand)).label('unique_brands')
|
||||||
|
).filter(
|
||||||
|
Product.marketplace.isnot(None)
|
||||||
|
).group_by(Product.marketplace).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MarketplaceStatsResponse(
|
||||||
|
marketplace=stat.marketplace,
|
||||||
|
total_products=stat.total_products,
|
||||||
|
unique_shops=stat.unique_shops,
|
||||||
|
unique_brands=stat.unique_brands
|
||||||
|
) for stat in marketplace_stats
|
||||||
|
]
|
||||||
|
|
||||||
315
app/api/v1/stock.py
Normal file
315
app/api/v1/stock.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest, StockResponse, \
|
||||||
|
StockSummaryResponse
|
||||||
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Stock Management Routes (Protected)
|
||||||
|
|
||||||
|
@router.post("/stock", response_model=StockResponse)
|
||||||
|
def set_stock(stock: StockCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)"""
|
||||||
|
|
||||||
|
# Normalize GTIN
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(stock.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
|
||||||
|
# Check if stock entry already exists for this GTIN and location
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == stock.location.strip().upper()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_stock:
|
||||||
|
# Update existing stock (SET to exact quantity)
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity = stock.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
logger.info(f"Updated stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} → {stock.quantity}")
|
||||||
|
return existing_stock
|
||||||
|
else:
|
||||||
|
# Create new stock entry
|
||||||
|
new_stock = Stock(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
location=stock.location.strip().upper(),
|
||||||
|
quantity=stock.quantity
|
||||||
|
)
|
||||||
|
db.add(new_stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_stock)
|
||||||
|
logger.info(f"Created new stock for GTIN {normalized_gtin} at {stock.location}: {stock.quantity}")
|
||||||
|
return new_stock
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stock/add", response_model=StockResponse)
|
||||||
|
def add_stock(stock: StockAdd, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)"""
|
||||||
|
|
||||||
|
# Normalize GTIN
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(stock.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
|
||||||
|
# Check if stock entry already exists for this GTIN and location
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == stock.location.strip().upper()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_stock:
|
||||||
|
# Add to existing stock
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity += stock.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
logger.info(
|
||||||
|
f"Added stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} + {stock.quantity} = {existing_stock.quantity}")
|
||||||
|
return existing_stock
|
||||||
|
else:
|
||||||
|
# Create new stock entry with the quantity
|
||||||
|
new_stock = Stock(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
location=stock.location.strip().upper(),
|
||||||
|
quantity=stock.quantity
|
||||||
|
)
|
||||||
|
db.add(new_stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_stock)
|
||||||
|
logger.info(f"Created new stock for GTIN {normalized_gtin} at {stock.location}: {stock.quantity}")
|
||||||
|
return new_stock
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stock/remove", response_model=StockResponse)
|
||||||
|
def remove_stock(stock: StockAdd, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Remove quantity from existing stock for a GTIN at a specific location"""
|
||||||
|
|
||||||
|
# Normalize GTIN
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(stock.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
|
||||||
|
# Find existing stock entry
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == stock.location.strip().upper()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_stock:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No stock found for GTIN {normalized_gtin} at location {stock.location}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we have enough stock to remove
|
||||||
|
if existing_stock.quantity < stock.quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Insufficient stock. Available: {existing_stock.quantity}, Requested to remove: {stock.quantity}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from existing stock
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity -= stock.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
logger.info(
|
||||||
|
f"Removed stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} - {stock.quantity} = {existing_stock.quantity}")
|
||||||
|
return existing_stock
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stock/{gtin}", response_model=StockSummaryResponse)
|
||||||
|
def get_stock_by_gtin(gtin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get all stock locations and total quantity for a specific GTIN"""
|
||||||
|
|
||||||
|
# Normalize GTIN
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
|
||||||
|
# Get all stock entries for this GTIN
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
|
||||||
|
if not stock_entries:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No stock found for GTIN: {gtin}")
|
||||||
|
|
||||||
|
# Calculate total quantity and build locations list
|
||||||
|
total_quantity = 0
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
for entry in stock_entries:
|
||||||
|
total_quantity += entry.quantity
|
||||||
|
locations.append(StockLocationResponse(
|
||||||
|
location=entry.location,
|
||||||
|
quantity=entry.quantity
|
||||||
|
))
|
||||||
|
|
||||||
|
# Try to get product title for reference
|
||||||
|
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
||||||
|
product_title = product.title if product else None
|
||||||
|
|
||||||
|
return StockSummaryResponse(
|
||||||
|
gtin=normalized_gtin,
|
||||||
|
total_quantity=total_quantity,
|
||||||
|
locations=locations,
|
||||||
|
product_title=product_title
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stock/{gtin}/total")
|
||||||
|
def get_total_stock(gtin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get total quantity in stock for a specific GTIN"""
|
||||||
|
|
||||||
|
# Normalize GTIN
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
||||||
|
|
||||||
|
# Calculate total stock
|
||||||
|
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
total_quantity = sum(entry.quantity for entry in total_stock)
|
||||||
|
|
||||||
|
# Get product info for context
|
||||||
|
product = db.query(Product).filter(Product.gtin == normalized_gtin).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gtin": normalized_gtin,
|
||||||
|
"total_quantity": total_quantity,
|
||||||
|
"product_title": product.title if product else None,
|
||||||
|
"locations_count": len(total_stock)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stock", response_model=List[StockResponse])
|
||||||
|
def get_all_stock(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
location: Optional[str] = Query(None, description="Filter by location"),
|
||||||
|
gtin: Optional[str] = Query(None, description="Filter by GTIN"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all stock entries with optional filtering"""
|
||||||
|
query = db.query(Stock)
|
||||||
|
|
||||||
|
if location:
|
||||||
|
query = query.filter(Stock.location.ilike(f"%{location}%"))
|
||||||
|
|
||||||
|
if gtin:
|
||||||
|
# Normalize GTIN for search
|
||||||
|
def normalize_gtin(gtin_value):
|
||||||
|
if not gtin_value:
|
||||||
|
return None
|
||||||
|
gtin_str = str(gtin_value).strip()
|
||||||
|
if '.' in gtin_str:
|
||||||
|
gtin_str = gtin_str.split('.')[0]
|
||||||
|
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
||||||
|
if len(gtin_clean) in [8, 12, 13, 14]:
|
||||||
|
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
||||||
|
return gtin_clean if gtin_clean else None
|
||||||
|
|
||||||
|
normalized_gtin = normalize_gtin(gtin)
|
||||||
|
if normalized_gtin:
|
||||||
|
query = query.filter(Stock.gtin == normalized_gtin)
|
||||||
|
|
||||||
|
stock_entries = query.offset(skip).limit(limit).all()
|
||||||
|
return stock_entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/stock/{stock_id}", response_model=StockResponse)
|
||||||
|
def update_stock(stock_id: int, stock_update: StockUpdate, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
"""Update stock quantity for a specific stock entry"""
|
||||||
|
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
if not stock_entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Stock entry not found")
|
||||||
|
|
||||||
|
stock_entry.quantity = stock_update.quantity
|
||||||
|
stock_entry.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stock_entry)
|
||||||
|
return stock_entry
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/stock/{stock_id}")
|
||||||
|
def delete_stock(stock_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Delete a stock entry"""
|
||||||
|
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
if not stock_entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Stock entry not found")
|
||||||
|
|
||||||
|
db.delete(stock_entry)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Stock entry deleted successfully"}
|
||||||
|
|
||||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
26
app/core/config.py
Normal file
26
app/core/config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
PROJECT_NAME: str = "Ecommerce Backend API with Marketplace Support"
|
||||||
|
DESCRIPTION: str = "Advanced product management system with JWT authentication"
|
||||||
|
VERSION: str = "2.2.0"
|
||||||
|
|
||||||
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://user:password@localhost/ecommerce")
|
||||||
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
|
ALLOWED_HOSTS: List[str] = ["*"] # Configure for production
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_REQUESTS: int = 100
|
||||||
|
RATE_LIMIT_WINDOW: int = 3600
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
21
app/core/database.py
Normal file
21
app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
# Database dependency with connection pooling
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
48
app/core/lifespan.py
Normal file
48
app/core/lifespan.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy import text
|
||||||
|
from .logging import setup_logging
|
||||||
|
from .database import engine
|
||||||
|
from models.database_models import Base
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan events"""
|
||||||
|
# Startup
|
||||||
|
logger = setup_logging() # Configure logging first
|
||||||
|
logger.info("Starting up ecommerce API")
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
create_indexes()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down ecommerce API")
|
||||||
|
|
||||||
|
|
||||||
|
def create_indexes():
|
||||||
|
"""Create database indexes"""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
try:
|
||||||
|
# User indexes
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_user_email ON users(email)"))
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_user_username ON users(username)"))
|
||||||
|
|
||||||
|
# Product indexes
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_product_gtin ON products(gtin)"))
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_product_marketplace ON products(marketplace)"))
|
||||||
|
|
||||||
|
# Stock indexes
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_stock_gtin_location ON stock(gtin, location)"))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Database indexes created successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Index creation warning: {e}")
|
||||||
42
app/core/logging.py
Normal file
42
app/core/logging.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# app/core/logging.py
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Configure application logging with file and console handlers"""
|
||||||
|
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
log_file = Path(settings.LOG_FILE)
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
||||||
|
|
||||||
|
# Remove existing handlers
|
||||||
|
for handler in logger.handlers[:]:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
# Create formatters
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# File handler
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Configure specific loggers
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
return logging.getLogger(__name__)
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
0
app/services/import_service.py
Normal file
0
app/services/import_service.py
Normal file
79
app/services/product_service.py
Normal file
79
app/services/product_service.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.database_models import Product
|
||||||
|
from models.api_models import ProductCreate
|
||||||
|
from utils.data_processing import GTINProcessor, PriceProcessor
|
||||||
|
from typing import Optional, List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductService:
|
||||||
|
def __init__(self):
|
||||||
|
self.gtin_processor = GTINProcessor()
|
||||||
|
self.price_processor = PriceProcessor()
|
||||||
|
|
||||||
|
def create_product(self, db: Session, product_data: ProductCreate) -> Product:
|
||||||
|
"""Create a new product with validation"""
|
||||||
|
# Process and validate GTIN if provided
|
||||||
|
if product_data.gtin:
|
||||||
|
normalized_gtin = self.gtin_processor.normalize(product_data.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
product_data.gtin = normalized_gtin
|
||||||
|
|
||||||
|
# Process price if provided
|
||||||
|
if product_data.price:
|
||||||
|
parsed_price, currency = self.price_processor.parse_price_currency(product_data.price)
|
||||||
|
if parsed_price:
|
||||||
|
product_data.price = parsed_price
|
||||||
|
product_data.currency = currency
|
||||||
|
|
||||||
|
# Set default marketplace if not provided
|
||||||
|
if not product_data.marketplace:
|
||||||
|
product_data.marketplace = "Letzshop"
|
||||||
|
|
||||||
|
db_product = Product(**product_data.dict())
|
||||||
|
db.add(db_product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_product)
|
||||||
|
|
||||||
|
logger.info(f"Created product {db_product.product_id}")
|
||||||
|
return db_product
|
||||||
|
|
||||||
|
def get_products_with_filters(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
brand: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
marketplace: Optional[str] = None,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> tuple[List[Product], int]:
|
||||||
|
"""Get products with filtering and pagination"""
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if brand:
|
||||||
|
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
||||||
|
if category:
|
||||||
|
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(Product.title.ilike(search_term)) |
|
||||||
|
(Product.description.ilike(search_term)) |
|
||||||
|
(Product.marketplace.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
products = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return products, total
|
||||||
|
|
||||||
|
|
||||||
|
# Create service instance
|
||||||
|
product_service = ProductService()
|
||||||
0
app/services/stock_service.py
Normal file
0
app/services/stock_service.py
Normal file
0
app/tasks/__init__.py
Normal file
0
app/tasks/__init__.py
Normal file
63
app/tasks/background_tasks.py
Normal file
63
app/tasks/background_tasks.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from models.database_models import MarketplaceImportJob
|
||||||
|
from utils.csv_processor import CSVProcessor
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_marketplace_import(
|
||||||
|
job_id: int,
|
||||||
|
url: str,
|
||||||
|
marketplace: str,
|
||||||
|
shop_name: str,
|
||||||
|
batch_size: int = 1000
|
||||||
|
):
|
||||||
|
"""Background task to process marketplace CSV import"""
|
||||||
|
db = SessionLocal()
|
||||||
|
csv_processor = CSVProcessor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update job status
|
||||||
|
job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
logger.error(f"Import job {job_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
job.status = "processing"
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Processing import: Job {job_id}, Marketplace: {marketplace}")
|
||||||
|
|
||||||
|
# Process CSV
|
||||||
|
result = await csv_processor.process_marketplace_csv_from_url(
|
||||||
|
url, marketplace, shop_name, batch_size, db
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update job with results
|
||||||
|
job.status = "completed"
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
job.imported_count = result["imported"]
|
||||||
|
job.updated_count = result["updated"]
|
||||||
|
job.error_count = result.get("errors", 0)
|
||||||
|
job.total_processed = result["total_processed"]
|
||||||
|
|
||||||
|
if result.get("errors", 0) > 0:
|
||||||
|
job.status = "completed_with_errors"
|
||||||
|
job.error_message = f"{result['errors']} rows had errors"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Import job {job_id} completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Import job {job_id} failed: {e}")
|
||||||
|
job.status = "failed"
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
job.error_message = str(e)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
493
comprehensive_readme.md
Normal file
493
comprehensive_readme.md
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
# Ecommerce Backend API with Marketplace Support
|
||||||
|
|
||||||
|
A comprehensive FastAPI-based product management system with JWT authentication, marketplace-aware CSV import/export, multi-shop support, and advanced stock management capabilities.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **JWT Authentication** - Secure user registration, login, and role-based access control
|
||||||
|
- **Marketplace Integration** - Support for multiple marketplaces (Letzshop, Amazon, eBay, Etsy, Shopify, etc.)
|
||||||
|
- **Multi-Shop Management** - Shop creation, ownership validation, and product catalog management
|
||||||
|
- **Advanced Product Management** - GTIN validation, price processing, and comprehensive filtering
|
||||||
|
- **Stock Management** - Multi-location inventory tracking with add/remove/set operations
|
||||||
|
- **CSV Import/Export** - Background processing of marketplace CSV files with progress tracking
|
||||||
|
- **Rate Limiting** - Built-in request rate limiting for API protection
|
||||||
|
- **Admin Panel** - Administrative functions for user and shop management
|
||||||
|
- **Statistics & Analytics** - Comprehensive reporting on products, marketplaces, and inventory
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **FastAPI** - Modern, fast web framework for building APIs
|
||||||
|
- **SQLAlchemy** - SQL toolkit and Object-Relational Mapping (ORM)
|
||||||
|
- **PostgreSQL** - Primary database (SQLite supported for development)
|
||||||
|
- **JWT** - JSON Web Tokens for secure authentication
|
||||||
|
- **Pydantic** - Data validation using Python type annotations
|
||||||
|
- **Pandas** - Data processing for CSV operations
|
||||||
|
- **bcrypt** - Password hashing
|
||||||
|
- **Pytest** - Comprehensive testing framework
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
letzshop_api/
|
||||||
|
├── main.py # FastAPI application entry point
|
||||||
|
├── app/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── config.py # Configuration settings
|
||||||
|
│ │ ├── database.py # Database setup
|
||||||
|
│ │ └── lifespan.py # App lifecycle management
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── deps.py # Common dependencies
|
||||||
|
│ │ ├── main.py # API router setup
|
||||||
|
│ │ └── v1/ # API version 1 routes
|
||||||
|
│ │ ├── auth.py # Authentication endpoints
|
||||||
|
│ │ ├── products.py # Product management
|
||||||
|
│ │ ├── stock.py # Stock operations
|
||||||
|
│ │ ├── shops.py # Shop management
|
||||||
|
│ │ ├── marketplace.py # Marketplace imports
|
||||||
|
│ │ ├── admin.py # Admin functions
|
||||||
|
│ │ └── stats.py # Statistics
|
||||||
|
│ ├── services/ # Business logic layer
|
||||||
|
│ └── tasks/ # Background task processing
|
||||||
|
├── models/
|
||||||
|
│ ├── database_models.py # SQLAlchemy ORM models
|
||||||
|
│ └── api_models.py # Pydantic API models
|
||||||
|
├── utils/
|
||||||
|
│ ├── data_processing.py # GTIN and price processing
|
||||||
|
│ ├── csv_processor.py # CSV import/export
|
||||||
|
│ └── database.py # Database utilities
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.py # JWT authentication
|
||||||
|
│ ├── rate_limiter.py # Rate limiting
|
||||||
|
│ ├── error_handler.py # Error handling
|
||||||
|
│ └── logging_middleware.py # Request logging
|
||||||
|
├── tests/ # Comprehensive test suite
|
||||||
|
└── requirements.txt # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd letzshop_api
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Configuration
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost/ecommerce
|
||||||
|
# For development, you can use SQLite:
|
||||||
|
# DATABASE_URL=sqlite:///./ecommerce.db
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
SECRET_KEY=your-super-secret-key-change-in-production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
ALLOWED_HOSTS=["*"]
|
||||||
|
RATE_LIMIT_REQUESTS=100
|
||||||
|
RATE_LIMIT_WINDOW=3600
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
PROJECT_NAME="Ecommerce Backend API"
|
||||||
|
VERSION="2.2.0"
|
||||||
|
DEBUG=True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Database Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The application will automatically create tables on startup
|
||||||
|
# For production, consider using Alembic for migrations
|
||||||
|
|
||||||
|
# Install PostgreSQL (if using PostgreSQL)
|
||||||
|
# Create database
|
||||||
|
createdb ecommerce
|
||||||
|
|
||||||
|
# Run the application (tables will be created automatically)
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Running the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development server
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Or using uvicorn directly
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at:
|
||||||
|
- **API Documentation**: http://localhost:8000/docs (Swagger UI)
|
||||||
|
- **Alternative Docs**: http://localhost:8000/redoc
|
||||||
|
- **Health Check**: http://localhost:8000/health
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### Register a new user
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Use JWT Token
|
||||||
|
```bash
|
||||||
|
# Get token from login response and use in subsequent requests
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/products" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Product Management
|
||||||
|
|
||||||
|
#### Create a product
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/products" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"product_id": "PROD001",
|
||||||
|
"title": "Amazing Product",
|
||||||
|
"description": "An amazing product description",
|
||||||
|
"price": "29.99",
|
||||||
|
"currency": "EUR",
|
||||||
|
"brand": "BrandName",
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"availability": "in stock",
|
||||||
|
"marketplace": "Letzshop",
|
||||||
|
"shop_name": "MyShop"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get products with filtering
|
||||||
|
```bash
|
||||||
|
# Get all products
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/products" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Filter by marketplace
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/products?marketplace=Amazon&limit=50" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Search products
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/products?search=Amazing&brand=BrandName" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stock Management
|
||||||
|
|
||||||
|
#### Set stock quantity
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/stock" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 100
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Add stock
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/stock/add" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 25
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check stock levels
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/stock/1234567890123" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marketplace Import
|
||||||
|
|
||||||
|
#### Import products from CSV
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/marketplace/import-from-marketplace" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"url": "https://example.com/products.csv",
|
||||||
|
"marketplace": "Amazon",
|
||||||
|
"shop_code": "MYSHOP",
|
||||||
|
"batch_size": 1000
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check import status
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/marketplace/marketplace-import-status/1" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Data
|
||||||
|
|
||||||
|
#### Export products to CSV
|
||||||
|
```bash
|
||||||
|
# Export all products
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/export-csv" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-o products_export.csv
|
||||||
|
|
||||||
|
# Export with filters
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/export-csv?marketplace=Amazon&shop_name=MyShop" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-o amazon_products.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSV Import Format
|
||||||
|
|
||||||
|
The system supports CSV imports with the following headers:
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
- `product_id` - Unique product identifier
|
||||||
|
- `title` - Product title
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
- `description` - Product description
|
||||||
|
- `link` - Product URL
|
||||||
|
- `image_link` - Product image URL
|
||||||
|
- `availability` - Stock availability (in stock, out of stock, preorder)
|
||||||
|
- `price` - Product price
|
||||||
|
- `currency` - Price currency (EUR, USD, etc.)
|
||||||
|
- `brand` - Product brand
|
||||||
|
- `gtin` - Global Trade Item Number (EAN/UPC)
|
||||||
|
- `google_product_category` - Product category
|
||||||
|
- `marketplace` - Source marketplace
|
||||||
|
- `shop_name` - Shop/seller name
|
||||||
|
|
||||||
|
### Example CSV
|
||||||
|
```csv
|
||||||
|
product_id,title,description,price,currency,brand,gtin,marketplace,shop_name
|
||||||
|
PROD001,"Amazing Widget","The best widget ever",29.99,EUR,WidgetCorp,1234567890123,Letzshop,MyShop
|
||||||
|
PROD002,"Super Gadget","A fantastic gadget",19.99,EUR,GadgetInc,9876543210987,Amazon,TechStore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
```bash
|
||||||
|
# Install test dependencies
|
||||||
|
pip install -r tests/requirements_test.txt
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
pytest tests/ --cov=app --cov=models --cov=utils --cov-report=html
|
||||||
|
|
||||||
|
# Run specific test categories
|
||||||
|
pytest tests/ -m unit -v # Unit tests only
|
||||||
|
pytest tests/ -m integration -v # Integration tests only
|
||||||
|
pytest tests/ -m "not slow" -v # Fast tests only
|
||||||
|
|
||||||
|
# Run specific test files
|
||||||
|
pytest tests/test_auth.py -v # Authentication tests
|
||||||
|
pytest tests/test_products.py -v # Product tests
|
||||||
|
pytest tests/test_stock.py -v # Stock management tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
The test suite includes:
|
||||||
|
- **Unit Tests** - Individual component testing
|
||||||
|
- **Integration Tests** - Full workflow testing
|
||||||
|
- **Security Tests** - Authentication, authorization, input validation
|
||||||
|
- **Performance Tests** - Load testing with large datasets
|
||||||
|
- **Error Handling Tests** - Edge cases and error conditions
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
- `POST /api/v1/auth/register` - Register new user
|
||||||
|
- `POST /api/v1/auth/login` - Login user
|
||||||
|
- `GET /api/v1/auth/me` - Get current user info
|
||||||
|
|
||||||
|
### Product Endpoints
|
||||||
|
- `GET /api/v1/products` - List products with filtering
|
||||||
|
- `POST /api/v1/products` - Create new product
|
||||||
|
- `GET /api/v1/products/{product_id}` - Get specific product
|
||||||
|
- `PUT /api/v1/products/{product_id}` - Update product
|
||||||
|
- `DELETE /api/v1/products/{product_id}` - Delete product
|
||||||
|
|
||||||
|
### Stock Endpoints
|
||||||
|
- `POST /api/v1/stock` - Set stock quantity
|
||||||
|
- `POST /api/v1/stock/add` - Add to stock
|
||||||
|
- `POST /api/v1/stock/remove` - Remove from stock
|
||||||
|
- `GET /api/v1/stock/{gtin}` - Get stock by GTIN
|
||||||
|
- `GET /api/v1/stock/{gtin}/total` - Get total stock
|
||||||
|
- `GET /api/v1/stock` - List all stock entries
|
||||||
|
|
||||||
|
### Shop Endpoints
|
||||||
|
- `POST /api/v1/shops` - Create new shop
|
||||||
|
- `GET /api/v1/shops` - List shops
|
||||||
|
- `GET /api/v1/shops/{shop_code}` - Get specific shop
|
||||||
|
|
||||||
|
### Marketplace Endpoints
|
||||||
|
- `POST /api/v1/marketplace/import-from-marketplace` - Start CSV import
|
||||||
|
- `GET /api/v1/marketplace/marketplace-import-status/{job_id}` - Check import status
|
||||||
|
- `GET /api/v1/marketplace/marketplace-import-jobs` - List import jobs
|
||||||
|
|
||||||
|
### Statistics Endpoints
|
||||||
|
- `GET /api/v1/stats` - Get general statistics
|
||||||
|
- `GET /api/v1/stats/marketplace-stats` - Get marketplace statistics
|
||||||
|
|
||||||
|
### Admin Endpoints (Admin only)
|
||||||
|
- `GET /api/v1/admin/users` - List all users
|
||||||
|
- `PUT /api/v1/admin/users/{user_id}/status` - Toggle user status
|
||||||
|
- `GET /api/v1/admin/shops` - List all shops
|
||||||
|
- `PUT /api/v1/admin/shops/{shop_id}/verify` - Verify/unverify shop
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
- **users** - User accounts and authentication
|
||||||
|
- **products** - Product catalog with marketplace info
|
||||||
|
- **stock** - Inventory tracking by location and GTIN
|
||||||
|
- **shops** - Shop/seller information
|
||||||
|
- **shop_products** - Shop-specific product settings
|
||||||
|
- **marketplace_import_jobs** - Background import job tracking
|
||||||
|
|
||||||
|
### Key Relationships
|
||||||
|
- Users own shops (one-to-many)
|
||||||
|
- Products belong to marketplaces and shops
|
||||||
|
- Stock entries are linked to products via GTIN
|
||||||
|
- Import jobs track user-initiated imports
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **JWT Authentication** - Secure token-based authentication
|
||||||
|
- **Password Hashing** - bcrypt for secure password storage
|
||||||
|
- **Role-Based Access** - User and admin role separation
|
||||||
|
- **Rate Limiting** - Protection against API abuse
|
||||||
|
- **Input Validation** - Comprehensive data validation
|
||||||
|
- **SQL Injection Protection** - Parameterized queries
|
||||||
|
- **CORS Configuration** - Cross-origin request handling
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
- **Database Indexing** - Strategic indexes on key columns
|
||||||
|
- **Pagination** - Efficient data retrieval with skip/limit
|
||||||
|
- **Streaming Responses** - Memory-efficient CSV exports
|
||||||
|
- **Background Processing** - Async import job handling
|
||||||
|
- **Connection Pooling** - Efficient database connections
|
||||||
|
- **Query Optimization** - Optimized database queries
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Considerations
|
||||||
|
- Use PostgreSQL for production database
|
||||||
|
- Set strong SECRET_KEY in environment
|
||||||
|
- Configure proper CORS settings
|
||||||
|
- Enable HTTPS
|
||||||
|
- Set up monitoring and logging
|
||||||
|
- Use a reverse proxy (nginx)
|
||||||
|
- Configure database connection pooling
|
||||||
|
- Set up backup strategies
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests for new functionality
|
||||||
|
5. Ensure all tests pass
|
||||||
|
6. Submit a pull request
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
```bash
|
||||||
|
# Install development dependencies
|
||||||
|
pip install -r requirements_dev.txt
|
||||||
|
|
||||||
|
# Run pre-commit hooks
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
flake8 app/ models/ utils/
|
||||||
|
black app/ models/ utils/
|
||||||
|
|
||||||
|
# Run type checking
|
||||||
|
mypy app/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support & Documentation
|
||||||
|
|
||||||
|
- **API Documentation**: http://localhost:8000/docs
|
||||||
|
- **Health Check**: http://localhost:8000/health
|
||||||
|
- **Version Info**: http://localhost:8000/
|
||||||
|
|
||||||
|
For issues and feature requests, please create an issue in the repository.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Specify your license here]
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v2.2.0
|
||||||
|
- Added marketplace-aware product import
|
||||||
|
- Implemented multi-shop support
|
||||||
|
- Enhanced stock management with location tracking
|
||||||
|
- Added comprehensive test suite
|
||||||
|
- Improved authentication and authorization
|
||||||
|
|
||||||
|
### v2.1.0
|
||||||
|
- Added JWT authentication
|
||||||
|
- Implemented role-based access control
|
||||||
|
- Added CSV import/export functionality
|
||||||
|
|
||||||
|
### v2.0.0
|
||||||
|
- Complete rewrite with FastAPI
|
||||||
|
- Added PostgreSQL support
|
||||||
|
- Implemented comprehensive API documentation
|
||||||
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
|
|||||||
# JWT
|
# JWT
|
||||||
jwt_secret_key: str = "change-this-in-production"
|
jwt_secret_key: str = "change-this-in-production"
|
||||||
jwt_expire_hours: int = 24
|
jwt_expire_hours: int = 24
|
||||||
|
jwt_expire_minutes: int = 30
|
||||||
|
|
||||||
# API
|
# API
|
||||||
api_host: str = "0.0.0.0"
|
api_host: str = "0.0.0.0"
|
||||||
|
|||||||
29
middleware/decorators.py
Normal file
29
middleware/decorators.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# middleware/decorators.py
|
||||||
|
from functools import wraps
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from middleware.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
# Initialize rate limiter instance
|
||||||
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
|
||||||
|
"""Rate limiting decorator for FastAPI endpoints"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Extract client IP or user ID for rate limiting
|
||||||
|
client_id = "anonymous" # In production, extract from request
|
||||||
|
|
||||||
|
if not rate_limiter.allow_request(client_id, max_requests, window_seconds):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Rate limit exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
57
tests/Makefile
Normal file
57
tests/Makefile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Makefile for running tests
|
||||||
|
# tests/Makefile
|
||||||
|
.PHONY: test test-unit test-integration test-coverage test-fast test-slow
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test:
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Run only unit tests
|
||||||
|
test-unit:
|
||||||
|
pytest tests/ -v -m unit
|
||||||
|
|
||||||
|
# Run only integration tests
|
||||||
|
test-integration:
|
||||||
|
pytest tests/ -v -m integration
|
||||||
|
|
||||||
|
# Run tests with coverage report
|
||||||
|
test-coverage:
|
||||||
|
pytest tests/ --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run fast tests only (exclude slow ones)
|
||||||
|
test-fast:
|
||||||
|
pytest tests/ -v -m "not slow"
|
||||||
|
|
||||||
|
# Run slow tests only
|
||||||
|
test-slow:
|
||||||
|
pytest tests/ -v -m slow
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
test-auth:
|
||||||
|
pytest tests/test_auth.py -v
|
||||||
|
|
||||||
|
test-products:
|
||||||
|
pytest tests/test_products.py -v
|
||||||
|
|
||||||
|
test-stock:
|
||||||
|
pytest tests/test_stock.py -v
|
||||||
|
|
||||||
|
# Clean up test artifacts
|
||||||
|
clean:
|
||||||
|
rm -rf htmlcov/
|
||||||
|
rm -rf .pytest_cache/
|
||||||
|
rm -rf .coverage
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
|
find . -name "*.pyc" -delete
|
||||||
|
|
||||||
|
# Install test dependencies
|
||||||
|
install-test-deps:
|
||||||
|
pip install -r tests/requirements_test.txtvalidate_csv_headers(valid_df) == True
|
||||||
|
|
||||||
|
# Invalid headers (missing required fields)
|
||||||
|
invalid_df = pd.DataFrame({
|
||||||
|
"id": ["TEST001"], # Wrong column name
|
||||||
|
"name": ["Test"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert self.processor._
|
||||||
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# tests/__init__.py
|
||||||
|
# This file makes the tests directory a Python package
|
||||||
194
tests/conftest.py
Normal file
194
tests/conftest.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# tests/conftest.py
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from app.core.database import get_db, Base
|
||||||
|
from models.database_models import User, Product, Stock, Shop
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
# Use in-memory SQLite database for tests
|
||||||
|
SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def engine():
|
||||||
|
"""Create test database engine"""
|
||||||
|
return create_engine(
|
||||||
|
SQLALCHEMY_TEST_DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
echo=False # Set to True for SQL debugging
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def testing_session_local(engine):
|
||||||
|
"""Create session factory for tests"""
|
||||||
|
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def db(engine, testing_session_local):
|
||||||
|
"""Create a fresh database for each test"""
|
||||||
|
# Create all tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
db = testing_session_local()
|
||||||
|
|
||||||
|
# Override the dependency
|
||||||
|
def override_get_db():
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
pass # Don't close here, we'll close in cleanup
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.rollback() # Rollback any uncommitted changes
|
||||||
|
db.close()
|
||||||
|
# Clean up the dependency override
|
||||||
|
if get_db in app.dependency_overrides:
|
||||||
|
del app.dependency_overrides[get_db]
|
||||||
|
# Drop all tables for next test
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def client(db):
|
||||||
|
"""Create a test client with database dependency override"""
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def auth_manager():
|
||||||
|
"""Create auth manager instance (session scope since it's stateless)"""
|
||||||
|
return AuthManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(db, auth_manager):
|
||||||
|
"""Create a test user"""
|
||||||
|
hashed_password = auth_manager.hash_password("testpass123")
|
||||||
|
user = User(
|
||||||
|
email="test@example.com",
|
||||||
|
username="testuser",
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
role="user",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_admin(db, auth_manager):
|
||||||
|
"""Create a test admin user"""
|
||||||
|
hashed_password = auth_manager.hash_password("adminpass123")
|
||||||
|
admin = User(
|
||||||
|
email="admin@example.com",
|
||||||
|
username="admin",
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
role="admin",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(client, test_user):
|
||||||
|
"""Get authentication headers for test user"""
|
||||||
|
response = client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpass123"
|
||||||
|
})
|
||||||
|
assert response.status_code == 200, f"Login failed: {response.text}"
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_headers(client, test_admin):
|
||||||
|
"""Get authentication headers for admin user"""
|
||||||
|
response = client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "adminpass123"
|
||||||
|
})
|
||||||
|
assert response.status_code == 200, f"Admin login failed: {response.text}"
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_product(db):
|
||||||
|
"""Create a test product"""
|
||||||
|
product = Product(
|
||||||
|
product_id="TEST001",
|
||||||
|
title="Test Product",
|
||||||
|
description="A test product",
|
||||||
|
price="10.99",
|
||||||
|
currency="EUR",
|
||||||
|
brand="TestBrand",
|
||||||
|
gtin="1234567890123",
|
||||||
|
availability="in stock",
|
||||||
|
marketplace="Letzshop",
|
||||||
|
shop_name="TestShop"
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_shop(db, test_user):
|
||||||
|
"""Create a test shop"""
|
||||||
|
shop = Shop(
|
||||||
|
shop_code="TESTSHOP",
|
||||||
|
shop_name="Test Shop",
|
||||||
|
owner_id=test_user.id,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True
|
||||||
|
)
|
||||||
|
db.add(shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
return shop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_stock(db, test_product, test_shop):
|
||||||
|
"""Create test stock entry"""
|
||||||
|
stock = Stock(
|
||||||
|
product_id=test_product.product_id,
|
||||||
|
shop_code=test_shop.shop_code,
|
||||||
|
quantity=10,
|
||||||
|
reserved_quantity=0
|
||||||
|
)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stock)
|
||||||
|
return stock
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup fixture to ensure clean state
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def cleanup():
|
||||||
|
"""Automatically clean up after each test"""
|
||||||
|
yield
|
||||||
|
# Clear any remaining dependency overrides
|
||||||
|
app.dependency_overrides.clear()
|
||||||
21
tests/pytest.ini
Normal file
21
tests/pytest.ini
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# tests/pytest.ini
|
||||||
|
[tool:pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--tb=short
|
||||||
|
--strict-markers
|
||||||
|
--disable-warnings
|
||||||
|
--color=yes
|
||||||
|
markers =
|
||||||
|
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||||
|
integration: marks tests as integration tests
|
||||||
|
unit: marks tests as unit tests
|
||||||
|
auth: marks tests related to authentication
|
||||||
|
products: marks tests related to products
|
||||||
|
stock: marks tests related to stock management
|
||||||
|
shops: marks tests related to shop management
|
||||||
|
admin: marks tests related to admin functionality
|
||||||
8
tests/requirements_test.txt
Normal file
8
tests/requirements_test.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# tests/requirements_test.txt
|
||||||
|
# Testing dependencies
|
||||||
|
pytest>=7.4.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
pytest-asyncio>=0.21.0
|
||||||
|
pytest-mock>=3.11.0
|
||||||
|
httpx>=0.24.0
|
||||||
|
faker>=19.0.0
|
||||||
34
tests/test_admin.py
Normal file
34
tests/test_admin.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# tests/test_admin.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminAPI:
|
||||||
|
def test_get_all_users_admin(self, client, admin_headers, test_user):
|
||||||
|
"""Test admin getting all users"""
|
||||||
|
response = client.get("/api/v1/admin/users", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 2 # test_user + admin user
|
||||||
|
|
||||||
|
def test_get_all_users_non_admin(self, client, auth_headers):
|
||||||
|
"""Test non-admin trying to access admin endpoint"""
|
||||||
|
response = client.get("/api/v1/admin/users", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "Access denied" in response.json()["detail"] or "admin" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_toggle_user_status_admin(self, client, admin_headers, test_user):
|
||||||
|
"""Test admin toggling user status"""
|
||||||
|
response = client.put(f"/api/v1/admin/users/{test_user.id}/status", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "deactivated" in response.json()["message"] or "activated" in response.json()["message"]
|
||||||
|
|
||||||
|
def test_get_all_shops_admin(self, client, admin_headers, test_shop):
|
||||||
|
"""Test admin getting all shops"""
|
||||||
|
response = client.get("/api/v1/admin/shops", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
119
tests/test_auth.py
Normal file
119
tests/test_auth.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# tests/test_auth.py
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthenticationAPI:
|
||||||
|
def test_register_user_success(self, client, db):
|
||||||
|
"""Test successful user registration"""
|
||||||
|
response = client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "newuser@example.com",
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "securepass123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["email"] == "newuser@example.com"
|
||||||
|
assert data["username"] == "newuser"
|
||||||
|
assert data["role"] == "user"
|
||||||
|
assert data["is_active"] == True
|
||||||
|
assert "hashed_password" not in data
|
||||||
|
|
||||||
|
def test_register_user_duplicate_email(self, client, test_user):
|
||||||
|
"""Test registration with duplicate email"""
|
||||||
|
response = client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "test@example.com", # Same as test_user
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "securepass123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Email already registered" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_register_user_duplicate_username(self, client, test_user):
|
||||||
|
"""Test registration with duplicate username"""
|
||||||
|
response = client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "new@example.com",
|
||||||
|
"username": "testuser", # Same as test_user
|
||||||
|
"password": "securepass123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Username already taken" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_success(self, client, test_user):
|
||||||
|
"""Test successful login"""
|
||||||
|
response = client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpass123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert "expires_in" in data
|
||||||
|
assert data["user"]["username"] == "testuser"
|
||||||
|
|
||||||
|
def test_login_wrong_password(self, client, test_user):
|
||||||
|
"""Test login with wrong password"""
|
||||||
|
response = client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "wrongpassword"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Incorrect username or password" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_nonexistent_user(self, client):
|
||||||
|
"""Test login with nonexistent user"""
|
||||||
|
response = client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "nonexistent",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_current_user_info(self, client, auth_headers):
|
||||||
|
"""Test getting current user info"""
|
||||||
|
response = client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["username"] == "testuser"
|
||||||
|
assert data["email"] == "test@example.com"
|
||||||
|
|
||||||
|
def test_get_current_user_no_auth(self, client):
|
||||||
|
"""Test getting current user without authentication"""
|
||||||
|
response = client.get("/api/v1/auth/me")
|
||||||
|
|
||||||
|
assert response.status_code == 403 # No authorization header
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthManager:
|
||||||
|
def test_hash_password(self, auth_manager):
|
||||||
|
"""Test password hashing"""
|
||||||
|
password = "testpassword123"
|
||||||
|
hashed = auth_manager.hash_password(password)
|
||||||
|
|
||||||
|
assert hashed != password
|
||||||
|
assert len(hashed) > 20 # bcrypt hashes are long
|
||||||
|
|
||||||
|
def test_verify_password(self, auth_manager):
|
||||||
|
"""Test password verification"""
|
||||||
|
password = "testpassword123"
|
||||||
|
hashed = auth_manager.hash_password(password)
|
||||||
|
|
||||||
|
assert auth_manager.verify_password(password, hashed) == True
|
||||||
|
assert auth_manager.verify_password("wrongpassword", hashed) == False
|
||||||
|
|
||||||
|
def test_create_access_token(self, auth_manager, test_user):
|
||||||
|
"""Test JWT token creation"""
|
||||||
|
token_data = auth_manager.create_access_token(test_user)
|
||||||
|
|
||||||
|
assert "access_token" in token_data
|
||||||
|
assert token_data["token_type"] == "bearer"
|
||||||
|
assert "expires_in" in token_data
|
||||||
|
assert isinstance(token_data["expires_in"], int)
|
||||||
|
|
||||||
83
tests/test_background_tasks.py
Normal file
83
tests/test_background_tasks.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# tests/test_background_tasks.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
|
from models.database_models import MarketplaceImportJob
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundTasks:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_marketplace_import_success(self, db):
|
||||||
|
"""Test successful marketplace import background task"""
|
||||||
|
# Create import job
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
source_url="http://example.com/test.csv",
|
||||||
|
marketplace="TestMarket",
|
||||||
|
shop_code="TESTSHOP",
|
||||||
|
user_id=1
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
# Mock CSV processor
|
||||||
|
with patch('app.tasks.background_tasks.CSVProcessor') as mock_processor:
|
||||||
|
mock_instance = mock_processor.return_value
|
||||||
|
mock_instance.process_marketplace_csv_from_url = AsyncMock(return_value={
|
||||||
|
"imported": 10,
|
||||||
|
"updated": 5,
|
||||||
|
"total_processed": 15,
|
||||||
|
"errors": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Run background task
|
||||||
|
await process_marketplace_import(
|
||||||
|
job.id,
|
||||||
|
"http://example.com/test.csv",
|
||||||
|
"TestMarket",
|
||||||
|
"TESTSHOP",
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify job was updated
|
||||||
|
db.refresh(job)
|
||||||
|
assert job.status == "completed"
|
||||||
|
assert job.imported_count == 10
|
||||||
|
assert job.updated_count == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_marketplace_import_failure(self, db):
|
||||||
|
"""Test marketplace import failure handling"""
|
||||||
|
# Create import job
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
source_url="http://example.com/test.csv",
|
||||||
|
marketplace="TestMarket",
|
||||||
|
shop_code="TESTSHOP",
|
||||||
|
user_id=1
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
# Mock CSV processor to raise exception
|
||||||
|
with patch('app.tasks.background_tasks.CSVProcessor') as mock_processor:
|
||||||
|
mock_instance = mock_processor.return_value
|
||||||
|
mock_instance.process_marketplace_csv_from_url = AsyncMock(
|
||||||
|
side_effect=Exception("Import failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run background task
|
||||||
|
await process_marketplace_import(
|
||||||
|
job.id,
|
||||||
|
"http://example.com/test.csv",
|
||||||
|
"TestMarket",
|
||||||
|
"TESTSHOP",
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify job failure was recorded
|
||||||
|
db.refresh(job)
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert "Import failed" in job.error_message
|
||||||
90
tests/test_csv_processor.py
Normal file
90
tests/test_csv_processor.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# tests/test_csv_processor.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock
|
||||||
|
from io import StringIO
|
||||||
|
import pandas as pd
|
||||||
|
from utils.csv_processor import CSVProcessor
|
||||||
|
|
||||||
|
|
||||||
|
class TestCSVProcessor:
|
||||||
|
def setup_method(self):
|
||||||
|
self.processor = CSVProcessor()
|
||||||
|
|
||||||
|
@patch('requests.get')
|
||||||
|
def test_download_csv_success(self, mock_get):
|
||||||
|
"""Test successful CSV download"""
|
||||||
|
# Mock successful HTTP response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.text = "product_id,title,price\nTEST001,Test Product,10.99"
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
csv_content = self.processor._download_csv("http://example.com/test.csv")
|
||||||
|
|
||||||
|
assert "product_id,title,price" in csv_content
|
||||||
|
assert "TEST001,Test Product,10.99" in csv_content
|
||||||
|
|
||||||
|
@patch('requests.get')
|
||||||
|
def test_download_csv_failure(self, mock_get):
|
||||||
|
"""Test CSV download failure"""
|
||||||
|
# Mock failed HTTP response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
self.processor._download_csv("http://example.com/nonexistent.csv")
|
||||||
|
|
||||||
|
def test_parse_csv_content(self):
|
||||||
|
"""Test CSV content parsing"""
|
||||||
|
csv_content = """product_id,title,price,marketplace
|
||||||
|
TEST001,Test Product 1,10.99,TestMarket
|
||||||
|
TEST002,Test Product 2,15.99,TestMarket"""
|
||||||
|
|
||||||
|
df = self.processor._parse_csv_content(csv_content)
|
||||||
|
|
||||||
|
assert len(df) == 2
|
||||||
|
assert "product_id" in df.columns
|
||||||
|
assert df.iloc[0]["product_id"] == "TEST001"
|
||||||
|
assert df.iloc[1]["price"] == "15.99"
|
||||||
|
|
||||||
|
def test_validate_csv_headers(self):
|
||||||
|
"""Test CSV header validation"""
|
||||||
|
# Valid headers
|
||||||
|
valid_df = pd.DataFrame({
|
||||||
|
"product_id": ["TEST001"],
|
||||||
|
"title": ["Test"],
|
||||||
|
"price": ["10.99"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert self.processor._validate_csv_headers(invalid_df) == False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_marketplace_csv_from_url(self, db):
|
||||||
|
"""Test complete marketplace CSV processing"""
|
||||||
|
with patch.object(self.processor, '_download_csv') as mock_download, \
|
||||||
|
patch.object(self.processor, '_parse_csv_content') as mock_parse, \
|
||||||
|
patch.object(self.processor, '_validate_csv_headers') as mock_validate:
|
||||||
|
# Mock successful download and parsing
|
||||||
|
mock_download.return_value = "csv_content"
|
||||||
|
mock_df = pd.DataFrame({
|
||||||
|
"product_id": ["TEST001", "TEST002"],
|
||||||
|
"title": ["Product 1", "Product 2"],
|
||||||
|
"price": ["10.99", "15.99"],
|
||||||
|
"marketplace": ["TestMarket", "TestMarket"],
|
||||||
|
"shop_name": ["TestShop", "TestShop"]
|
||||||
|
})
|
||||||
|
mock_parse.return_value = mock_df
|
||||||
|
mock_validate.return_value = True
|
||||||
|
|
||||||
|
result = await self.processor.process_marketplace_csv_from_url(
|
||||||
|
"http://example.com/test.csv",
|
||||||
|
"TestMarket",
|
||||||
|
"TestShop",
|
||||||
|
1000,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "imported" in result
|
||||||
|
assert "updated" in result
|
||||||
|
assert "total_processed" in result
|
||||||
46
tests/test_data_validation.py
Normal file
46
tests/test_data_validation.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# tests/test_data_validation.py
|
||||||
|
import pytest
|
||||||
|
from utils.data_processing import GTINProcessor, PriceProcessor
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataValidation:
|
||||||
|
def test_gtin_normalization_edge_cases(self):
|
||||||
|
"""Test GTIN normalization with edge cases"""
|
||||||
|
processor = GTINProcessor()
|
||||||
|
|
||||||
|
# Test with leading zeros
|
||||||
|
assert processor.normalize("000123456789") == "000123456789"
|
||||||
|
|
||||||
|
# Test with spaces
|
||||||
|
assert processor.normalize("123 456 789 012") == "123456789012"
|
||||||
|
|
||||||
|
# Test with dashes
|
||||||
|
assert processor.normalize("123-456-789-012") == "123456789012"
|
||||||
|
|
||||||
|
# Test very long numbers
|
||||||
|
long_number = "1234567890123456789"
|
||||||
|
normalized = processor.normalize(long_number)
|
||||||
|
assert len(normalized) <= 14 # Should be truncated
|
||||||
|
|
||||||
|
def test_price_parsing_edge_cases(self):
|
||||||
|
"""Test price parsing with edge cases"""
|
||||||
|
processor = PriceProcessor()
|
||||||
|
|
||||||
|
# Test with multiple decimal places
|
||||||
|
price, currency = processor.parse_price_currency("12.999 EUR")
|
||||||
|
assert price == "12.999"
|
||||||
|
|
||||||
|
# Test with no currency
|
||||||
|
price, currency = processor.parse_price_currency("15.50")
|
||||||
|
assert price == "15.50"
|
||||||
|
|
||||||
|
# Test with unusual formatting
|
||||||
|
price, currency = processor.parse_price_currency("EUR 25,50")
|
||||||
|
assert currency == "EUR"
|
||||||
|
assert price == "25.50" # Comma should be converted to dot
|
||||||
|
|
||||||
|
def test_input_sanitization(self):
|
||||||
|
"""Test input sanitization"""
|
||||||
|
# These tests would verify that inputs are properly sanitized
|
||||||
|
# to prevent SQL injection, XSS, etc.
|
||||||
|
pass # Implementation would depend on your sanitization logic
|
||||||
98
tests/test_database.py
Normal file
98
tests/test_database.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# tests/test_database.py
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from models.database_models import User, Product, Stock, Shop
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseModels:
|
||||||
|
def test_user_model(self, db):
|
||||||
|
"""Test User model creation and relationships"""
|
||||||
|
user = User(
|
||||||
|
email="db_test@example.com",
|
||||||
|
username="dbtest",
|
||||||
|
hashed_password="hashed_password_123",
|
||||||
|
role="user",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.email == "db_test@example.com"
|
||||||
|
assert user.created_at is not None
|
||||||
|
assert user.updated_at is not None
|
||||||
|
|
||||||
|
def test_product_model(self, db):
|
||||||
|
"""Test Product model creation"""
|
||||||
|
product = Product(
|
||||||
|
product_id="DB_TEST_001",
|
||||||
|
title="Database Test Product",
|
||||||
|
description="Testing product model",
|
||||||
|
price="25.99",
|
||||||
|
currency="USD",
|
||||||
|
brand="DBTest",
|
||||||
|
gtin="1234567890123",
|
||||||
|
availability="in stock",
|
||||||
|
marketplace="TestDB",
|
||||||
|
shop_name="DBTestShop"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
|
||||||
|
assert product.id is not None
|
||||||
|
assert product.product_id == "DB_TEST_001"
|
||||||
|
assert product.created_at is not None
|
||||||
|
|
||||||
|
def test_stock_model(self, db):
|
||||||
|
"""Test Stock model creation"""
|
||||||
|
stock = Stock(
|
||||||
|
gtin="1234567890123",
|
||||||
|
location="DB_WAREHOUSE",
|
||||||
|
quantity=150
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stock)
|
||||||
|
|
||||||
|
assert stock.id is not None
|
||||||
|
assert stock.gtin == "1234567890123"
|
||||||
|
assert stock.location == "DB_WAREHOUSE"
|
||||||
|
assert stock.quantity == 150
|
||||||
|
|
||||||
|
def test_shop_model_with_owner(self, db, test_user):
|
||||||
|
"""Test Shop model with owner relationship"""
|
||||||
|
shop = Shop(
|
||||||
|
shop_code="DBTEST",
|
||||||
|
shop_name="Database Test Shop",
|
||||||
|
description="Testing shop model",
|
||||||
|
owner_id=test_user.id,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=False
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
|
||||||
|
assert shop.id is not None
|
||||||
|
assert shop.shop_code == "DBTEST"
|
||||||
|
assert shop.owner_id == test_user.id
|
||||||
|
assert shop.owner.username == test_user.username
|
||||||
|
|
||||||
|
def test_database_constraints(self, db):
|
||||||
|
"""Test database constraints and unique indexes"""
|
||||||
|
# Test unique product_id constraint
|
||||||
|
product1 = Product(product_id="UNIQUE_001", title="Product 1")
|
||||||
|
db.add(product1)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# This should raise an integrity error
|
||||||
|
with pytest.raises(Exception): # Could be IntegrityError or similar
|
||||||
|
product2 = Product(product_id="UNIQUE_001", title="Product 2")
|
||||||
|
db.add(product2)
|
||||||
|
db.commit()
|
||||||
45
tests/test_error_handling.py
Normal file
45
tests/test_error_handling.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# tests/test_error_handling.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
def test_invalid_json(self, client, auth_headers):
|
||||||
|
"""Test handling of invalid JSON"""
|
||||||
|
response = client.post("/api/v1/products",
|
||||||
|
headers=auth_headers,
|
||||||
|
data="invalid json")
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
def test_missing_required_fields(self, client, auth_headers):
|
||||||
|
"""Test handling of missing required fields"""
|
||||||
|
response = client.post("/api/v1/products",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"title": "Test"}) # Missing product_id
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_invalid_authentication(self, client):
|
||||||
|
"""Test handling of invalid authentication"""
|
||||||
|
response = client.get("/api/v1/products",
|
||||||
|
headers={"Authorization": "Bearer invalid_token"})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_nonexistent_resource(self, client, auth_headers):
|
||||||
|
"""Test handling of nonexistent resource access"""
|
||||||
|
response = client.get("/api/v1/products/NONEXISTENT", headers=auth_headers)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
response = client.get("/api/v1/shops/NONEXISTENT", headers=auth_headers)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_duplicate_resource_creation(self, client, auth_headers, test_product):
|
||||||
|
"""Test handling of duplicate resource creation"""
|
||||||
|
product_data = {
|
||||||
|
"product_id": test_product.product_id, # Duplicate ID
|
||||||
|
"title": "Another Product"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
assert response.status_code == 400
|
||||||
65
tests/test_export.py
Normal file
65
tests/test_export.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# tests/test_export.py
|
||||||
|
import pytest
|
||||||
|
import csv
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportFunctionality:
|
||||||
|
def test_csv_export_basic(self, client, auth_headers, test_product):
|
||||||
|
"""Test basic CSV export functionality"""
|
||||||
|
response = client.get("/api/v1/export-csv", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
# Parse CSV content
|
||||||
|
csv_content = response.content.decode('utf-8')
|
||||||
|
csv_reader = csv.reader(StringIO(csv_content))
|
||||||
|
|
||||||
|
# Check header row
|
||||||
|
header = next(csv_reader)
|
||||||
|
expected_fields = ["product_id", "title", "description", "price", "marketplace"]
|
||||||
|
for field in expected_fields:
|
||||||
|
assert field in header
|
||||||
|
|
||||||
|
def test_csv_export_with_marketplace_filter(self, client, auth_headers, db):
|
||||||
|
"""Test CSV export with marketplace filtering"""
|
||||||
|
# Create products in different marketplaces
|
||||||
|
products = [
|
||||||
|
Product(product_id="EXP1", title="Product 1", marketplace="Amazon"),
|
||||||
|
Product(product_id="EXP2", title="Product 2", marketplace="eBay"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/export-csv?marketplace=Amazon", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
csv_content = response.content.decode('utf-8')
|
||||||
|
assert "EXP1" in csv_content
|
||||||
|
assert "EXP2" not in csv_content # Should be filtered out
|
||||||
|
|
||||||
|
def test_csv_export_performance(self, client, auth_headers, db):
|
||||||
|
"""Test CSV export performance with many products"""
|
||||||
|
# Create many products
|
||||||
|
products = []
|
||||||
|
for i in range(1000):
|
||||||
|
product = Product(
|
||||||
|
product_id=f"PERF{i:04d}",
|
||||||
|
title=f"Performance Product {i}",
|
||||||
|
marketplace="Performance"
|
||||||
|
)
|
||||||
|
products.append(product)
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
response = client.get("/api/v1/export-csv", headers=auth_headers)
|
||||||
|
end_time = time.time()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert end_time - start_time < 10.0 # Should complete within 10 seconds
|
||||||
|
|
||||||
85
tests/test_filtering.py
Normal file
85
tests/test_filtering.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# tests/test_filtering.py
|
||||||
|
import pytest
|
||||||
|
from models.database_models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiltering:
|
||||||
|
def test_product_brand_filter(self, client, auth_headers, db):
|
||||||
|
"""Test filtering products by brand"""
|
||||||
|
# Create products with different brands
|
||||||
|
products = [
|
||||||
|
Product(product_id="BRAND1", title="Product 1", brand="BrandA"),
|
||||||
|
Product(product_id="BRAND2", title="Product 2", brand="BrandB"),
|
||||||
|
Product(product_id="BRAND3", title="Product 3", brand="BrandA"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Filter by BrandA
|
||||||
|
response = client.get("/api/v1/products?brand=BrandA", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
|
# Filter by BrandB
|
||||||
|
response = client.get("/api/v1/products?brand=BrandB", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
def test_product_marketplace_filter(self, client, auth_headers, db):
|
||||||
|
"""Test filtering products by marketplace"""
|
||||||
|
products = [
|
||||||
|
Product(product_id="MKT1", title="Product 1", marketplace="Amazon"),
|
||||||
|
Product(product_id="MKT2", title="Product 2", marketplace="eBay"),
|
||||||
|
Product(product_id="MKT3", title="Product 3", marketplace="Amazon"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/products?marketplace=Amazon", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
|
def test_product_search_filter(self, client, auth_headers, db):
|
||||||
|
"""Test searching products by text"""
|
||||||
|
products = [
|
||||||
|
Product(product_id="SEARCH1", title="Apple iPhone", description="Smartphone"),
|
||||||
|
Product(product_id="SEARCH2", title="Samsung Galaxy", description="Android phone"),
|
||||||
|
Product(product_id="SEARCH3", title="iPad Tablet", description="Apple tablet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Search for "Apple"
|
||||||
|
response = client.get("/api/v1/products?search=Apple", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2 # iPhone and iPad
|
||||||
|
|
||||||
|
# Search for "phone"
|
||||||
|
response = client.get("/api/v1/products?search=phone", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2 # iPhone and Galaxy
|
||||||
|
|
||||||
|
def test_combined_filters(self, client, auth_headers, db):
|
||||||
|
"""Test combining multiple filters"""
|
||||||
|
products = [
|
||||||
|
Product(product_id="COMBO1", title="Apple iPhone", brand="Apple", marketplace="Amazon"),
|
||||||
|
Product(product_id="COMBO2", title="Apple iPad", brand="Apple", marketplace="eBay"),
|
||||||
|
Product(product_id="COMBO3", title="Samsung Phone", brand="Samsung", marketplace="Amazon"),
|
||||||
|
]
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Filter by brand AND marketplace
|
||||||
|
response = client.get("/api/v1/products?brand=Apple&marketplace=Amazon", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1 # Only iPhone matches both
|
||||||
117
tests/test_integration.py
Normal file
117
tests/test_integration.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# tests/test_integration.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrationFlows:
|
||||||
|
def test_full_product_workflow(self, client, auth_headers):
|
||||||
|
"""Test complete product creation and management workflow"""
|
||||||
|
# 1. Create a product
|
||||||
|
product_data = {
|
||||||
|
"product_id": "FLOW001",
|
||||||
|
"title": "Integration Test Product",
|
||||||
|
"description": "Testing full workflow",
|
||||||
|
"price": "29.99",
|
||||||
|
"brand": "FlowBrand",
|
||||||
|
"gtin": "1111222233334",
|
||||||
|
"availability": "in stock",
|
||||||
|
"marketplace": "TestFlow"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
product = response.json()
|
||||||
|
|
||||||
|
# 2. Add stock for the product
|
||||||
|
stock_data = {
|
||||||
|
"gtin": product["gtin"],
|
||||||
|
"location": "MAIN_WAREHOUSE",
|
||||||
|
"quantity": 50
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock", headers=auth_headers, json=stock_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 3. Get product with stock info
|
||||||
|
response = client.get(f"/api/v1/products/{product['product_id']}", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
product_detail = response.json()
|
||||||
|
assert product_detail["stock_info"]["total_quantity"] == 50
|
||||||
|
|
||||||
|
# 4. Update product
|
||||||
|
update_data = {"title": "Updated Integration Test Product"}
|
||||||
|
response = client.put(f"/api/v1/products/{product['product_id']}",
|
||||||
|
headers=auth_headers, json=update_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 5. Search for product
|
||||||
|
response = client.get("/api/v1/products?search=Updated Integration", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 1
|
||||||
|
|
||||||
|
def test_shop_product_workflow(self, client, auth_headers):
|
||||||
|
"""Test shop creation and product management workflow"""
|
||||||
|
# 1. Create a shop
|
||||||
|
shop_data = {
|
||||||
|
"shop_code": "FLOWSHOP",
|
||||||
|
"shop_name": "Integration Flow Shop",
|
||||||
|
"description": "Test shop for integration"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/shops", headers=auth_headers, json=shop_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
shop = response.json()
|
||||||
|
|
||||||
|
# 2. Create a product
|
||||||
|
product_data = {
|
||||||
|
"product_id": "SHOPFLOW001",
|
||||||
|
"title": "Shop Flow Product",
|
||||||
|
"price": "15.99",
|
||||||
|
"marketplace": "ShopFlow"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
product = response.json()
|
||||||
|
|
||||||
|
# 3. Add product to shop (if endpoint exists)
|
||||||
|
# This would test the shop-product association
|
||||||
|
|
||||||
|
# 4. Get shop details
|
||||||
|
response = client.get(f"/api/v1/shops/{shop['shop_code']}", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_stock_operations_workflow(self, client, auth_headers):
|
||||||
|
"""Test complete stock management workflow"""
|
||||||
|
gtin = "9999888877776"
|
||||||
|
location = "TEST_WAREHOUSE"
|
||||||
|
|
||||||
|
# 1. Set initial stock
|
||||||
|
response = client.post("/api/v1/stock", headers=auth_headers, json={
|
||||||
|
"gtin": gtin,
|
||||||
|
"location": location,
|
||||||
|
"quantity": 100
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 2. Add more stock
|
||||||
|
response = client.post("/api/v1/stock/add", headers=auth_headers, json={
|
||||||
|
"gtin": gtin,
|
||||||
|
"location": location,
|
||||||
|
"quantity": 25
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["quantity"] == 125
|
||||||
|
|
||||||
|
# 3. Remove some stock
|
||||||
|
response = client.post("/api/v1/stock/remove", headers=auth_headers, json={
|
||||||
|
"gtin": gtin,
|
||||||
|
"location": location,
|
||||||
|
"quantity": 30
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["quantity"] == 95
|
||||||
|
|
||||||
|
# 4. Check total stock
|
||||||
|
response = client.get(f"/api/v1/stock/{gtin}/total", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total_quantity"] == 95
|
||||||
52
tests/test_marketplace.py
Normal file
52
tests/test_marketplace.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# tests/test_marketplace.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketplaceAPI:
|
||||||
|
@patch('utils.csv_processor.CSVProcessor.process_marketplace_csv_from_url')
|
||||||
|
def test_import_from_marketplace(self, mock_process, client, auth_headers, test_shop):
|
||||||
|
"""Test marketplace import endpoint"""
|
||||||
|
mock_process.return_value = AsyncMock()
|
||||||
|
|
||||||
|
import_data = {
|
||||||
|
"url": "https://example.com/products.csv",
|
||||||
|
"marketplace": "TestMarket",
|
||||||
|
"shop_code": test_shop.shop_code
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/marketplace/import-from-marketplace",
|
||||||
|
headers=auth_headers, json=import_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
assert data["marketplace"] == "TestMarket"
|
||||||
|
assert "job_id" in data
|
||||||
|
|
||||||
|
def test_import_from_marketplace_invalid_shop(self, client, auth_headers):
|
||||||
|
"""Test marketplace import with invalid shop"""
|
||||||
|
import_data = {
|
||||||
|
"url": "https://example.com/products.csv",
|
||||||
|
"marketplace": "TestMarket",
|
||||||
|
"shop_code": "NONEXISTENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/marketplace/import-from-marketplace",
|
||||||
|
headers=auth_headers, json=import_data)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "Shop not found" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_get_marketplace_import_jobs(self, client, auth_headers):
|
||||||
|
"""Test getting marketplace import jobs"""
|
||||||
|
response = client.get("/api/v1/marketplace/marketplace-import-jobs", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
|
|
||||||
|
def test_marketplace_requires_auth(self, client):
|
||||||
|
"""Test that marketplace endpoints require authentication"""
|
||||||
|
response = client.get("/api/v1/marketplace/marketplace-import-jobs")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
63
tests/test_middleware.py
Normal file
63
tests/test_middleware.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# tests/test_middleware.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from middleware.rate_limiter import RateLimiter
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiter:
|
||||||
|
def test_rate_limiter_allows_requests(self):
|
||||||
|
"""Test rate limiter allows requests within limit"""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
client_id = "test_client"
|
||||||
|
|
||||||
|
# Should allow first request
|
||||||
|
assert limiter.allow_request(client_id, max_requests=10, window_seconds=3600) == True
|
||||||
|
|
||||||
|
# Should allow subsequent requests within limit
|
||||||
|
for _ in range(5):
|
||||||
|
assert limiter.allow_request(client_id, max_requests=10, window_seconds=3600) == True
|
||||||
|
|
||||||
|
def test_rate_limiter_blocks_excess_requests(self):
|
||||||
|
"""Test rate limiter blocks requests exceeding limit"""
|
||||||
|
limiter = RateLimiter()
|
||||||
|
client_id = "test_client_blocked"
|
||||||
|
max_requests = 3
|
||||||
|
|
||||||
|
# Use up the allowed requests
|
||||||
|
for _ in range(max_requests):
|
||||||
|
assert limiter.allow_request(client_id, max_requests, 3600) == True
|
||||||
|
|
||||||
|
# Next request should be blocked
|
||||||
|
assert limiter.allow_request(client_id, max_requests, 3600) == False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthManager:
|
||||||
|
def test_password_hashing_and_verification(self):
|
||||||
|
"""Test password hashing and verification"""
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
password = "test_password_123"
|
||||||
|
|
||||||
|
# Hash password
|
||||||
|
hashed = auth_manager.hash_password(password)
|
||||||
|
|
||||||
|
# Verify correct password
|
||||||
|
assert auth_manager.verify_password(password, hashed) == True
|
||||||
|
|
||||||
|
# Verify incorrect password
|
||||||
|
assert auth_manager.verify_password("wrong_password", hashed) == False
|
||||||
|
|
||||||
|
def test_jwt_token_creation_and_validation(self, test_user):
|
||||||
|
"""Test JWT token creation and validation"""
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
|
||||||
|
# Create token
|
||||||
|
token_data = auth_manager.create_access_token(test_user)
|
||||||
|
|
||||||
|
assert "access_token" in token_data
|
||||||
|
assert token_data["token_type"] == "bearer"
|
||||||
|
assert isinstance(token_data["expires_in"], int)
|
||||||
|
|
||||||
|
# Token should be a string
|
||||||
|
assert isinstance(token_data["access_token"], str)
|
||||||
|
assert len(token_data["access_token"]) > 50 # JWT tokens are long
|
||||||
56
tests/test_pagination.py
Normal file
56
tests/test_pagination.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# tests/test_pagination.py
|
||||||
|
import pytest
|
||||||
|
from models.database_models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class TestPagination:
|
||||||
|
def test_product_pagination(self, client, auth_headers, db):
|
||||||
|
"""Test pagination for product listing"""
|
||||||
|
# Create multiple products
|
||||||
|
products = []
|
||||||
|
for i in range(25):
|
||||||
|
product = Product(
|
||||||
|
product_id=f"PAGE{i:03d}",
|
||||||
|
title=f"Pagination Test Product {i}",
|
||||||
|
marketplace="PaginationTest"
|
||||||
|
)
|
||||||
|
products.append(product)
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Test first page
|
||||||
|
response = client.get("/api/v1/products?limit=10&skip=0", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["products"]) == 10
|
||||||
|
assert data["total"] == 25
|
||||||
|
assert data["skip"] == 0
|
||||||
|
assert data["limit"] == 10
|
||||||
|
|
||||||
|
# Test second page
|
||||||
|
response = client.get("/api/v1/products?limit=10&skip=10", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["products"]) == 10
|
||||||
|
assert data["skip"] == 10
|
||||||
|
|
||||||
|
# Test last page
|
||||||
|
response = client.get("/api/v1/products?limit=10&skip=20", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["products"]) == 5 # Only 5 remaining
|
||||||
|
|
||||||
|
def test_pagination_boundaries(self, client, auth_headers):
|
||||||
|
"""Test pagination boundary conditions"""
|
||||||
|
# Test negative skip
|
||||||
|
response = client.get("/api/v1/products?skip=-1", headers=auth_headers)
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
# Test zero limit
|
||||||
|
response = client.get("/api/v1/products?limit=0", headers=auth_headers)
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
# Test excessive limit
|
||||||
|
response = client.get("/api/v1/products?limit=10000", headers=auth_headers)
|
||||||
|
assert response.status_code == 422 # Should be limited
|
||||||
56
tests/test_performance.py
Normal file
56
tests/test_performance.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# tests/test_performance.py
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformance:
|
||||||
|
def test_product_list_performance(self, client, auth_headers, db):
|
||||||
|
"""Test performance of product listing with many products"""
|
||||||
|
# Create multiple products
|
||||||
|
products = []
|
||||||
|
for i in range(100):
|
||||||
|
product = Product(
|
||||||
|
product_id=f"PERF{i:03d}",
|
||||||
|
title=f"Performance Test Product {i}",
|
||||||
|
price=f"{i}.99",
|
||||||
|
marketplace="Performance"
|
||||||
|
)
|
||||||
|
products.append(product)
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Time the request
|
||||||
|
start_time = time.time()
|
||||||
|
response = client.get("/api/v1/products?limit=100", headers=auth_headers)
|
||||||
|
end_time = time.time()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()["products"]) == 100
|
||||||
|
assert end_time - start_time < 2.0 # Should complete within 2 seconds
|
||||||
|
|
||||||
|
def test_search_performance(self, client, auth_headers, db):
|
||||||
|
"""Test search performance"""
|
||||||
|
# Create products with searchable content
|
||||||
|
products = []
|
||||||
|
for i in range(50):
|
||||||
|
product = Product(
|
||||||
|
product_id=f"SEARCH{i:03d}",
|
||||||
|
title=f"Searchable Product {i}",
|
||||||
|
description=f"This is a searchable product number {i}",
|
||||||
|
brand="SearchBrand",
|
||||||
|
marketplace="SearchMarket"
|
||||||
|
)
|
||||||
|
products.append(product)
|
||||||
|
|
||||||
|
db.add_all(products)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Time search request
|
||||||
|
start_time = time.time()
|
||||||
|
response = client.get("/api/v1/products?search=Searchable", headers=auth_headers)
|
||||||
|
end_time = time.time()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 50
|
||||||
|
assert end_time - start_time < 1.0 # Search should be fast
|
||||||
122
tests/test_products.py
Normal file
122
tests/test_products.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# tests/test_products.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductsAPI:
|
||||||
|
def test_get_products_empty(self, client, auth_headers):
|
||||||
|
"""Test getting products when none exist"""
|
||||||
|
response = client.get("/api/v1/products", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["products"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
def test_get_products_with_data(self, client, auth_headers, test_product):
|
||||||
|
"""Test getting products with data"""
|
||||||
|
response = client.get("/api/v1/products", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["products"]) == 1
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["products"][0]["product_id"] == "TEST001"
|
||||||
|
|
||||||
|
def test_get_products_with_filters(self, client, auth_headers, test_product):
|
||||||
|
"""Test filtering products"""
|
||||||
|
# Test brand filter
|
||||||
|
response = client.get("/api/v1/products?brand=TestBrand", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 1
|
||||||
|
|
||||||
|
# Test marketplace filter
|
||||||
|
response = client.get("/api/v1/products?marketplace=Letzshop", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 1
|
||||||
|
|
||||||
|
# Test search
|
||||||
|
response = client.get("/api/v1/products?search=Test", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 1
|
||||||
|
|
||||||
|
def test_create_product(self, client, auth_headers):
|
||||||
|
"""Test creating a new product"""
|
||||||
|
product_data = {
|
||||||
|
"product_id": "NEW001",
|
||||||
|
"title": "New Product",
|
||||||
|
"description": "A new product",
|
||||||
|
"price": "15.99",
|
||||||
|
"brand": "NewBrand",
|
||||||
|
"gtin": "9876543210987",
|
||||||
|
"availability": "in stock",
|
||||||
|
"marketplace": "Amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["product_id"] == "NEW001"
|
||||||
|
assert data["title"] == "New Product"
|
||||||
|
assert data["marketplace"] == "Amazon"
|
||||||
|
|
||||||
|
def test_create_product_duplicate_id(self, client, auth_headers, test_product):
|
||||||
|
"""Test creating product with duplicate ID"""
|
||||||
|
product_data = {
|
||||||
|
"product_id": "TEST001", # Same as test_product
|
||||||
|
"title": "Another Product",
|
||||||
|
"price": "20.00"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "already exists" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_get_product_by_id(self, client, auth_headers, test_product):
|
||||||
|
"""Test getting specific product"""
|
||||||
|
response = client.get(f"/api/v1/products/{test_product.product_id}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["product"]["product_id"] == test_product.product_id
|
||||||
|
assert data["product"]["title"] == test_product.title
|
||||||
|
|
||||||
|
def test_get_nonexistent_product(self, client, auth_headers):
|
||||||
|
"""Test getting nonexistent product"""
|
||||||
|
response = client.get("/api/v1/products/NONEXISTENT", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_product(self, client, auth_headers, test_product):
|
||||||
|
"""Test updating product"""
|
||||||
|
update_data = {
|
||||||
|
"title": "Updated Product Title",
|
||||||
|
"price": "25.99"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/products/{test_product.product_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json=update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "Updated Product Title"
|
||||||
|
assert data["price"] == "25.99"
|
||||||
|
|
||||||
|
def test_delete_product(self, client, auth_headers, test_product):
|
||||||
|
"""Test deleting product"""
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/products/{test_product.product_id}",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "deleted successfully" in response.json()["message"]
|
||||||
|
|
||||||
|
def test_products_require_auth(self, client):
|
||||||
|
"""Test that product endpoints require authentication"""
|
||||||
|
response = client.get("/api/v1/products")
|
||||||
|
assert response.status_code == 403
|
||||||
61
tests/test_security.py
Normal file
61
tests/test_security.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# tests/test_security.py
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecurity:
|
||||||
|
def test_protected_endpoint_without_auth(self, client):
|
||||||
|
"""Test that protected endpoints reject unauthenticated requests"""
|
||||||
|
protected_endpoints = [
|
||||||
|
"/api/v1/products",
|
||||||
|
"/api/v1/stock",
|
||||||
|
"/api/v1/shops",
|
||||||
|
"/api/v1/stats",
|
||||||
|
"/api/v1/admin/users"
|
||||||
|
]
|
||||||
|
|
||||||
|
for endpoint in protected_endpoints:
|
||||||
|
response = client.get(endpoint)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_protected_endpoint_with_invalid_token(self, client):
|
||||||
|
"""Test protected endpoints with invalid token"""
|
||||||
|
headers = {"Authorization": "Bearer invalid_token_here"}
|
||||||
|
|
||||||
|
response = client.get("/api/v1/products", headers=headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_admin_endpoint_requires_admin_role(self, client, auth_headers):
|
||||||
|
"""Test that admin endpoints require admin role"""
|
||||||
|
response = client.get("/api/v1/admin/users", headers=auth_headers)
|
||||||
|
assert response.status_code == 403 # Regular user should be denied
|
||||||
|
|
||||||
|
def test_sql_injection_prevention(self, client, auth_headers):
|
||||||
|
"""Test SQL injection prevention in search parameters"""
|
||||||
|
# Try SQL injection in search parameter
|
||||||
|
malicious_search = "'; DROP TABLE products; --"
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1/products?search={malicious_search}", headers=auth_headers)
|
||||||
|
|
||||||
|
# Should not crash and should return normal response
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Database should still be intact (no products dropped)
|
||||||
|
|
||||||
|
def test_input_validation(self, client, auth_headers):
|
||||||
|
"""Test input validation and sanitization"""
|
||||||
|
# Test XSS attempt in product creation
|
||||||
|
xss_payload = "<script>alert('xss')</script>"
|
||||||
|
|
||||||
|
product_data = {
|
||||||
|
"product_id": "XSS_TEST",
|
||||||
|
"title": xss_payload,
|
||||||
|
"description": xss_payload
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/products", headers=auth_headers, json=product_data)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
# If creation succeeds, content should be escaped/sanitized
|
||||||
|
data = response.json()
|
||||||
|
assert "<script>" not in data["title"]
|
||||||
60
tests/test_services.py
Normal file
60
tests/test_services.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# tests/test_services.py
|
||||||
|
import pytest
|
||||||
|
from app.services.product_service import ProductService
|
||||||
|
from models.api_models import ProductCreate
|
||||||
|
from models.database_models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductService:
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = ProductService()
|
||||||
|
|
||||||
|
def test_create_product_with_gtin_validation(self, db):
|
||||||
|
"""Test product creation with GTIN validation"""
|
||||||
|
product_data = ProductCreate(
|
||||||
|
product_id="SVC001",
|
||||||
|
title="Service Test Product",
|
||||||
|
gtin="1234567890123",
|
||||||
|
price="19.99",
|
||||||
|
marketplace="TestMarket"
|
||||||
|
)
|
||||||
|
|
||||||
|
product = self.service.create_product(db, product_data)
|
||||||
|
|
||||||
|
assert product.product_id == "SVC001"
|
||||||
|
assert product.gtin == "1234567890123"
|
||||||
|
assert product.marketplace == "TestMarket"
|
||||||
|
|
||||||
|
def test_create_product_invalid_gtin(self, db):
|
||||||
|
"""Test product creation with invalid GTIN"""
|
||||||
|
product_data = ProductCreate(
|
||||||
|
product_id="SVC002",
|
||||||
|
title="Service Test Product",
|
||||||
|
gtin="invalid_gtin",
|
||||||
|
price="19.99"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.create_product(db, product_data)
|
||||||
|
|
||||||
|
def test_get_products_with_filters(self, db, test_product):
|
||||||
|
"""Test getting products with various filters"""
|
||||||
|
products, total = self.service.get_products_with_filters(
|
||||||
|
db,
|
||||||
|
brand="TestBrand"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert len(products) == 1
|
||||||
|
assert products[0].brand == "TestBrand"
|
||||||
|
|
||||||
|
def test_get_products_with_search(self, db, test_product):
|
||||||
|
"""Test getting products with search"""
|
||||||
|
products, total = self.service.get_products_with_filters(
|
||||||
|
db,
|
||||||
|
search="Test Product"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert len(products) == 1
|
||||||
|
|
||||||
55
tests/test_shops.py
Normal file
55
tests/test_shops.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# tests/test_shops.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestShopsAPI:
|
||||||
|
def test_create_shop(self, client, auth_headers):
|
||||||
|
"""Test creating a new shop"""
|
||||||
|
shop_data = {
|
||||||
|
"shop_code": "NEWSHOP",
|
||||||
|
"shop_name": "New Shop",
|
||||||
|
"description": "A new test shop"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/shops", headers=auth_headers, json=shop_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["shop_code"] == "NEWSHOP"
|
||||||
|
assert data["shop_name"] == "New Shop"
|
||||||
|
assert data["is_active"] == True
|
||||||
|
|
||||||
|
def test_create_shop_duplicate_code(self, client, auth_headers, test_shop):
|
||||||
|
"""Test creating shop with duplicate code"""
|
||||||
|
shop_data = {
|
||||||
|
"shop_code": "TESTSHOP", # Same as test_shop
|
||||||
|
"shop_name": "Another Shop"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/shops", headers=auth_headers, json=shop_data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "already exists" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_get_shops(self, client, auth_headers, test_shop):
|
||||||
|
"""Test getting shops list"""
|
||||||
|
response = client.get("/api/v1/shops", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["shops"]) >= 1
|
||||||
|
|
||||||
|
def test_get_shop_by_code(self, client, auth_headers, test_shop):
|
||||||
|
"""Test getting specific shop"""
|
||||||
|
response = client.get(f"/api/v1/shops/{test_shop.shop_code}", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["shop_code"] == test_shop.shop_code
|
||||||
|
assert data["shop_name"] == test_shop.shop_name
|
||||||
|
|
||||||
|
def test_shops_require_auth(self, client):
|
||||||
|
"""Test that shop endpoints require authentication"""
|
||||||
|
response = client.get("/api/v1/shops")
|
||||||
|
assert response.status_code == 403
|
||||||
33
tests/test_stats.py
Normal file
33
tests/test_stats.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# tests/test_stats.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsAPI:
|
||||||
|
def test_get_basic_stats(self, client, auth_headers, test_product):
|
||||||
|
"""Test getting basic statistics"""
|
||||||
|
response = client.get("/api/v1/stats", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "total_products" in data
|
||||||
|
assert "unique_brands" in data
|
||||||
|
assert "unique_categories" in data
|
||||||
|
assert "unique_marketplaces" in data
|
||||||
|
assert "unique_shops" in data
|
||||||
|
assert data["total_products"] >= 1
|
||||||
|
|
||||||
|
def test_get_marketplace_stats(self, client, auth_headers, test_product):
|
||||||
|
"""Test getting marketplace statistics"""
|
||||||
|
response = client.get("/api/v1/stats/marketplace-stats", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
if len(data) > 0:
|
||||||
|
assert "marketplace" in data[0]
|
||||||
|
assert "total_products" in data[0]
|
||||||
|
|
||||||
|
def test_stats_require_auth(self, client):
|
||||||
|
"""Test that stats endpoints require authentication"""
|
||||||
|
response = client.get("/api/v1/stats")
|
||||||
|
assert response.status_code == 403
|
||||||
147
tests/test_stock.py
Normal file
147
tests/test_stock.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# tests/test_stock.py
|
||||||
|
import pytest
|
||||||
|
from models.database_models import Stock
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockAPI:
|
||||||
|
def test_set_stock_new(self, client, auth_headers):
|
||||||
|
"""Test setting stock for new GTIN"""
|
||||||
|
stock_data = {
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 100
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock", headers=auth_headers, json=stock_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["gtin"] == "1234567890123"
|
||||||
|
assert data["location"] == "WAREHOUSE_A"
|
||||||
|
assert data["quantity"] == 100
|
||||||
|
|
||||||
|
def test_set_stock_existing(self, client, auth_headers, db):
|
||||||
|
"""Test updating existing stock"""
|
||||||
|
# Create initial stock
|
||||||
|
stock = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stock_data = {
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 75
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock", headers=auth_headers, json=stock_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["quantity"] == 75 # Should be replaced, not added
|
||||||
|
|
||||||
|
def test_add_stock(self, client, auth_headers, db):
|
||||||
|
"""Test adding to existing stock"""
|
||||||
|
# Create initial stock
|
||||||
|
stock = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stock_data = {
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 25
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock/add", headers=auth_headers, json=stock_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["quantity"] == 75 # 50 + 25
|
||||||
|
|
||||||
|
def test_remove_stock(self, client, auth_headers, db):
|
||||||
|
"""Test removing from existing stock"""
|
||||||
|
# Create initial stock
|
||||||
|
stock = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stock_data = {
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 15
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock/remove", headers=auth_headers, json=stock_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["quantity"] == 35 # 50 - 15
|
||||||
|
|
||||||
|
def test_remove_stock_insufficient(self, client, auth_headers, db):
|
||||||
|
"""Test removing more stock than available"""
|
||||||
|
# Create initial stock
|
||||||
|
stock = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=10)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stock_data = {
|
||||||
|
"gtin": "1234567890123",
|
||||||
|
"location": "WAREHOUSE_A",
|
||||||
|
"quantity": 20
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/stock/remove", headers=auth_headers, json=stock_data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Insufficient stock" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_get_stock_by_gtin(self, client, auth_headers, db):
|
||||||
|
"""Test getting stock summary for GTIN"""
|
||||||
|
# Create stock in multiple locations
|
||||||
|
stock1 = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
stock2 = Stock(gtin="1234567890123", location="WAREHOUSE_B", quantity=25)
|
||||||
|
db.add_all([stock1, stock2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/stock/1234567890123", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["gtin"] == "1234567890123"
|
||||||
|
assert data["total_quantity"] == 75
|
||||||
|
assert len(data["locations"]) == 2
|
||||||
|
|
||||||
|
def test_get_total_stock(self, client, auth_headers, db):
|
||||||
|
"""Test getting total stock for GTIN"""
|
||||||
|
# Create stock in multiple locations
|
||||||
|
stock1 = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
stock2 = Stock(gtin="1234567890123", location="WAREHOUSE_B", quantity=25)
|
||||||
|
db.add_all([stock1, stock2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/stock/1234567890123/total", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["gtin"] == "1234567890123"
|
||||||
|
assert data["total_quantity"] == 75
|
||||||
|
assert data["locations_count"] == 2
|
||||||
|
|
||||||
|
def test_get_all_stock(self, client, auth_headers, db):
|
||||||
|
"""Test getting all stock entries"""
|
||||||
|
# Create some stock entries
|
||||||
|
stock1 = Stock(gtin="1234567890123", location="WAREHOUSE_A", quantity=50)
|
||||||
|
stock2 = Stock(gtin="9876543210987", location="WAREHOUSE_B", quantity=25)
|
||||||
|
db.add_all([stock1, stock2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/stock", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
def test_stock_requires_auth(self, client):
|
||||||
|
"""Test that stock endpoints require authentication"""
|
||||||
|
response = client.get("/api/v1/stock")
|
||||||
|
assert response.status_code == 403
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_utils.py
|
# tests/test_utils.py (Enhanced version of your existing file)
|
||||||
import pytest
|
import pytest
|
||||||
from utils.data_processing import GTINProcessor, PriceProcessor
|
from utils.data_processing import GTINProcessor, PriceProcessor
|
||||||
|
|
||||||
@@ -8,54 +8,111 @@ class TestGTINProcessor:
|
|||||||
self.processor = GTINProcessor()
|
self.processor = GTINProcessor()
|
||||||
|
|
||||||
def test_normalize_valid_gtin(self):
|
def test_normalize_valid_gtin(self):
|
||||||
|
"""Test GTIN normalization with valid inputs"""
|
||||||
# Test EAN-13
|
# Test EAN-13
|
||||||
assert self.processor.normalize("1234567890123") == "1234567890123"
|
assert self.processor.normalize("1234567890123") == "1234567890123"
|
||||||
|
|
||||||
# Test UPC-A
|
# Test UPC-A (12 digits)
|
||||||
assert self.processor.normalize("123456789012") == "123456789012"
|
assert self.processor.normalize("123456789012") == "123456789012"
|
||||||
|
|
||||||
# Test with decimal
|
# Test with decimal point
|
||||||
assert self.processor.normalize("123456789012.0") == "123456789012"
|
assert self.processor.normalize("123456789012.0") == "123456789012"
|
||||||
|
|
||||||
|
# Test EAN-8
|
||||||
|
assert self.processor.normalize("12345678") == "12345678"
|
||||||
|
|
||||||
def test_normalize_invalid_gtin(self):
|
def test_normalize_invalid_gtin(self):
|
||||||
|
"""Test GTIN normalization with invalid inputs"""
|
||||||
assert self.processor.normalize("") is None
|
assert self.processor.normalize("") is None
|
||||||
assert self.processor.normalize(None) is None
|
assert self.processor.normalize(None) is None
|
||||||
assert self.processor.normalize("abc") is None
|
assert self.processor.normalize("abc") is None
|
||||||
assert self.processor.normalize("123") == "000000000123" # Padded to 12 digits
|
|
||||||
|
# Test short number (gets padded)
|
||||||
|
assert self.processor.normalize("123") == "000000000123"
|
||||||
|
|
||||||
|
def test_normalize_gtin_with_formatting(self):
|
||||||
|
"""Test GTIN normalization with various formatting"""
|
||||||
|
# Test with spaces
|
||||||
|
assert self.processor.normalize("123 456 789 012") == "123456789012"
|
||||||
|
|
||||||
|
# Test with dashes
|
||||||
|
assert self.processor.normalize("123-456-789-012") == "123456789012"
|
||||||
|
|
||||||
|
# Test with mixed formatting
|
||||||
|
assert self.processor.normalize("123 456-789 012") == "123456789012"
|
||||||
|
|
||||||
def test_validate_gtin(self):
|
def test_validate_gtin(self):
|
||||||
|
"""Test GTIN validation"""
|
||||||
assert self.processor.validate("1234567890123") is True
|
assert self.processor.validate("1234567890123") is True
|
||||||
assert self.processor.validate("123456789012") is True
|
assert self.processor.validate("123456789012") is True
|
||||||
assert self.processor.validate("12345678") is True
|
assert self.processor.validate("12345678") is True
|
||||||
assert self.processor.validate("123") is False
|
assert self.processor.validate("123") is False
|
||||||
assert self.processor.validate("") is False
|
assert self.processor.validate("") is False
|
||||||
|
assert self.processor.validate(None) is False
|
||||||
|
|
||||||
|
def test_gtin_checksum_validation(self):
|
||||||
|
"""Test GTIN checksum validation if implemented"""
|
||||||
|
# This test would verify checksum calculation if your GTINProcessor implements it
|
||||||
|
# For now, we'll test the structure validation
|
||||||
|
assert self.processor.validate("1234567890123") is True
|
||||||
|
assert self.processor.validate("12345678901234") is True # 14 digits
|
||||||
|
assert self.processor.validate("123456789012345") is False # 15 digits
|
||||||
|
|
||||||
|
|
||||||
class TestPriceProcessor:
|
class TestPriceProcessor:
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.processor = PriceProcessor()
|
self.processor = PriceProcessor()
|
||||||
|
|
||||||
def test_parse_price_currency(self):
|
def test_parse_price_currency_eur(self):
|
||||||
# Test EUR with symbol
|
"""Test EUR price parsing"""
|
||||||
price, currency = self.processor.parse_price_currency("8.26 EUR")
|
price, currency = self.processor.parse_price_currency("8.26 EUR")
|
||||||
assert price == "8.26"
|
assert price == "8.26"
|
||||||
assert currency == "EUR"
|
assert currency == "EUR"
|
||||||
|
|
||||||
# Test USD with symbol
|
# Test with euro symbol
|
||||||
|
price, currency = self.processor.parse_price_currency("8.26 €")
|
||||||
|
assert price == "8.26"
|
||||||
|
assert currency == "EUR"
|
||||||
|
|
||||||
|
def test_parse_price_currency_usd(self):
|
||||||
|
"""Test USD price parsing"""
|
||||||
price, currency = self.processor.parse_price_currency("$12.50")
|
price, currency = self.processor.parse_price_currency("$12.50")
|
||||||
assert price == "12.50"
|
assert price == "12.50"
|
||||||
assert currency == "USD"
|
assert currency == "USD"
|
||||||
|
|
||||||
# Test with comma decimal separator
|
price, currency = self.processor.parse_price_currency("12.50 USD")
|
||||||
price, currency = self.processor.parse_price_currency("8,26 €")
|
assert price == "12.50"
|
||||||
|
assert currency == "USD"
|
||||||
|
|
||||||
|
def test_parse_price_currency_comma_decimal(self):
|
||||||
|
"""Test price parsing with comma as decimal separator"""
|
||||||
|
price, currency = self.processor.parse_price_currency("8,26 EUR")
|
||||||
assert price == "8.26"
|
assert price == "8.26"
|
||||||
assert currency == "EUR"
|
assert currency == "EUR"
|
||||||
|
|
||||||
def test_parse_invalid_price(self):
|
def test_parse_invalid_price(self):
|
||||||
|
"""Test invalid price parsing"""
|
||||||
price, currency = self.processor.parse_price_currency("")
|
price, currency = self.processor.parse_price_currency("")
|
||||||
assert price is None
|
assert price is None
|
||||||
assert currency is None
|
assert currency is None
|
||||||
|
|
||||||
price, currency = self.processor.parse_price_currency(None)
|
price, currency = self.processor.parse_price_currency(None)
|
||||||
assert price is None
|
assert price is None
|
||||||
assert currency is None
|
assert currency is None
|
||||||
|
|
||||||
|
def test_parse_price_edge_cases(self):
|
||||||
|
"""Test edge cases in price parsing"""
|
||||||
|
# Test price without currency
|
||||||
|
price, currency = self.processor.parse_price_currency("15.99")
|
||||||
|
assert price == "15.99"
|
||||||
|
# currency might be None or default value
|
||||||
|
|
||||||
|
# Test currency before price
|
||||||
|
price, currency = self.processor.parse_price_currency("EUR 25.50")
|
||||||
|
assert price == "25.50"
|
||||||
|
assert currency == "EUR"
|
||||||
|
|
||||||
|
# Test with multiple decimal places
|
||||||
|
price, currency = self.processor.parse_price_currency("12.999 USD")
|
||||||
|
assert price == "12.999"
|
||||||
|
assert currency == "USD"
|
||||||
|
|||||||
Reference in New Issue
Block a user