Refactoring code for modular approach

This commit is contained in:
2025-09-09 21:27:58 +02:00
parent 9a5d70e825
commit 71153a1ff5
55 changed files with 3928 additions and 1352 deletions

View File

@@ -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
View File

@@ -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
View File

0
app/api/__init__.py Normal file
View File

40
app/api/deps.py Normal file
View 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
View 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
View File

153
app/api/v1/admin.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

26
app/core/config.py Normal file
View 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
View 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
View 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
View 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
View File

View File

View 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()

View File

0
app/tasks/__init__.py Normal file
View File

View 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
View 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

View File

@@ -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"

1358
main.py

File diff suppressed because it is too large Load Diff

29
middleware/decorators.py Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
# tests/__init__.py
# This file makes the tests directory a Python package

194
tests/conftest.py Normal file
View 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
View 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

View 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
View 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
View 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)

View 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

View 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

View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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"