shop management features

This commit is contained in:
2025-09-07 22:33:30 +02:00
parent 3342f1ab45
commit 9a5d70e825
3 changed files with 482 additions and 26 deletions

286
main.py
View File

@@ -18,19 +18,18 @@ import time
from functools import wraps
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Import utility modules
from utils.data_processing import GTINProcessor, PriceProcessor
from utils.csv_processor import CSVProcessor
from utils.database import get_db_engine, get_session_local
from models.database_models import Base, Product, Stock, User, MarketplaceImportJob
from models.database_models import Base, Product, Stock, User, MarketplaceImportJob, Shop, ShopProduct
from models.api_models import *
from middleware.rate_limiter import RateLimiter
from middleware.auth import AuthManager
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=logging.INFO,
@@ -115,7 +114,8 @@ async def lifespan(app: FastAPI):
# FastAPI app with lifespan
app = FastAPI(
title="Ecommerce Backend API with Marketplace Support",
description="Advanced product management system with JWT authentication, marketplace-aware CSV import/export and stock management",
description="Advanced product management system with JWT authentication, marketplace-aware CSV "
"import/export and stock management",
version="2.2.0",
lifespan=lifespan
)
@@ -157,6 +157,19 @@ def get_current_admin_user(current_user: User = Depends(get_current_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 (or admin)"""
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
if not shop:
raise HTTPException(status_code=404, detail="Shop not found")
# Admin can access any shop, owners can access their own shops
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
# Rate limiting decorator
def rate_limit(max_requests: int = 100, window_seconds: int = 3600):
def decorator(func):
@@ -282,14 +295,23 @@ async def import_products_from_marketplace(
"""Import products from marketplace CSV with background processing (Protected)"""
logger.info(
f"Starting marketplace import: {request.marketplace} -> {request.shop_name} by user {current_user.username}")
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_name=request.shop_name,
shop_code=request.shop_code,
user_id=current_user.id,
created_at=datetime.utcnow()
)
@@ -303,7 +325,7 @@ async def import_products_from_marketplace(
import_job.id,
request.url,
request.marketplace,
request.shop_name,
request.shop_code,
request.batch_size or 1000
)
@@ -311,12 +333,13 @@ async def import_products_from_marketplace(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
shop_name=request.shop_name,
shop_code=request.shop_code,
message=f"Marketplace import started from {request.marketplace}. Check status with /marketplace-import-status/{import_job.id}"
)
async def process_marketplace_import(job_id: int, url: str, marketplace: str, shop_name: str, batch_size: int = 1000):
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 with batching"""
db = SessionLocal()
@@ -352,7 +375,8 @@ async def process_marketplace_import(job_id: int, url: str, marketplace: str, sh
db.commit()
logger.info(
f"Marketplace import job {job_id} completed successfully - Imported: {result['imported']}, Updated: {result['updated']}")
f"Marketplace import job {job_id} completed successfully - Imported: {result['imported']}, "
f"Updated: {result['updated']}")
except Exception as e:
logger.error(f"Marketplace import job {job_id} failed: {e}")
@@ -899,7 +923,8 @@ def get_all_stock(
@app.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)):
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:
@@ -923,6 +948,180 @@ def delete_stock(stock_id: int, db: Session = Depends(get_db), current_user: Use
db.commit()
return {"message": "Stock entry deleted successfully"}
# Shop Management Routes
@app.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
@app.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
)
@app.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
@app.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
@app.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)
}
# Enhanced Statistics with Marketplace Support
@app.get("/stats", response_model=StatsResponse)
def get_stats(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
@@ -1078,6 +1277,65 @@ def toggle_user_status(
return {"message": f"User {user.username} has been {status}"}
@app.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
)
@app.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}"}
@app.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}"}
@app.get("/admin/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse])
def get_all_marketplace_import_jobs(
marketplace: Optional[str] = Query(None),
@@ -1130,4 +1388,4 @@ if __name__ == "__main__":
port=8000,
reload=True,
log_level="info"
)
)

View File

