diff --git a/main.py b/main.py index 76af05a8..53838a14 100644 --- a/main.py +++ b/main.py @@ -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" - ) \ No newline at end of file + ) diff --git a/models/api_models.py b/models/api_models.py index e419851d..c1ba1f97 100644 --- a/models/api_models.py +++ b/models/api_models.py @@ -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 diff --git a/models/database_models.py b/models/database_models.py index 7116c28e..b1857df2 100644 --- a/models/database_models.py +++ b/models/database_models.py @@ -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"" +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"" + return (f"") + + +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"" + return (f"")