@@ -55,6 +55,75 @@ class LoginResponse(BaseModel):
user: UserResponse
# NEW: Shop models
class ShopCreate(BaseModel):
shop_code: str = Field(..., min_length=3, max_length=50, description="Unique shop code (e.g., TECHSTORE)")
shop_name: str = Field(..., min_length=1, max_length=200, description="Display name of the shop")
description: Optional[str] = Field(None, max_length=2000, description="Shop description")
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
website: Optional[str] = None
business_address: Optional[str] = None
tax_number: Optional[str] = None
@field_validator('shop_code')
def validate_shop_code(cls, v):
# Convert to uppercase and check format
v = v.upper().strip()
if not v.replace('_', '').replace('-', '').isalnum():
raise ValueError('Shop code must be alphanumeric (underscores and hyphens allowed)')
return v
@field_validator('contact_email')
def validate_contact_email(cls, v):
if v and ('@' not in v or '.' not in v):
raise ValueError('Invalid email format')
return v.lower() if v else v
class ShopUpdate(BaseModel):
shop_name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=2000)
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
website: Optional[str] = None
business_address: Optional[str] = None
tax_number: Optional[str] = None
@field_validator('contact_email')
def validate_contact_email(cls, v):
if v and ('@' not in v or '.' not in v):
raise ValueError('Invalid email format')
return v.lower() if v else v
class ShopResponse(BaseModel):
id: int
shop_code: str
shop_name: str
description: Optional[str]
owner_id: int
contact_email: Optional[str]
contact_phone: Optional[str]
website: Optional[str]
business_address: Optional[str]
tax_number: Optional[str]
is_active: bool
is_verified: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ShopListResponse(BaseModel):
shops: List[ShopResponse]
total: int
skip: int
limit: int
# Base Product Models with Marketplace Support
class ProductBase(BaseModel):
product_id: Optional[str] = None
@@ -123,6 +192,41 @@ class ProductResponse(ProductBase):
model_config = {"from_attributes": True}
# NEW: Shop Product models
class ShopProductCreate(BaseModel):
product_id: str = Field(..., description="Product ID to add to shop")
shop_product_id: Optional[str] = Field(None, description="Shop's internal product ID")
shop_price: Optional[float] = Field(None, ge=0, description="Shop-specific price override")
shop_sale_price: Optional[float] = Field(None, ge=0, description="Shop-specific sale price")
shop_currency: Optional[str] = Field(None, description="Shop-specific currency")
shop_availability: Optional[str] = Field(None, description="Shop-specific availability")
shop_condition: Optional[str] = Field(None, description="Shop-specific condition")
is_featured: bool = Field(False, description="Featured product flag")
min_quantity: int = Field(1, ge=1, description="Minimum order quantity")
max_quantity: Optional[int] = Field(None, ge=1, description="Maximum order quantity")
class ShopProductResponse(BaseModel):
id: int
shop_id: int
product: ProductResponse
shop_product_id: Optional[str]
shop_price: Optional[float]
shop_sale_price: Optional[float]
shop_currency: Optional[str]
shop_availability: Optional[str]
shop_condition: Optional[str]
is_featured: bool
is_active: bool
min_quantity: int
max_quantity: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Stock Models
class StockBase(BaseModel):
gtin: str = Field(..., min_length=1, description="GTIN is required")
@@ -168,7 +272,7 @@ class StockSummaryResponse(BaseModel):
class MarketplaceImportRequest(BaseModel):
url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Name of the marketplace (e.g., Letzshop, Amazon, eBay)")
shop_name: str = Field(..., min_length=1, description="Name of the shop these products belong to")
shop_code: str = Field(..., description="Shop code to associate products with")
batch_size: Optional[int] = Field(1000, gt=0, le=10000, description="Batch size for processing")
@field_validator('url')
@@ -188,18 +292,18 @@ class MarketplaceImportRequest(BaseModel):
pass
return v.strip()
@field_validator('shop_name')
@field_validator('shop_code')
@classmethod
def validate_shop_name(cls, v):
if not v or not v.strip():
raise ValueError('Shop name cannot be empty')
return v.strip()
def validate_shop_code(cls, v):
return v.upper().strip()
class MarketplaceImportJobResponse(BaseModel):
job_id: int
status: str
marketplace: str
shop_id: int
shop_code: Optional[str] = None # Will be populated from shop relationship
shop_name: str
message: Optional[str] = None
imported: Optional[int] = 0

View File

@@ -1,5 +1,5 @@
# models/database_models.py - Updated with Marketplace Support
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint, Boolean
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey, UniqueConstraint, Index
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
@@ -14,16 +14,52 @@ class User(Base):
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # 'admin' or 'user'
role = Column(String, nullable=False, default="user") # user, admin, shop_owner
is_active = Column(Boolean, default=True, nullable=False)
last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="user")
owned_shops = relationship("Shop", back_populates="owner")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"
class Shop(Base):
__tablename__ = "shops"
id = Column(Integer, primary_key=True, index=True)
shop_code = Column(String, unique=True, index=True, nullable=False) # e.g., "TECHSTORE", "FASHIONHUB"
shop_name = Column(String, nullable=False) # Display name
description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
website = Column(String)
# Business information
business_address = Column(Text)
tax_number = Column(String)
# Status
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
owner = relationship("User", back_populates="owned_shops")
shop_products = relationship("ShopProduct", back_populates="shop")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="shop")
class Product(Base):
__tablename__ = "products"
@@ -76,6 +112,7 @@ class Product(Base):
# Relationship to stock (one-to-many via GTIN)
stock_entries = relationship("Stock", foreign_keys="Stock.gtin", primaryjoin="Product.gtin == Stock.gtin",
viewonly=True)
shop_products = relationship("ShopProduct", back_populates="product")
# Additional indexes for marketplace queries
__table_args__ = (
@@ -84,7 +121,48 @@ class Product(Base):
)
def __repr__(self):
return f"<Product(product_id='{self.product_id}', title='{self.title}', marketplace='{self.marketplace}', shop='{self.shop_name}')>"
return (f"<Product(product_id='{self.product_id}', title='{self.title}', marketplace='{self.marketplace}', "
f"shop='{self.shop_name}')>")
class ShopProduct(Base):
__tablename__ = "shop_products"
id = Column(Integer, primary_key=True, index=True)
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Shop-specific overrides (can override the main product data)
shop_product_id = Column(String) # Shop's internal product ID
shop_price = Column(Float) # Override main product price
shop_sale_price = Column(Float)
shop_currency = Column(String)
shop_availability = Column(String) # Override availability
shop_condition = Column(String)
# Shop-specific metadata
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory management
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
shop = relationship("Shop", back_populates="shop_products")
product = relationship("Product", back_populates="shop_products")
# Constraints
__table_args__ = (
UniqueConstraint('shop_id', 'product_id', name='uq_shop_product'),
Index('idx_shop_product_active', 'shop_id', 'is_active'),
Index('idx_shop_product_featured', 'shop_id', 'is_featured'),
)
class Stock(Base):
@@ -94,9 +172,15 @@ class Stock(Base):
gtin = Column(String, index=True, nullable=False) # Foreign key relationship would be ideal
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) # For orders being processed
shop_id = Column(Integer, ForeignKey("shops.id")) # Optional: shop-specific stock
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
shop = relationship("Shop")
# Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = (
UniqueConstraint('gtin', 'location', name='uq_stock_gtin_location'),
@@ -116,24 +200,34 @@ class MarketplaceImportJob(Base):
source_url = Column(String, nullable=False)
marketplace = Column(String, nullable=False, index=True, default="Letzshop") # Index for marketplace filtering
shop_name = Column(String, nullable=False, index=True) # Index for shop filtering
user_id = Column(Integer, ForeignKey('users.id')) # Foreign key to users table
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False) # Add proper foreign key
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # Foreign key to users table
# Results
imported_count = Column(Integer, default=0)
updated_count = Column(Integer, default=0)
error_count = Column(Integer, default=0)
total_processed = Column(Integer, default=0)
# Error handling
error_message = Column(String)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Relationship to user
user = relationship("User", foreign_keys=[user_id])
shop = relationship("Shop", back_populates="marketplace_import_jobs")
# Additional indexes for marketplace import job queries
__table_args__ = (
Index('idx_marketplace_import_user_marketplace', 'user_id', 'marketplace'), # User's marketplace imports
Index('idx_marketplace_import_shop_status', 'shop_name', 'status'), # Shop import status
Index('idx_marketplace_import_shop_status', 'status'), # Shop import status
Index('idx_marketplace_import_shop_id', 'shop_id'),
)
def __repr__(self):
return f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', shop='{self.shop_name}', status='{self.status}', imported={self.imported_count})>"
return (f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', shop='{self.shop_name}', "
f"status='{self.status}', imported={self.imported_count})>")