major refactoring adding vendor and customer features

This commit is contained in:
2025-10-11 09:09:25 +02:00
parent f569995883
commit dd16198276
126 changed files with 15109 additions and 3747 deletions

View File

@@ -102,8 +102,8 @@ test-auth:
test-products:
pytest tests/test_products.py -v
test-stock:
pytest tests/test_stock.py -v
test-inventory:
pytest tests/test_inventory.py -v
# =============================================================================
# CODE QUALITY

View File

@@ -1,6 +1,6 @@
# 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.
A comprehensive FastAPI-based product management system with JWT authentication, marketplace-aware CSV import/export, multi-shop support, and advanced inventory management capabilities.
## Overview
@@ -12,7 +12,7 @@ This FastAPI application provides a complete ecommerce backend solution designed
- **Marketplace Integration** - Support for multiple marketplaces (Letzshop, Amazon, eBay, Etsy, Shopify, etc.)
- **Multi-Shop Management** - Shop creation, ownership validation, and product catalog management
- **Advanced MarketplaceProduct Management** - GTIN validation, price processing, and comprehensive filtering
- **Stock Management** - Multi-location inventory tracking with add/remove/set operations
- **Inventory 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
@@ -47,7 +47,7 @@ letzshop_api/
│ │ └── v1/ # API version 1 routes
│ │ ├── auth.py # Authentication endpoints
│ │ ├── products.py # MarketplaceProduct management
│ │ ├── stock.py # Stock operations
│ │ ├── inventory.py # Inventory operations
│ │ ├── shops.py # Shop management
│ │ ├── marketplace.py # Marketplace imports
│ │ ├── admin.py # Admin functions
@@ -61,7 +61,7 @@ letzshop_api/
│ │ ├── user.py # User, UserProfile models
│ │ ├── auth.py # Authentication-related models
│ │ ├── product.py # MarketplaceProduct, ProductVariant models
│ │ ├── stock.py # Stock, StockMovement models
│ │ ├── inventory.py # Inventory, InventoryMovement models
│ │ ├── shop.py # Shop, ShopLocation models
│ │ ├── marketplace.py # Marketplace integration models
│ │ └── admin.py # Admin-specific models
@@ -70,7 +70,7 @@ letzshop_api/
│ ├── base.py # Base Pydantic models
│ ├── auth.py # Login, Token, User response models
│ ├── product.py # MarketplaceProduct request/response models
│ ├── stock.py # Stock operation models
│ ├── inventory.py # Inventory operation models
│ ├── shop.py # Shop management models
│ ├── marketplace.py # Marketplace import models
│ ├── admin.py # Admin operation models
@@ -307,7 +307,7 @@ curl -X POST "http://localhost:8000/api/v1/marketplace/product" \
"currency": "EUR",
"brand": "BrandName",
"gtin": "1234567890123",
"availability": "in stock",
"availability": "in inventory",
"marketplace": "Letzshop",
"shop_name": "MyShop"
}'
@@ -329,12 +329,12 @@ curl -X GET "http://localhost:8000/api/v1/marketplace/product?search=Amazing&bra
-H "Authorization: Bearer YOUR_TOKEN"
```
### Stock Management
### Inventory Management
#### Set Stock Quantity
#### Set Inventory Quantity
```bash
curl -X POST "http://localhost:8000/api/v1/stock" \
curl -X POST "http://localhost:8000/api/v1/inventory" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -344,10 +344,10 @@ curl -X POST "http://localhost:8000/api/v1/stock" \
}'
```
#### Add Stock
#### Add Inventory
```bash
curl -X POST "http://localhost:8000/api/v1/stock/add" \
curl -X POST "http://localhost:8000/api/v1/inventory/add" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -357,10 +357,10 @@ curl -X POST "http://localhost:8000/api/v1/stock/add" \
}'
```
#### Check Stock Levels
#### Check Inventory Levels
```bash
curl -X GET "http://localhost:8000/api/v1/stock/1234567890123" \
curl -X GET "http://localhost:8000/api/v1/inventory/1234567890123" \
-H "Authorization: Bearer YOUR_TOKEN"
```
@@ -415,7 +415,7 @@ The system supports CSV imports with the following headers:
- `description` - MarketplaceProduct description
- `link` - MarketplaceProduct URL
- `image_link` - MarketplaceProduct image URL
- `availability` - Stock availability (in stock, out of stock, preorder)
- `availability` - Inventory availability (in inventory, out of inventory, preorder)
- `price` - MarketplaceProduct price
- `currency` - Price currency (EUR, USD, etc.)
- `brand` - MarketplaceProduct brand
@@ -448,13 +448,13 @@ PROD002,"Super Gadget","A fantastic gadget",19.99,EUR,GadgetInc,9876543210987,Am
- `GET /api/v1/marketplace/import-status/{job_id}` - Check import status
- `GET /api/v1/marketplace/import-jobs` - List import jobs
-
### 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
### Inventory Endpoints
- `POST /api/v1/inventory` - Set inventory quantity
- `POST /api/v1/inventory/add` - Add to inventory
- `POST /api/v1/inventory/remove` - Remove from inventory
- `GET /api/v1/inventory/{gtin}` - Get inventory by GTIN
- `GET /api/v1/inventory/{gtin}/total` - Get total inventory
- `GET /api/v1/inventory` - List all inventory entries
### Shop Endpoints
- `POST /api/v1/shop` - Create new shop
@@ -528,7 +528,7 @@ make docs-help
### Core Tables
- **users** - User accounts and authentication
- **products** - MarketplaceProduct catalog with marketplace info
- **stock** - Inventory tracking by location and GTIN
- **inventory** - Inventory tracking by location and GTIN
- **shops** - Shop/seller information
- **shop_products** - Shop-specific product settings
- **marketplace_import_jobs** - Background import job tracking
@@ -536,7 +536,7 @@ make docs-help
### Key Relationships
- Users own shops (one-to-many)
- Products belong to marketplaces and shops
- Stock entries are linked to products via GTIN
- Inventory entries are linked to products via GTIN
- Import jobs track user-initiated imports
## Security Features
@@ -584,8 +584,8 @@ make test-auth
# MarketplaceProduct management tests
make test-products
# Stock management tests
make test-stock
# Inventory management tests
make test-inventory
# Marketplace functionality tests
make test-marketplace
@@ -714,7 +714,7 @@ This will display all available commands organized by category:
### v2.2.0
- Added marketplace-aware product import
- Implemented multi-shop support
- Enhanced stock management with location tracking
- Enhanced inventory management with location tracking
- Added comprehensive test suite
- Improved authentication and authorization

View File

@@ -1,9 +1,9 @@
# alembic/env.py
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Add your project directory to the Python path
@@ -16,57 +16,77 @@ from models.database.base import Base
# Only import SQLAlchemy models, not Pydantic API models
print("[ALEMBIC] Importing database models...")
# Core models
try:
from models.database.user import User
print(" ✓ User model imported")
except ImportError as e:
print(f" ✗ User model failed: {e}")
try:
from models.database.vendor import Vendor, VendorUser, Role
print(" ✓ Vendor models imported (Vendor, VendorUser, Role)")
except ImportError as e:
print(f" ✗ Vendor models failed: {e}")
# Product models
try:
from models.database.marketplace_product import MarketplaceProduct
print(" ✓ MarketplaceProduct model imported")
except ImportError as e:
print(f" ✗ MarketplaceProduct model failed: {e}")
try:
from models.database.stock import Stock
print(" ✓ Stock model imported")
except ImportError as e:
print(f" ✗ Stock model failed: {e}")
try:
from models.database.vendor import Vendor
print(" ✓ Vendor model imported")
except ImportError as e:
print(f" ✗ Vendor model failed: {e}")
try:
from models.database.product import Product
print(" ✓ Product model imported")
except ImportError as e:
print(f" ✗ Product model failed: {e}")
# Inventory
try:
from models.database.inventory import Inventory
print(" ✓ Inventory model imported")
except ImportError as e:
print(f" ✗ Inventory model failed: {e}")
# Marketplace imports
try:
from models.database.marketplace_import_job import MarketplaceImportJob
print(" ✓ Marketplace model imported")
print(" ✓ MarketplaceImportJob model imported")
except ImportError as e:
print(f" ✗ Marketplace model failed: {e}")
print(f" ✗ MarketplaceImportJob model failed: {e}")
# Check if there are any additional models in the database directory
# Customer models (MISSING IN YOUR FILE)
try:
from models.database import auth as auth_models
print("Auth models imported")
except ImportError:
print(" - Auth models not found (optional)")
from models.database.customer import Customer, CustomerAddress
print("Customer models imported (Customer, CustomerAddress)")
except ImportError as e:
print(f" ✗ Customer models failed: {e}")
# Order models (MISSING IN YOUR FILE)
try:
from models.database import admin as admin_models
print("Admin models imported")
except ImportError:
print(" - Admin models not found (optional)")
from models.database.order import Order, OrderItem
print("Order models imported (Order, OrderItem)")
except ImportError as e:
print(f" ✗ Order models failed: {e}")
print(f"[ALEMBIC] Model import completed. Tables found: {list(Base.metadata.tables.keys())}")
print(f"[ALEMBIC] Total tables to create: {len(Base.metadata.tables)}")
# Payment models (if you have them)
try:
from models.database.payment import Payment
print(" ✓ Payment model imported")
except ImportError as e:
print(f" - Payment model not found (optional)")
# Shipping models (if you have them)
try:
from models.database.shipping import ShippingAddress
print(" ✓ Shipping model imported")
except ImportError as e:
print(f" - Shipping model not found (optional)")
print(f"\n[ALEMBIC] Model import completed.")
print(f"[ALEMBIC] Tables found: {list(Base.metadata.tables.keys())}")
print(f"[ALEMBIC] Total tables to create: {len(Base.metadata.tables)}\n")
# Alembic Config object
config = context.config

View File

@@ -1,220 +0,0 @@
"""initial_complete_schema
Revision ID: dbe48f596a44
Revises:
Create Date: 2025-09-21 15:45:40.290712
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'dbe48f596a44'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('marketplace_product_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('link', sa.String(), nullable=True),
sa.Column('image_link', sa.String(), nullable=True),
sa.Column('availability', sa.String(), nullable=True),
sa.Column('price', sa.String(), nullable=True),
sa.Column('brand', sa.String(), nullable=True),
sa.Column('gtin', sa.String(), nullable=True),
sa.Column('mpn', sa.String(), nullable=True),
sa.Column('condition', sa.String(), nullable=True),
sa.Column('adult', sa.String(), nullable=True),
sa.Column('multipack', sa.Integer(), nullable=True),
sa.Column('is_bundle', sa.String(), nullable=True),
sa.Column('age_group', sa.String(), nullable=True),
sa.Column('color', sa.String(), nullable=True),
sa.Column('gender', sa.String(), nullable=True),
sa.Column('material', sa.String(), nullable=True),
sa.Column('pattern', sa.String(), nullable=True),
sa.Column('size', sa.String(), nullable=True),
sa.Column('size_type', sa.String(), nullable=True),
sa.Column('size_system', sa.String(), nullable=True),
sa.Column('item_group_id', sa.String(), nullable=True),
sa.Column('google_product_category', sa.String(), nullable=True),
sa.Column('product_type', sa.String(), nullable=True),
sa.Column('custom_label_0', sa.String(), nullable=True),
sa.Column('custom_label_1', sa.String(), nullable=True),
sa.Column('custom_label_2', sa.String(), nullable=True),
sa.Column('custom_label_3', sa.String(), nullable=True),
sa.Column('custom_label_4', sa.String(), nullable=True),
sa.Column('additional_image_link', sa.String(), nullable=True),
sa.Column('sale_price', sa.String(), nullable=True),
sa.Column('unit_pricing_measure', sa.String(), nullable=True),
sa.Column('unit_pricing_base_measure', sa.String(), nullable=True),
sa.Column('identifier_exists', sa.String(), nullable=True),
sa.Column('shipping', sa.String(), nullable=True),
sa.Column('currency', sa.String(), nullable=True),
sa.Column('marketplace', sa.String(), nullable=True),
sa.Column('vendor_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_marketplace_brand', 'products', ['marketplace', 'brand'], unique=False)
op.create_index('idx_marketplace_shop', 'products', ['marketplace', 'vendor_name'], unique=False)
op.create_index(op.f('ix_products_availability'), 'products', ['availability'], unique=False)
op.create_index(op.f('ix_products_brand'), 'products', ['brand'], unique=False)
op.create_index(op.f('ix_products_google_product_category'), 'products', ['google_product_category'], unique=False)
op.create_index(op.f('ix_products_gtin'), 'products', ['gtin'], unique=False)
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_marketplace'), 'products', ['marketplace'], unique=False)
op.create_index(op.f('ix_products_product_id'), 'products', ['marketplace_product_id'], unique=True)
op.create_index(op.f('ix_products_vendor_name'), 'products', ['vendor_name'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('vendors',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_code', sa.String(), nullable=False),
sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('contact_email', sa.String(), nullable=True),
sa.Column('contact_phone', sa.String(), nullable=True),
sa.Column('website', sa.String(), nullable=True),
sa.Column('business_address', sa.Text(), nullable=True),
sa.Column('tax_number', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_vendors_id'), 'vendors', ['id'], unique=False)
op.create_index(op.f('ix_vendors_vendor_code'), 'vendors', ['vendor_code'], unique=True)
op.create_table('marketplace_import_jobs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('source_url', sa.String(), nullable=False),
sa.Column('marketplace', sa.String(), nullable=False),
sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('imported_count', sa.Integer(), nullable=True),
sa.Column('updated_count', sa.Integer(), nullable=True),
sa.Column('error_count', sa.Integer(), nullable=True),
sa.Column('total_processed', sa.Integer(), nullable=True),
sa.Column('error_message', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_marketplace_import_vendor_id', 'marketplace_import_jobs', ['vendor_id'], unique=False)
op.create_index('idx_marketplace_import_vendor_status', 'marketplace_import_jobs', ['status'], unique=False)
op.create_index('idx_marketplace_import_user_marketplace', 'marketplace_import_jobs', ['user_id', 'marketplace'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_id'), 'marketplace_import_jobs', ['id'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_marketplace'), 'marketplace_import_jobs', ['marketplace'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_vendor_name'), 'marketplace_import_jobs', ['vendor_name'], unique=False)
op.create_table('vendor_products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('marketplace_product_id', sa.Integer(), nullable=False),
sa.Column('vendor_product_id', sa.String(), nullable=True),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('vendor_sale_price', sa.Float(), nullable=True),
sa.Column('vendor_currency', sa.String(), nullable=True),
sa.Column('vendor_availability', sa.String(), nullable=True),
sa.Column('vendor_condition', sa.String(), nullable=True),
sa.Column('is_featured', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
sa.Column('min_quantity', sa.Integer(), nullable=True),
sa.Column('max_quantity', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['marketplace_product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('vendor_id', 'marketplace_product_id', name='uq_vendor_product')
)
op.create_index('idx_vendor_product_active', 'vendor_products', ['vendor_id', 'is_active'], unique=False)
op.create_index('idx_vendor_product_featured', 'vendor_products', ['vendor_id', 'is_featured'], unique=False)
op.create_index(op.f('ix_vendor_products_id'), 'vendor_products', ['id'], unique=False)
op.create_table('stock',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('gtin', sa.String(), nullable=False),
sa.Column('location', sa.String(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('reserved_quantity', sa.Integer(), nullable=True),
sa.Column('vendor_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('gtin', 'location', name='uq_stock_gtin_location')
)
op.create_index('idx_stock_gtin_location', 'stock', ['gtin', 'location'], unique=False)
op.create_index(op.f('ix_stock_gtin'), 'stock', ['gtin'], unique=False)
op.create_index(op.f('ix_stock_id'), 'stock', ['id'], unique=False)
op.create_index(op.f('ix_stock_location'), 'stock', ['location'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_stock_location'), table_name='stock')
op.drop_index(op.f('ix_stock_id'), table_name='stock')
op.drop_index(op.f('ix_stock_gtin'), table_name='stock')
op.drop_index('idx_stock_gtin_location', table_name='stock')
op.drop_table('stock')
op.drop_index(op.f('ix_vendor_products_id'), table_name='vendor_products')
op.drop_index('idx_vendor_product_featured', table_name='vendor_products')
op.drop_index('idx_vendor_product_active', table_name='vendor_products')
op.drop_table('vendor_products')
op.drop_index(op.f('ix_marketplace_import_jobs_vendor_name'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_marketplace_import_jobs_marketplace'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_marketplace_import_jobs_id'), table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_user_marketplace', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_vendor_status', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_vendor_id', table_name='marketplace_import_jobs')
op.drop_table('marketplace_import_jobs')
op.drop_index(op.f('ix_vendors_vendor_code'), table_name='vendors')
op.drop_index(op.f('ix_vendors_id'), table_name='vendors')
op.drop_table('vendors')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_products_vendor_name'), table_name='products')
op.drop_index(op.f('ix_products_product_id'), table_name='products')
op.drop_index(op.f('ix_products_marketplace'), table_name='products')
op.drop_index(op.f('ix_products_id'), table_name='products')
op.drop_index(op.f('ix_products_gtin'), table_name='products')
op.drop_index(op.f('ix_products_google_product_category'), table_name='products')
op.drop_index(op.f('ix_products_brand'), table_name='products')
op.drop_index(op.f('ix_products_availability'), table_name='products')
op.drop_index('idx_marketplace_shop', table_name='products')
op.drop_index('idx_marketplace_brand', table_name='products')
op.drop_table('products')
# ### end Alembic commands ###

View File

@@ -53,7 +53,7 @@ def get_user_vendor(
if not vendor:
raise VendorNotFoundException(vendor_code)
if current_user.role != "admin" and vendor.owner_id != current_user.id:
if current_user.role != "admin" and vendor.owner_user_id != current_user.id:
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
return vendor

View File

@@ -1,22 +1,48 @@
# app/api/main.py
"""Summary description ....
"""
API router configuration for multi-tenant ecommerce platform.
This module provides classes and functions for:
- ....
- ....
- ....
This module provides:
- API version 1 route aggregation
- Route organization by user type (admin, vendor, public)
- Proper route prefixing and tagging
"""
from fastapi import APIRouter
from app.api.v1 import admin, auth, marketplace, vendor, stats, stock
from app.api.v1 import admin, vendor, public
api_router = APIRouter()
# Include all route modules
api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(marketplace.router, tags=["marketplace"])
api_router.include_router(vendor.router, tags=["vendor"])
api_router.include_router(stats.router, tags=["statistics"])
api_router.include_router(stock.router, tags=["stock"])
# ============================================================================
# ADMIN ROUTES (Platform-level management)
# Prefix: /api/v1/admin
# ============================================================================
api_router.include_router(
admin.router,
prefix="/v1/admin",
tags=["admin"]
)
# ============================================================================
# VENDOR ROUTES (Vendor-scoped operations)
# Prefix: /api/v1/vendor
# ============================================================================
api_router.include_router(
vendor.router,
prefix="/v1/vendor",
tags=["vendor"]
)
# ============================================================================
# PUBLIC/CUSTOMER ROUTES (Customer-facing)
# Prefix: /api/v1/public
# ============================================================================
api_router.include_router(
public.router,
prefix="/v1/public",
tags=["public"]
)

View File

@@ -0,0 +1,8 @@
# app/api/v1/__init__.py
"""
API Version 1 - All endpoints
"""
from . import admin, vendor, public
__all__ = ["admin", "vendor", "public"]

View File

@@ -1,125 +0,0 @@
# app/api/v1/admin.py
"""
Admin endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- User management (view, toggle status)
- Vendor management (view, verify, toggle status)
- Marketplace import job monitoring
- Admin dashboard statistics
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.core.database import get_db
from app.services.admin_service import admin_service
from models.schemas.auth import UserResponse
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.schemas.vendor import VendorListResponse
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@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 = admin_service.get_all_users(db=db, skip=skip, limit=limit)
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, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
return {"message": message}
@router.get("/admin/vendors", response_model=VendorListResponse)
def get_all_vendors_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 vendors with admin view (Admin only)."""
vendors, total = admin_service.get_all_vendors(db=db, skip=skip, limit=limit)
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.put("/admin/vendors/{vendor_id}/verify")
def verify_vendor(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Verify/unverify vendor (Admin only)."""
vendor, message = admin_service.verify_vendor(db, vendor_id)
return {"message": message}
@router.put("/admin/vendors/{vendor_id}/status")
def toggle_vendor_status(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Toggle vendor active status (Admin only)."""
vendor, message = admin_service.toggle_vendor_status(db, vendor_id)
return {"message": message}
@router.get(
"/admin/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse]
)
def get_all_marketplace_import_jobs(
marketplace: Optional[str] = Query(None),
vendor_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)."""
return admin_service.get_marketplace_import_jobs(
db=db,
marketplace=marketplace,
vendor_name=vendor_name,
status=status,
skip=skip,
limit=limit,
)
@router.get("/admin/stats/users")
def get_user_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get user statistics for admin dashboard (Admin only)."""
return admin_service.get_user_statistics(db)
@router.get("/admin/stats/vendors")
def get_vendor_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get vendor statistics for admin dashboard (Admin only)."""
return admin_service.get_vendor_statistics(db)

View File

@@ -0,0 +1,17 @@
"""
Admin API endpoints.
"""
from fastapi import APIRouter
from . import auth, vendors, dashboard, users
# Create admin router
router = APIRouter()
# Include all admin sub-routers
router.include_router(auth.router, tags=["admin-auth"])
router.include_router(vendors.router, tags=["admin-vendors"])
router.include_router(dashboard.router, tags=["admin-dashboard"])
router.include_router(users.router, tags=["admin-users"])
__all__ = ["router"]

58
app/api/v1/admin/auth.py Normal file
View File

@@ -0,0 +1,58 @@
# app/api/v1/admin/auth.py
"""
Admin authentication endpoints.
This module provides:
- Admin user login
- Admin token validation
- Admin-specific authentication logic
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.auth_service import auth_service
from app.exceptions import InvalidCredentialsException
from models.schemas.auth import LoginResponse, UserLogin
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/login", response_model=LoginResponse)
def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
"""
Admin login endpoint.
Only allows users with 'admin' role to login.
Returns JWT token for authenticated admin users.
"""
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
# Verify user is admin
if login_result["user"].role != "admin":
logger.warning(f"Non-admin user attempted admin login: {user_credentials.username}")
raise InvalidCredentialsException("Admin access required")
logger.info(f"Admin login successful: {login_result['user'].username}")
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["user"],
)
@router.post("/logout")
def admin_logout():
"""
Admin logout endpoint.
Client should remove token from storage.
Server-side token invalidation can be implemented here if needed.
"""
return {"message": "Logged out successfully"}

View File

@@ -0,0 +1,90 @@
# app/api/v1/admin/dashboard.py
"""
Admin dashboard and statistics endpoints.
"""
import logging
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.core.database import get_db
from app.services.admin_service import admin_service
from app.services.stats_service import stats_service
from models.database.user import User
from models.schemas.stats import MarketplaceStatsResponse, StatsResponse
router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)
@router.get("")
def get_admin_dashboard(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get admin dashboard with platform statistics (Admin only)."""
return {
"platform": {
"name": "Multi-Tenant Ecommerce Platform",
"version": "1.0.0",
},
"users": admin_service.get_user_statistics(db),
"vendors": admin_service.get_vendor_statistics(db),
"recent_vendors": admin_service.get_recent_vendors(db, limit=5),
"recent_imports": admin_service.get_recent_import_jobs(db, limit=10),
}
@router.get("/stats", response_model=StatsResponse)
def get_comprehensive_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get comprehensive platform statistics (Admin only)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
return StatsResponse(
total_products=stats_data["total_products"],
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_vendors=stats_data["unique_vendors"],
total_inventory_entries=stats_data["total_inventory_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@router.get("/stats/marketplace", response_model=List[MarketplaceStatsResponse])
def get_marketplace_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get statistics broken down by marketplace (Admin only)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
return [
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats
]
@router.get("/stats/platform")
def get_platform_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get comprehensive platform statistics (Admin only)."""
return {
"users": admin_service.get_user_statistics(db),
"vendors": admin_service.get_vendor_statistics(db),
"products": admin_service.get_product_statistics(db),
"orders": admin_service.get_order_statistics(db),
"imports": admin_service.get_import_statistics(db),
}

View File

@@ -0,0 +1,49 @@
# app/api/v1/admin/marketplace.py
"""
Marketplace import job monitoring endpoints for admin.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.core.database import get_db
from app.services.admin_service import admin_service
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.database.user import User
router = APIRouter(prefix="/marketplace-import-jobs")
logger = logging.getLogger(__name__)
@router.get("", response_model=List[MarketplaceImportJobResponse])
def get_all_marketplace_import_jobs(
marketplace: Optional[str] = Query(None),
vendor_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)."""
return admin_service.get_marketplace_import_jobs(
db=db,
marketplace=marketplace,
vendor_name=vendor_name,
status=status,
skip=skip,
limit=limit,
)
@router.get("/stats")
def get_import_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get marketplace import statistics (Admin only)."""
return admin_service.get_import_statistics(db)

51
app/api/v1/admin/users.py Normal file
View File

@@ -0,0 +1,51 @@
# app/api/v1/admin/users.py
"""
User management endpoints for admin.
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.core.database import get_db
from app.services.admin_service import admin_service
from models.schemas.auth import UserResponse
from models.database.user import User
router = APIRouter(prefix="/users")
logger = logging.getLogger(__name__)
@router.get("", 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 = admin_service.get_all_users(db=db, skip=skip, limit=limit)
return [UserResponse.model_validate(user) for user in users]
@router.put("/{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, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
return {"message": message}
@router.get("/stats")
def get_user_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get user statistics for admin dashboard (Admin only)."""
return admin_service.get_user_statistics(db)

143
app/api/v1/admin/vendors.py Normal file
View File

@@ -0,0 +1,143 @@
# app/api/v1/admin/vendors.py
"""
Vendor management endpoints for admin.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user
from app.core.database import get_db
from app.services.admin_service import admin_service
from models.schemas.vendor import VendorListResponse, VendorResponse, VendorCreate
from models.database.user import User
router = APIRouter(prefix="/vendors")
logger = logging.getLogger(__name__)
@router.post("", response_model=VendorResponse)
def create_vendor_with_owner(
vendor_data: VendorCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""
Create a new vendor with owner user account (Admin only).
This endpoint:
1. Creates a new vendor
2. Creates an owner user account for the vendor
3. Sets up default roles (Owner, Manager, Editor, Viewer)
4. Sends welcome email to vendor owner (if email service configured)
Returns the created vendor with owner information.
"""
vendor, owner_user, temp_password = admin_service.create_vendor_with_owner(
db=db,
vendor_data=vendor_data
)
return {
**VendorResponse.model_validate(vendor).model_dump(),
"owner_email": owner_user.email,
"owner_username": owner_user.username,
"temporary_password": temp_password, # Only shown once!
"login_url": f"{vendor.subdomain}.platform.com/vendor/login" if vendor.subdomain else None
}
@router.get("", response_model=VendorListResponse)
def get_all_vendors_admin(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None, description="Search by name or vendor code"),
is_active: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get all vendors with admin view (Admin only)."""
vendors, total = admin_service.get_all_vendors(
db=db,
skip=skip,
limit=limit,
search=search,
is_active=is_active,
is_verified=is_verified
)
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.get("/{vendor_id}", response_model=VendorResponse)
def get_vendor_details(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get detailed vendor information (Admin only)."""
vendor = admin_service.get_vendor_by_id(db, vendor_id)
return VendorResponse.model_validate(vendor)
@router.put("/{vendor_id}/verify")
def verify_vendor(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Verify/unverify vendor (Admin only)."""
vendor, message = admin_service.verify_vendor(db, vendor_id)
return {"message": message, "vendor": VendorResponse.model_validate(vendor)}
@router.put("/{vendor_id}/status")
def toggle_vendor_status(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Toggle vendor active status (Admin only)."""
vendor, message = admin_service.toggle_vendor_status(db, vendor_id)
return {"message": message, "vendor": VendorResponse.model_validate(vendor)}
@router.delete("/{vendor_id}")
def delete_vendor(
vendor_id: int,
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""
Delete vendor and all associated data (Admin only).
WARNING: This is destructive and will delete:
- Vendor account
- All products
- All orders
- All customers
- All team members
Requires confirmation parameter.
"""
if not confirm:
raise HTTPException(
status_code=400,
detail="Deletion requires confirmation parameter: confirm=true"
)
message = admin_service.delete_vendor(db, vendor_id)
return {"message": message}
@router.get("/stats/vendors")
def get_vendor_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get vendor statistics for admin dashboard (Admin only)."""
return admin_service.get_vendor_statistics(db)

View File

@@ -1,50 +0,0 @@
# app/api/v1/auth.py
"""
Authentication endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- User registration and validation
- User authentication and JWT token generation
- Current user information retrieval
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.auth_service import auth_service
from models.schemas.auth import (LoginResponse, UserLogin, UserRegister,
UserResponse)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/auth/register", response_model=UserResponse)
def register_user(user_data: UserRegister, db: Session = Depends(get_db)):
"""Register a new user."""
user = auth_service.register_user(db=db, user_data=user_data)
return UserResponse.model_validate(user)
@router.post("/auth/login", response_model=LoginResponse)
def login_user(user_credentials: UserLogin, db: Session = Depends(get_db)):
"""Login user and return JWT token."""
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=UserResponse.model_validate(login_result["user"]),
)
@router.get("/auth/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)

View File

@@ -1,264 +0,0 @@
# app/api/v1/marketplace_products.py
"""
MarketplaceProduct endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- MarketplaceProduct CRUD operations with marketplace support
- Advanced product filtering and search
- MarketplaceProduct export functionality
- MarketplaceProduct import from marketplace CSV files
- Import job management and monitoring
- Import statistics and job cancellation
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.marketplace_product_service import marketplace_product_service
from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
MarketplaceImportJobRequest)
from models.schemas.marketplace_product import (MarketplaceProductCreate, MarketplaceProductDetailResponse,
MarketplaceProductListResponse, MarketplaceProductResponse,
MarketplaceProductUpdate)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCT ENDPOINTS
# ============================================================================
@router.get("/marketplace/product/export-csv")
async def export_csv(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor 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():
return marketplace_product_service.generate_csv_export(
db=db, marketplace=marketplace, vendor_name=vendor_name
)
filename = "marketplace_products_export"
if marketplace:
filename += f"_{marketplace}"
if vendor_name:
filename += f"_{vendor_name}"
filename += ".csv"
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/marketplace/product", response_model=MarketplaceProductListResponse)
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"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor 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 vendor (Protected)."""
products, total = marketplace_product_service.get_products_with_filters(
db=db,
skip=skip,
limit=limit,
brand=brand,
category=category,
availability=availability,
marketplace=marketplace,
vendor_name=vendor_name,
search=search,
)
return MarketplaceProductListResponse(
products=products, total=total, skip=skip, limit=limit
)
@router.post("/marketplace/product", response_model=MarketplaceProductResponse)
def create_product(
product: MarketplaceProductCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new product with validation and marketplace support (Protected)."""
logger.info(f"Starting product creation for ID: {product.marketplace_product_id}")
db_product = marketplace_product_service.create_product(db, product)
logger.info("MarketplaceProduct created successfully")
return db_product
@router.get("/marketplace/product/{marketplace_product_id}", response_model=MarketplaceProductDetailResponse)
def get_product(
marketplace_product_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get product with stock information (Protected)."""
product = marketplace_product_service.get_product_by_id_or_raise(db, marketplace_product_id)
# Get stock information if GTIN exists
stock_info = None
if product.gtin:
stock_info = marketplace_product_service.get_stock_info(db, product.gtin)
return MarketplaceProductDetailResponse(product=product, stock_info=stock_info)
@router.put("/marketplace/product/{marketplace_product_id}", response_model=MarketplaceProductResponse)
def update_product(
marketplace_product_id: str,
product_update: MarketplaceProductUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update product with validation and marketplace support (Protected)."""
updated_product = marketplace_product_service.update_product(db, marketplace_product_id, product_update)
return updated_product
@router.delete("/marketplace/product/{marketplace_product_id}")
def delete_product(
marketplace_product_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete product and associated stock (Protected)."""
marketplace_product_service.delete_product(db, marketplace_product_id)
return {"message": "MarketplaceProduct and associated stock deleted successfully"}
# ============================================================================
# IMPORT JOB ENDPOINTS
# ============================================================================
@router.post("/marketplace/import-product", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600) # Limit marketplace imports
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
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.vendor_code} by user {current_user.username}"
)
# Create import job through service
import_job = marketplace_import_job_service.create_import_job(db, request, current_user)
# Process in background
background_tasks.add_task(
process_marketplace_import,
import_job.id,
request.url,
request.marketplace,
request.vendor_code,
request.batch_size or 1000,
)
return MarketplaceImportJobResponse(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
vendor_code=request.vendor_code,
vendor_id=import_job.vendor_id,
vendor_name=import_job.vendor_name,
message=f"Marketplace import started from {request.marketplace}. Check status with "
f"/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 = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
return marketplace_import_job_service.convert_to_response_model(job)
@router.get(
"/marketplace/import-jobs", response_model=List[MarketplaceImportJobResponse]
)
def get_marketplace_import_jobs(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor 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)."""
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
user=current_user,
marketplace=marketplace,
vendor_name=vendor_name,
skip=skip,
limit=limit,
)
return [marketplace_import_job_service.convert_to_response_model(job) for job in jobs]
@router.get("/marketplace/marketplace-import-stats")
def get_marketplace_import_stats(
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
"""Get statistics about marketplace import jobs (Protected)."""
return marketplace_import_job_service.get_job_stats(db, current_user)
@router.put(
"/marketplace/import-jobs/{job_id}/cancel",
response_model=MarketplaceImportJobResponse,
)
def cancel_marketplace_import_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel a pending or running marketplace import job (Protected)."""
job = marketplace_import_job_service.cancel_import_job(db, job_id, current_user)
return marketplace_import_job_service.convert_to_response_model(job)
@router.delete("/marketplace/import-jobs/{job_id}")
def delete_marketplace_import_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a completed marketplace import job (Protected)."""
marketplace_import_job_service.delete_import_job(db, job_id, current_user)
return {"message": "Marketplace import job deleted successfully"}

View File

@@ -0,0 +1,18 @@
# app/api/v1/public/__init__.py
"""
Public API endpoints (customer-facing).
"""
from fastapi import APIRouter
from .vendors import auth, products, cart, orders
# Create public router
router = APIRouter()
# Include all public sub-routers
router.include_router(auth.router, prefix="/vendors", tags=["public-auth"])
router.include_router(products.router, prefix="/vendors", tags=["public-products"])
router.include_router(cart.router, prefix="/vendors", tags=["public-cart"])
router.include_router(orders.router, prefix="/vendors", tags=["public-orders"])
__all__ = ["router"]

2
app/api/v1/public/vendors/__init__.py vendored Normal file
View File

@@ -0,0 +1,2 @@
# app/api/v1/public/vendors/__init__.py
"""Vendor-specific public API endpoints"""

175
app/api/v1/public/vendors/auth.py vendored Normal file
View File

@@ -0,0 +1,175 @@
# app/api/v1/public/vendors/auth.py
"""
Customer authentication endpoints (public-facing).
This module provides:
- Customer registration (vendor-scoped)
- Customer login (vendor-scoped)
- Customer password reset
"""
import logging
from fastapi import APIRouter, Depends, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.customer_service import customer_service
from app.exceptions import VendorNotFoundException
from models.schemas.auth import LoginResponse, UserLogin
from models.schemas.customer import CustomerRegister, CustomerResponse
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/{vendor_id}/customers/register", response_model=CustomerResponse)
def register_customer(
vendor_id: int,
customer_data: CustomerRegister,
db: Session = Depends(get_db)
):
"""
Register a new customer for a specific vendor.
Customer accounts are vendor-scoped - each vendor has independent customers.
Same email can be used for different vendors.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Create customer account
customer = customer_service.register_customer(
db=db,
vendor_id=vendor_id,
customer_data=customer_data
)
logger.info(
f"New customer registered: {customer.email} "
f"for vendor {vendor.vendor_code}"
)
return CustomerResponse.model_validate(customer)
@router.post("/{vendor_id}/customers/login", response_model=LoginResponse)
def customer_login(
vendor_id: int,
user_credentials: UserLogin,
db: Session = Depends(get_db)
):
"""
Customer login for a specific vendor.
Authenticates customer and returns JWT token.
Customer must belong to the specified vendor.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Authenticate customer
login_result = customer_service.login_customer(
db=db,
vendor_id=vendor_id,
credentials=user_credentials
)
logger.info(
f"Customer login successful: {login_result['customer'].email} "
f"for vendor {vendor.vendor_code}"
)
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["customer"], # Return customer as user
)
@router.post("/{vendor_id}/customers/logout")
def customer_logout(vendor_id: int):
"""
Customer logout.
Client should remove token from storage.
"""
return {"message": "Logged out successfully"}
@router.post("/{vendor_id}/customers/forgot-password")
def forgot_password(
vendor_id: int,
email: str,
db: Session = Depends(get_db)
):
"""
Request password reset for customer.
Sends password reset email to customer if account exists.
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# TODO: Implement password reset logic
# - Generate reset token
# - Send email with reset link
# - Store token in database
logger.info(f"Password reset requested for {email} in vendor {vendor.vendor_code}")
return {
"message": "If an account exists, a password reset link has been sent",
"email": email
}
@router.post("/{vendor_id}/customers/reset-password")
def reset_password(
vendor_id: int,
token: str,
new_password: str,
db: Session = Depends(get_db)
):
"""
Reset customer password using reset token.
Validates token and updates password.
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# TODO: Implement password reset logic
# - Validate reset token
# - Check token expiration
# - Update password
# - Invalidate token
logger.info(f"Password reset completed for vendor {vendor.vendor_code}")
return {"message": "Password reset successful"}

164
app/api/v1/public/vendors/cart.py vendored Normal file
View File

@@ -0,0 +1,164 @@
# app/api/v1/public/vendors/cart.py
"""
Shopping cart endpoints (customer-facing).
"""
import logging
from fastapi import APIRouter, Depends, Path, Body
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.core.database import get_db
from app.services.cart_service import cart_service
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
class AddToCartRequest(BaseModel):
"""Request model for adding to cart."""
product_id: int = Field(..., description="Product ID to add")
quantity: int = Field(1, ge=1, description="Quantity to add")
class UpdateCartItemRequest(BaseModel):
"""Request model for updating cart item."""
quantity: int = Field(..., ge=1, description="New quantity")
@router.get("/{vendor_id}/cart/{session_id}")
def get_cart(
vendor_id: int = Path(..., description="Vendor ID"),
session_id: str = Path(..., description="Session ID"),
db: Session = Depends(get_db),
):
"""
Get shopping cart contents.
No authentication required - uses session ID.
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
cart = cart_service.get_cart(
db=db,
vendor_id=vendor_id,
session_id=session_id
)
return cart
@router.post("/{vendor_id}/cart/{session_id}/items")
def add_to_cart(
vendor_id: int = Path(..., description="Vendor ID"),
session_id: str = Path(..., description="Session ID"),
cart_data: AddToCartRequest = Body(...),
db: Session = Depends(get_db),
):
"""
Add product to cart.
No authentication required - uses session ID.
"""
# Verify vendor
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
result = cart_service.add_to_cart(
db=db,
vendor_id=vendor_id,
session_id=session_id,
product_id=cart_data.product_id,
quantity=cart_data.quantity
)
return result
@router.put("/{vendor_id}/cart/{session_id}/items/{product_id}")
def update_cart_item(
vendor_id: int = Path(..., description="Vendor ID"),
session_id: str = Path(..., description="Session ID"),
product_id: int = Path(..., description="Product ID"),
cart_data: UpdateCartItemRequest = Body(...),
db: Session = Depends(get_db),
):
"""Update cart item quantity."""
# Verify vendor
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
result = cart_service.update_cart_item(
db=db,
vendor_id=vendor_id,
session_id=session_id,
product_id=product_id,
quantity=cart_data.quantity
)
return result
@router.delete("/{vendor_id}/cart/{session_id}/items/{product_id}")
def remove_from_cart(
vendor_id: int = Path(..., description="Vendor ID"),
session_id: str = Path(..., description="Session ID"),
product_id: int = Path(..., description="Product ID"),
db: Session = Depends(get_db),
):
"""Remove item from cart."""
# Verify vendor
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
result = cart_service.remove_from_cart(
db=db,
vendor_id=vendor_id,
session_id=session_id,
product_id=product_id
)
return result
@router.delete("/{vendor_id}/cart/{session_id}")
def clear_cart(
vendor_id: int = Path(..., description="Vendor ID"),
session_id: str = Path(..., description="Session ID"),
db: Session = Depends(get_db),
):
"""Clear all items from cart."""
result = cart_service.clear_cart(
db=db,
vendor_id=vendor_id,
session_id=session_id
)
return result

163
app/api/v1/public/vendors/orders.py vendored Normal file
View File

@@ -0,0 +1,163 @@
# app/api/v1/public/vendors/orders.py
"""
Customer order endpoints (public-facing).
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.order_service import order_service
from app.services.customer_service import customer_service
from models.schemas.order import (
OrderCreate,
OrderResponse,
OrderDetailResponse,
OrderListResponse
)
from models.database.vendor import Vendor
from models.database.customer import Customer
router = APIRouter()
logger = logging.getLogger(__name__)
def get_current_customer(
vendor_id: int,
customer_id: int,
db: Session
) -> Customer:
"""Helper to get and verify customer."""
customer = customer_service.get_customer(
db=db,
vendor_id=vendor_id,
customer_id=customer_id
)
return customer
@router.post("/{vendor_id}/orders", response_model=OrderResponse)
def place_order(
vendor_id: int = Path(..., description="Vendor ID"),
order_data: OrderCreate = ...,
db: Session = Depends(get_db),
):
"""
Place a new order.
Customer must be authenticated to place an order.
This endpoint creates an order from the customer's cart.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Create order
order = order_service.create_order(
db=db,
vendor_id=vendor_id,
order_data=order_data
)
logger.info(
f"Order {order.order_number} placed for vendor {vendor.vendor_code}, "
f"total: €{order.total_amount:.2f}"
)
# TODO: Update customer stats
# TODO: Clear cart
# TODO: Send order confirmation email
return OrderResponse.model_validate(order)
@router.get("/{vendor_id}/customers/{customer_id}/orders", response_model=OrderListResponse)
def get_customer_orders(
vendor_id: int = Path(..., description="Vendor ID"),
customer_id: int = Path(..., description="Customer ID"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
):
"""
Get order history for customer.
Returns all orders placed by the authenticated customer.
"""
# Verify vendor
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Verify customer belongs to vendor
customer = get_current_customer(vendor_id, customer_id, db)
# Get orders
orders, total = order_service.get_customer_orders(
db=db,
vendor_id=vendor_id,
customer_id=customer_id,
skip=skip,
limit=limit
)
return OrderListResponse(
orders=[OrderResponse.model_validate(o) for o in orders],
total=total,
skip=skip,
limit=limit
)
@router.get("/{vendor_id}/customers/{customer_id}/orders/{order_id}", response_model=OrderDetailResponse)
def get_customer_order_details(
vendor_id: int = Path(..., description="Vendor ID"),
customer_id: int = Path(..., description="Customer ID"),
order_id: int = Path(..., description="Order ID"),
db: Session = Depends(get_db),
):
"""
Get detailed order information for customer.
Customer can only view their own orders.
"""
# Verify vendor
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Verify customer
customer = get_current_customer(vendor_id, customer_id, db)
# Get order
order = order_service.get_order(
db=db,
vendor_id=vendor_id,
order_id=order_id
)
# Verify order belongs to customer
if order.customer_id != customer_id:
from app.exceptions import OrderNotFoundException
raise OrderNotFoundException(str(order_id))
return OrderDetailResponse.model_validate(order)

138
app/api/v1/public/vendors/products.py vendored Normal file
View File

@@ -0,0 +1,138 @@
# app/api/v1/public/vendors/products.py
"""
Public product catalog endpoints (customer-facing).
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.product_service import product_service
from models.schemas.product import ProductResponse, ProductDetailResponse, ProductListResponse
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/{vendor_id}/products", response_model=ProductListResponse)
def get_public_product_catalog(
vendor_id: int = Path(..., description="Vendor ID"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None, description="Search products by name"),
is_featured: Optional[bool] = Query(None),
db: Session = Depends(get_db),
):
"""
Get public product catalog for a vendor.
Only returns active products visible to customers.
No authentication required.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Get only active products for public view
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
is_active=True, # Only show active products to customers
is_featured=is_featured
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit
)
@router.get("/{vendor_id}/products/{product_id}", response_model=ProductDetailResponse)
def get_public_product_details(
vendor_id: int = Path(..., description="Vendor ID"),
product_id: int = Path(..., description="Product ID"),
db: Session = Depends(get_db),
):
"""
Get detailed product information for customers.
No authentication required.
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
product = product_service.get_product(
db=db,
vendor_id=vendor_id,
product_id=product_id
)
# Check if product is active
if not product.is_active:
from app.exceptions import ProductNotActiveException
raise ProductNotActiveException(str(product_id))
return ProductDetailResponse.model_validate(product)
@router.get("/{vendor_id}/products/search")
def search_products(
vendor_id: int = Path(..., description="Vendor ID"),
q: str = Query(..., min_length=1, description="Search query"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
):
"""
Search products in vendor catalog.
Searches in product names, descriptions, and SKUs.
No authentication required.
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(
Vendor.id == vendor_id,
Vendor.is_active == True
).first()
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# TODO: Implement search functionality
# For now, return filtered products
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
is_active=True
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit
)

View File

@@ -1,111 +0,0 @@
# app/api/v1/stats.py
"""
Statistics endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Comprehensive system statistics
- Marketplace-specific analytics
- Performance metrics and data insights
"""
import logging
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.stats_service import stats_service
from models.schemas.stats import MarketplaceStatsResponse, StatsResponse
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@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)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
return StatsResponse(
total_products=stats_data["total_products"],
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_vendors=stats_data["unique_vendors"],
total_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@router.get("/stats/marketplace", 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)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
# app/api/v1/stats.py
"""
Statistics endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Comprehensive system statistics
- Marketplace-specific analytics
- Performance metrics and data insights
"""
import logging
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.stats_service import stats_service
from models.schemas.stats import MarketplaceStatsResponse, StatsResponse
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@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)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
return StatsResponse(
total_products=stats_data["total_products"],
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_vendors=stats_data["unique_vendors"],
total_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@router.get("/stats/marketplace", 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)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
return [
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats
]

View File

@@ -1,112 +0,0 @@
# app/api/v1/stock.py
"""
Stock endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Stock quantity management (set, add, remove)
- Stock information retrieval and filtering
- Location-based stock tracking
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.stock_service import stock_service
from models.schemas.stock import (StockAdd, StockCreate, StockResponse,
StockSummaryResponse, StockUpdate)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@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)."""
return stock_service.set_stock(db, 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)."""
return stock_service.add_stock(db, 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."""
return stock_service.remove_stock(db, 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."""
return stock_service.get_stock_by_gtin(db, gtin)
@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."""
return stock_service.get_total_stock(db, gtin)
@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."""
return stock_service.get_all_stock(
db=db, skip=skip, limit=limit, location=location, gtin=gtin
)
@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."""
return stock_service.update_stock(db, stock_id, stock_update)
@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_service.delete_stock(db, stock_id)
return {"message": "Stock entry deleted successfully"}

View File

@@ -1,137 +0,0 @@
# app/api/v1/vendor.py
"""
Vendor endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Vendor CRUD operations and management
- Vendor product catalog management
- Vendor filtering and verification
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_user_vendor
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from models.schemas.vendor import (VendorCreate, VendorListResponse, VendorResponse)
from models.schemas.product import (ProductCreate,ProductResponse)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/vendor", response_model=VendorResponse)
def create_vendor(
vendor_data: VendorCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new vendor (Protected)."""
vendor = vendor_service.create_vendor(
db=db, vendor_data=vendor_data, current_user=current_user
)
return VendorResponse.model_validate(vendor)
@router.get("/vendor", response_model=VendorListResponse)
def get_vendors(
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 vendors with filtering (Protected)."""
vendors, total = vendor_service.get_vendors(
db=db,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
verified_only=verified_only,
)
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.get("/vendor/{vendor_code}", response_model=VendorResponse)
def get_vendor(
vendor_code: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get vendor details (Protected)."""
vendor = vendor_service.get_vendor_by_code(
db=db, vendor_code=vendor_code, current_user=current_user
)
return VendorResponse.model_validate(vendor)
@router.post("/vendor/{vendor_code}/products", response_model=ProductResponse)
def add_product_to_catalog(
vendor_code: str,
product: ProductCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add existing product to vendor catalog with vendor -specific settings (Protected)."""
# Get and verify vendor (using existing dependency)
vendor = get_user_vendor(vendor_code, current_user, db)
# Add product to vendor
new_product = vendor_service.add_product_to_catalog(
db=db, vendor=vendor, product=product
)
# Return with product details
response = ProductResponse.model_validate(new_product)
response.marketplace_product = new_product.marketplace_product
return response
@router.get("/vendor/{vendor_code}/products")
def get_products(
vendor_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 vendor catalog (Protected)."""
# Get vendor
vendor = vendor_service.get_vendor_by_code(
db=db, vendor_code=vendor_code, current_user=current_user
)
# Get vendor products
vendor_products, total = vendor_service.get_products(
db=db,
vendor=vendor,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
featured_only=featured_only,
)
# Format response
products = []
for vp in vendor_products:
product_response = ProductResponse.model_validate(vp)
product_response.marketplace_product = vp.marketplace_product
products.append(product_response)
return {
"products": products,
"total": total,
"skip": skip,
"limit": limit,
"vendor": VendorResponse.model_validate(vendor),
}

21
app/api/v1/vendor/__init__.py vendored Normal file
View File

@@ -0,0 +1,21 @@
# app/api/v1/vendor/__init__.py
"""
Vendor API endpoints.
"""
from fastapi import APIRouter
from . import auth, dashboard, products, orders, marketplace, inventory, vendor
# Create vendor router
router = APIRouter()
# Include all vendor sub-routers
router.include_router(auth.router, tags=["vendor-auth"])
router.include_router(dashboard.router, tags=["vendor-dashboard"])
router.include_router(products.router, tags=["vendor-products"])
router.include_router(orders.router, tags=["vendor-orders"])
router.include_router(marketplace.router, tags=["vendor-marketplace"])
router.include_router(inventory.router, tags=["vendor-inventory"])
router.include_router(vendor.router, tags=["vendor-management"])
__all__ = ["router"]

83
app/api/v1/vendor/auth.py vendored Normal file
View File

@@ -0,0 +1,83 @@
# app/api/v1/vendor/auth.py
"""
Vendor team authentication endpoints.
This module provides:
- Vendor team member login
- Vendor owner login
- Vendor-scoped authentication
"""
import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.auth_service import auth_service
from app.exceptions import InvalidCredentialsException
from middleware.vendor_context import get_current_vendor
from models.schemas.auth import LoginResponse, UserLogin
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/login", response_model=LoginResponse)
def vendor_login(
user_credentials: UserLogin,
request: Request,
db: Session = Depends(get_db)
):
"""
Vendor team member login.
Authenticates users who are part of a vendor team.
Validates against vendor context if available.
"""
# Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
user = login_result["user"]
# Prevent admin users from using vendor login
if user.role == "admin":
logger.warning(f"Admin user attempted vendor login: {user.username}")
raise InvalidCredentialsException("Please use admin portal to login")
# Optional: Validate user belongs to current vendor context
vendor = get_current_vendor(request)
if vendor:
# Check if user is vendor owner or team member
is_owner = any(v.id == vendor.id for v in user.owned_vendors)
is_team_member = any(
vm.vendor_id == vendor.id and vm.is_active
for vm in user.vendor_memberships
)
if not (is_owner or is_team_member):
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
logger.info(f"Vendor team login successful: {user.username}")
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["user"],
)
@router.post("/logout")
def vendor_logout():
"""
Vendor team member logout.
Client should remove token from storage.
"""
return {"message": "Logged out successfully"}

62
app/api/v1/vendor/dashboard.py vendored Normal file
View File

@@ -0,0 +1,62 @@
# app/api/v1/vendor/dashboard.py
"""
Vendor dashboard and statistics endpoints.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.stats_service import stats_service
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)
@router.get("/stats")
def get_vendor_dashboard_stats(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Get vendor-specific dashboard statistics.
Returns statistics for the current vendor only:
- Total products in catalog
- Total orders
- Total customers
- Revenue metrics
"""
# Get vendor-scoped statistics
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor.id)
return {
"vendor": {
"id": vendor.id,
"name": vendor.name,
"vendor_code": vendor.vendor_code,
},
"products": {
"total": stats_data.get("total_products", 0),
"active": stats_data.get("active_products", 0),
},
"orders": {
"total": stats_data.get("total_orders", 0),
"pending": stats_data.get("pending_orders", 0),
"completed": stats_data.get("completed_orders", 0),
},
"customers": {
"total": stats_data.get("total_customers", 0),
"active": stats_data.get("active_customers", 0),
},
"revenue": {
"total": stats_data.get("total_revenue", 0),
"this_month": stats_data.get("revenue_this_month", 0),
}
}

141
app/api/v1/vendor/inventory.py vendored Normal file
View File

@@ -0,0 +1,141 @@
# app/api/v1/vendor/inventory.py
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.inventory_service import inventory_service
from models.schemas.inventory import (
InventoryCreate,
InventoryAdjust,
InventoryUpdate,
InventoryReserve,
InventoryResponse,
ProductInventorySummary,
InventoryListResponse
)
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory(
inventory: InventoryCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
return inventory_service.set_inventory(db, vendor.id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: InventoryAdjust,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
return inventory_service.adjust_inventory(db, vendor.id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse)
def reserve_inventory(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
return inventory_service.reserve_inventory(db, vendor.id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse)
def release_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
return inventory_service.release_reservation(db, vendor.id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse)
def fulfill_reservation(
reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
return inventory_service.fulfill_reservation(db, vendor.id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
return inventory_service.get_product_inventory(db, vendor.id, product_id)
@router.get("/inventory", response_model=InventoryListResponse)
def get_vendor_inventory(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
location: Optional[str] = Query(None),
low_stock: Optional[int] = Query(None, ge=0),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get all inventory for vendor."""
inventories = inventory_service.get_vendor_inventory(
db, vendor.id, skip, limit, location, low_stock
)
# Get total count
total = len(inventories) # You might want a separate count query for large datasets
return InventoryListResponse(
inventories=inventories,
total=total,
skip=skip,
limit=limit
)
@router.put("/inventory/{inventory_id}", response_model=InventoryResponse)
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update inventory entry."""
return inventory_service.update_inventory(db, vendor.id, inventory_id, inventory_update)
@router.delete("/inventory/{inventory_id}")
def delete_inventory(
inventory_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete inventory entry."""
inventory_service.delete_inventory(db, vendor.id, inventory_id)
return {"message": "Inventory deleted successfully"}

115
app/api/v1/vendor/marketplace.py vendored Normal file
View File

@@ -0,0 +1,115 @@
# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route
"""
Marketplace import endpoints for vendors.
Vendor context is automatically injected by middleware.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context # IMPORTANT
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit
from models.schemas.marketplace_import_job import (
MarketplaceImportJobResponse,
MarketplaceImportJobRequest
)
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected)."""
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username}"
)
# Create import job (vendor comes from middleware)
import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user
)
# Process in background
background_tasks.add_task(
process_marketplace_import,
import_job.id,
request.source_url, # FIXED: was request.url
request.marketplace,
vendor.id, # Pass vendor_id instead of vendor_code
request.batch_size or 1000,
)
return MarketplaceImportJobResponse(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code,
vendor_name=vendor.name, # FIXED: from vendor object
source_url=request.source_url,
message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}",
imported=0,
updated=0,
total_processed=0,
error_count=0,
created_at=import_job.created_at,
)
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status(
job_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
# Verify job belongs to current vendor
if job.vendor_id != vendor.id:
from app.exceptions import UnauthorizedVendorAccessException
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
return marketplace_import_job_service.convert_to_response_model(job)
@router.get("/imports", response_model=List[MarketplaceImportJobResponse])
def get_marketplace_import_jobs(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
vendor=vendor,
user=current_user,
marketplace=marketplace,
skip=skip,
limit=limit,
)
return [marketplace_import_job_service.convert_to_response_model(job) for job in jobs]

111
app/api/v1/vendor/orders.py vendored Normal file
View File

@@ -0,0 +1,111 @@
# app/api/v1/vendor/orders.py
"""
Vendor order management endpoints.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.order_service import order_service
from models.schemas.order import (
OrderResponse,
OrderDetailResponse,
OrderListResponse,
OrderUpdate
)
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/orders")
logger = logging.getLogger(__name__)
@router.get("", response_model=OrderListResponse)
def get_vendor_orders(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
status: Optional[str] = Query(None, description="Filter by order status"),
customer_id: Optional[int] = Query(None, description="Filter by customer"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Get all orders for vendor.
Supports filtering by:
- status: Order status (pending, processing, shipped, delivered, cancelled)
- customer_id: Filter orders from specific customer
"""
orders, total = order_service.get_vendor_orders(
db=db,
vendor_id=vendor.id,
skip=skip,
limit=limit,
status=status,
customer_id=customer_id
)
return OrderListResponse(
orders=[OrderResponse.model_validate(o) for o in orders],
total=total,
skip=skip,
limit=limit
)
@router.get("/{order_id}", response_model=OrderDetailResponse)
def get_order_details(
order_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get detailed order information including items and addresses."""
order = order_service.get_order(
db=db,
vendor_id=vendor.id,
order_id=order_id
)
return OrderDetailResponse.model_validate(order)
@router.put("/{order_id}/status", response_model=OrderResponse)
def update_order_status(
order_id: int,
order_update: OrderUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Update order status and tracking information.
Valid statuses:
- pending: Order placed, awaiting processing
- processing: Order being prepared
- shipped: Order shipped to customer
- delivered: Order delivered
- cancelled: Order cancelled
- refunded: Order refunded
"""
order = order_service.update_order_status(
db=db,
vendor_id=vendor.id,
order_id=order_id,
order_update=order_update
)
logger.info(
f"Order {order.order_number} status updated to {order.status} "
f"by user {current_user.username}"
)
return OrderResponse.model_validate(order)

227
app/api/v1/vendor/products.py vendored Normal file
View File

@@ -0,0 +1,227 @@
# app/api/v1/vendor/products.py
"""
Vendor product catalog management endpoints.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.product_service import product_service
from models.schemas.product import (
ProductCreate,
ProductUpdate,
ProductResponse,
ProductDetailResponse,
ProductListResponse
)
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/products")
logger = logging.getLogger(__name__)
@router.get("", response_model=ProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
is_active: Optional[bool] = Query(None),
is_featured: Optional[bool] = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Get all products in vendor catalog.
Supports filtering by:
- is_active: Filter active/inactive products
- is_featured: Filter featured products
"""
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor.id,
skip=skip,
limit=limit,
is_active=is_active,
is_featured=is_featured
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit
)
@router.get("/{product_id}", response_model=ProductDetailResponse)
def get_product_details(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
product = product_service.get_product(
db=db,
vendor_id=vendor.id,
product_id=product_id
)
return ProductDetailResponse.model_validate(product)
@router.post("", response_model=ProductResponse)
def add_product_to_catalog(
product_data: ProductCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Add a product from marketplace to vendor catalog.
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
product = product_service.create_product(
db=db,
vendor_id=vendor.id,
product_data=product_data
)
logger.info(
f"Product {product.id} added to catalog by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
)
return ProductResponse.model_validate(product)
@router.put("/{product_id}", response_model=ProductResponse)
def update_product(
product_id: int,
product_data: ProductUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
product = product_service.update_product(
db=db,
vendor_id=vendor.id,
product_id=product_id,
product_update=product_data
)
logger.info(
f"Product {product_id} updated by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
)
return ProductResponse.model_validate(product)
@router.delete("/{product_id}")
def remove_product_from_catalog(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
product_service.delete_product(
db=db,
vendor_id=vendor.id,
product_id=product_id
)
logger.info(
f"Product {product_id} removed from catalog by user {current_user.username} "
f"for vendor {vendor.vendor_code}"
)
return {"message": f"Product {product_id} removed from catalog"}
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
def publish_from_marketplace(
marketplace_product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Publish a marketplace product to vendor catalog.
Shortcut endpoint for publishing directly from marketplace import.
"""
product_data = ProductCreate(
marketplace_product_id=marketplace_product_id,
is_active=True
)
product = product_service.create_product(
db=db,
vendor_id=vendor.id,
product_data=product_data
)
logger.info(
f"Marketplace product {marketplace_product_id} published to catalog "
f"by user {current_user.username} for vendor {vendor.vendor_code}"
)
return ProductResponse.model_validate(product)
@router.put("/{product_id}/toggle-active")
def toggle_product_active(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Toggle product active status."""
product = product_service.get_product(db, vendor.id, product_id)
product.is_active = not product.is_active
db.commit()
db.refresh(product)
status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
return {
"message": f"Product {status}",
"is_active": product.is_active
}
@router.put("/{product_id}/toggle-featured")
def toggle_product_featured(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
product = product_service.get_product(db, vendor.id, product_id)
product.is_featured = not product.is_featured
db.commit()
db.refresh(product)
status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
return {
"message": f"Product {status}",
"is_featured": product.is_featured
}

330
app/api/v1/vendor/vendor.py vendored Normal file
View File

@@ -0,0 +1,330 @@
# app/api/v1/vendor/vendor.py
"""
Vendor management endpoints for vendor-scoped operations.
This module provides:
- Vendor profile management
- Vendor settings configuration
- Vendor team member management
- Vendor dashboard statistics
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.vendor_service import vendor_service
from app.services.team_service import team_service
from models.schemas.vendor import VendorUpdate, VendorResponse
from models.schemas.product import ProductResponse, ProductListResponse
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# VENDOR PROFILE ENDPOINTS
# ============================================================================
@router.get("/profile", response_model=VendorResponse)
def get_vendor_profile(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get current vendor profile information."""
return vendor
@router.put("/profile", response_model=VendorResponse)
def update_vendor_profile(
vendor_update: VendorUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update vendor profile information."""
# Verify user has permission to update vendor
if not vendor_service.can_update_vendor(vendor, current_user):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Insufficient permissions")
return vendor_service.update_vendor(db, vendor.id, vendor_update)
# ============================================================================
# VENDOR SETTINGS ENDPOINTS
# ============================================================================
@router.get("/settings")
def get_vendor_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get vendor settings and configuration."""
return {
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
"name": vendor.name,
"contact_email": vendor.contact_email,
"contact_phone": vendor.contact_phone,
"website": vendor.website,
"business_address": vendor.business_address,
"tax_number": vendor.tax_number,
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
"theme_config": vendor.theme_config,
"is_active": vendor.is_active,
"is_verified": vendor.is_verified,
}
@router.put("/settings/marketplace")
def update_marketplace_settings(
marketplace_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update marketplace integration settings."""
# Verify permissions
if not vendor_service.can_update_vendor(vendor, current_user):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Insufficient permissions")
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config:
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
if "letzshop_csv_url_en" in marketplace_config:
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
if "letzshop_csv_url_de" in marketplace_config:
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
db.commit()
db.refresh(vendor)
return {
"message": "Marketplace settings updated successfully",
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
}
@router.put("/settings/theme")
def update_theme_settings(
theme_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update vendor theme configuration."""
if not vendor_service.can_update_vendor(vendor, current_user):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Insufficient permissions")
vendor.theme_config = theme_config
db.commit()
db.refresh(vendor)
return {
"message": "Theme settings updated successfully",
"theme_config": vendor.theme_config,
}
# ============================================================================
# VENDOR CATALOG ENDPOINTS
# ============================================================================
@router.get("/products", response_model=ProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
is_active: Optional[bool] = Query(None),
is_featured: Optional[bool] = Query(None),
search: Optional[str] = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get all products in vendor catalog."""
products, total = vendor_service.get_products(
db=db,
vendor=vendor,
current_user=current_user,
skip=skip,
limit=limit,
active_only=is_active,
featured_only=is_featured,
)
return ProductListResponse(
products=products,
total=total,
skip=skip,
limit=limit
)
@router.post("/products", response_model=ProductResponse)
def add_product_to_catalog(
product_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Add a product from marketplace to vendor catalog."""
from models.schemas.product import ProductCreate
product_create = ProductCreate(**product_data)
return vendor_service.add_product_to_catalog(db, vendor, product_create)
@router.get("/products/{product_id}", response_model=ProductResponse)
def get_vendor_product(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get a specific product from vendor catalog."""
from app.services.product_service import product_service
return product_service.get_product(db, vendor.id, product_id)
@router.put("/products/{product_id}", response_model=ProductResponse)
def update_vendor_product(
product_id: int,
product_update: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
from app.services.product_service import product_service
from models.schemas.product import ProductUpdate
product_update_schema = ProductUpdate(**product_update)
return product_service.update_product(db, vendor.id, product_id, product_update_schema)
@router.delete("/products/{product_id}")
def remove_product_from_catalog(
product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
from app.services.product_service import product_service
product_service.delete_product(db, vendor.id, product_id)
return {"message": "Product removed from catalog successfully"}
# ============================================================================
# VENDOR TEAM ENDPOINTS
# ============================================================================
@router.get("/team/members")
def get_team_members(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get all team members for vendor."""
return team_service.get_team_members(db, vendor.id, current_user)
@router.post("/team/invite")
def invite_team_member(
invitation_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Invite a new team member."""
return team_service.invite_team_member(db, vendor.id, invitation_data, current_user)
@router.put("/team/members/{user_id}")
def update_team_member(
user_id: int,
update_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update team member role or status."""
return team_service.update_team_member(db, vendor.id, user_id, update_data, current_user)
@router.delete("/team/members/{user_id}")
def remove_team_member(
user_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Remove team member from vendor."""
team_service.remove_team_member(db, vendor.id, user_id, current_user)
return {"message": "Team member removed successfully"}
@router.get("/team/roles")
def get_team_roles(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get available roles for vendor team."""
return team_service.get_vendor_roles(db, vendor.id)
# ============================================================================
# VENDOR DASHBOARD & STATISTICS
# ============================================================================
@router.get("/dashboard")
def get_vendor_dashboard(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get vendor dashboard statistics."""
from app.services.stats_service import stats_service
return {
"vendor": {
"code": vendor.vendor_code,
"name": vendor.name,
"subdomain": vendor.subdomain,
"is_verified": vendor.is_verified,
},
"stats": stats_service.get_vendor_stats(db, vendor.id),
"recent_imports": [], # TODO: Implement
"recent_orders": [], # TODO: Implement
"low_stock_products": [], # TODO: Implement
}
@router.get("/analytics")
def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get vendor analytics data."""
from app.services.stats_service import stats_service
return stats_service.get_vendor_analytics(db, vendor.id, period)

View File

@@ -38,12 +38,12 @@ from .marketplace_product import (
MarketplaceProductCSVImportException,
)
from .stock import (
StockNotFoundException,
InsufficientStockException,
InvalidStockOperationException,
StockValidationException,
NegativeStockException,
from .inventory import (
InventoryNotFoundException,
InsufficientInventoryException,
InvalidInventoryOperationException,
InventoryValidationException,
NegativeInventoryException,
InvalidQuantityException,
LocationNotFoundException
)
@@ -59,9 +59,50 @@ from .vendor import (
VendorValidationException,
)
from .customer import (
CustomerNotFoundException,
CustomerAlreadyExistsException,
DuplicateCustomerEmailException,
CustomerNotActiveException,
InvalidCustomerCredentialsException,
CustomerValidationException,
CustomerAuthorizationException,
)
from .team import (
TeamMemberNotFoundException,
TeamMemberAlreadyExistsException,
TeamInvitationNotFoundException,
TeamInvitationExpiredException,
TeamInvitationAlreadyAcceptedException,
UnauthorizedTeamActionException,
CannotRemoveOwnerException,
CannotModifyOwnRoleException,
RoleNotFoundException,
InvalidRoleException,
InsufficientPermissionsException,
MaxTeamMembersReachedException,
TeamValidationException,
InvalidInvitationDataException,
)
from .product import (
ProductNotFoundException,
ProductAlreadyExistsException,
ProductNotInCatalogException,
ProductNotActiveException,
InvalidProductDataException,
ProductValidationException,
CannotDeleteProductWithInventoryException,
CannotDeleteProductWithOrdersException,
)
from .order import (
OrderNotFoundException,
OrderAlreadyExistsException,
OrderValidationException,
InvalidOrderStatusException,
OrderCannotBeCancelledException,
)
from .marketplace_import_job import (
@@ -100,6 +141,7 @@ __all__ = [
"BusinessLogicException",
"ExternalServiceException",
"RateLimitException",
"ServiceUnavailableException",
# Auth exceptions
"InvalidCredentialsException",
@@ -110,20 +152,37 @@ __all__ = [
"AdminRequiredException",
"UserAlreadyExistsException",
# MarketplaceProduct exceptions
"MarketplaceProductNotFoundException",
"MarketplaceProductAlreadyExistsException",
"InvalidMarketplaceProductDataException",
"MarketplaceProductValidationException",
"InvalidGTINException",
"MarketplaceProductCSVImportException",
# Customer exceptions
"CustomerNotFoundException",
"CustomerAlreadyExistsException",
"DuplicateCustomerEmailException",
"CustomerNotActiveException",
"InvalidCustomerCredentialsException",
"CustomerValidationException",
"CustomerAuthorizationException",
# Stock exceptions
"StockNotFoundException",
"InsufficientStockException",
"InvalidStockOperationException",
"StockValidationException",
"NegativeStockException",
# Team exceptions
"TeamMemberNotFoundException",
"TeamMemberAlreadyExistsException",
"TeamInvitationNotFoundException",
"TeamInvitationExpiredException",
"TeamInvitationAlreadyAcceptedException",
"UnauthorizedTeamActionException",
"CannotRemoveOwnerException",
"CannotModifyOwnRoleException",
"RoleNotFoundException",
"InvalidRoleException",
"InsufficientPermissionsException",
"MaxTeamMembersReachedException",
"TeamValidationException",
"InvalidInvitationDataException",
# Inventory exceptions
"InventoryNotFoundException",
"InsufficientInventoryException",
"InvalidInventoryOperationException",
"InventoryValidationException",
"NegativeInventoryException",
"InvalidQuantityException",
"LocationNotFoundException",
@@ -138,8 +197,29 @@ __all__ = [
"VendorValidationException",
# Product exceptions
"ProductAlreadyExistsException",
"ProductNotFoundException",
"ProductAlreadyExistsException",
"ProductNotInCatalogException",
"ProductNotActiveException",
"InvalidProductDataException",
"ProductValidationException",
"CannotDeleteProductWithInventoryException",
"CannotDeleteProductWithOrdersException",
# Order exceptions
"OrderNotFoundException",
"OrderAlreadyExistsException",
"OrderValidationException",
"InvalidOrderStatusException",
"OrderCannotBeCancelledException",
# MarketplaceProduct exceptions
"MarketplaceProductNotFoundException",
"MarketplaceProductAlreadyExistsException",
"InvalidMarketplaceProductDataException",
"MarketplaceProductValidationException",
"InvalidGTINException",
"MarketplaceProductCSVImportException",
# Marketplace import exceptions
"MarketplaceImportException",

102
app/exceptions/customer.py Normal file
View File

@@ -0,0 +1,102 @@
# app/exceptions/customer.py
"""
Customer management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import (
ResourceNotFoundException,
ConflictException,
ValidationException,
AuthenticationException,
BusinessLogicException
)
class CustomerNotFoundException(ResourceNotFoundException):
"""Raised when a customer is not found."""
def __init__(self, customer_identifier: str):
super().__init__(
resource_type="Customer",
identifier=customer_identifier,
message=f"Customer '{customer_identifier}' not found",
error_code="CUSTOMER_NOT_FOUND"
)
class CustomerAlreadyExistsException(ConflictException):
"""Raised when trying to create a customer that already exists."""
def __init__(self, email: str):
super().__init__(
message=f"Customer with email '{email}' already exists",
error_code="CUSTOMER_ALREADY_EXISTS",
details={"email": email}
)
class DuplicateCustomerEmailException(ConflictException):
"""Raised when email already exists for vendor."""
def __init__(self, email: str, vendor_code: str):
super().__init__(
message=f"Email '{email}' is already registered for this vendor",
error_code="DUPLICATE_CUSTOMER_EMAIL",
details={
"email": email,
"vendor_code": vendor_code
}
)
class CustomerNotActiveException(BusinessLogicException):
"""Raised when trying to perform operations on inactive customer."""
def __init__(self, email: str):
super().__init__(
message=f"Customer account '{email}' is not active",
error_code="CUSTOMER_NOT_ACTIVE",
details={"email": email}
)
class InvalidCustomerCredentialsException(AuthenticationException):
"""Raised when customer credentials are invalid."""
def __init__(self):
super().__init__(
message="Invalid email or password",
error_code="INVALID_CUSTOMER_CREDENTIALS"
)
class CustomerValidationException(ValidationException):
"""Raised when customer data validation fails."""
def __init__(
self,
message: str = "Customer validation failed",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None
):
super().__init__(
message=message,
field=field,
details=details
)
self.error_code = "CUSTOMER_VALIDATION_FAILED"
class CustomerAuthorizationException(BusinessLogicException):
"""Raised when customer is not authorized for operation."""
def __init__(self, customer_email: str, operation: str):
super().__init__(
message=f"Customer '{customer_email}' not authorized for: {operation}",
error_code="CUSTOMER_NOT_AUTHORIZED",
details={
"customer_email": customer_email,
"operation": operation
}
)

View File

@@ -1,31 +1,31 @@
# app/exceptions/stock.py
# app/exceptions/inventory.py
"""
Stock management specific exceptions.
Inventory management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import ResourceNotFoundException, ValidationException, BusinessLogicException
class StockNotFoundException(ResourceNotFoundException):
"""Raised when stock record is not found."""
class InventoryNotFoundException(ResourceNotFoundException):
"""Raised when inventory record is not found."""
def __init__(self, identifier: str, identifier_type: str = "ID"):
if identifier_type.lower() == "gtin":
message = f"No stock found for GTIN '{identifier}'"
message = f"No inventory found for GTIN '{identifier}'"
else:
message = f"Stock record with {identifier_type} '{identifier}' not found"
message = f"Inventory record with {identifier_type} '{identifier}' not found"
super().__init__(
resource_type="Stock",
resource_type="Inventory",
identifier=identifier,
message=message,
error_code="STOCK_NOT_FOUND",
error_code="INVENTORY_NOT_FOUND",
)
class InsufficientStockException(BusinessLogicException):
"""Raised when trying to remove more stock than available."""
class InsufficientInventoryException(BusinessLogicException):
"""Raised when trying to remove more inventory than available."""
def __init__(
self,
@@ -34,11 +34,11 @@ class InsufficientStockException(BusinessLogicException):
requested: int,
available: int,
):
message = f"Insufficient stock for GTIN '{gtin}' at '{location}'. Requested: {requested}, Available: {available}"
message = f"Insufficient inventory for GTIN '{gtin}' at '{location}'. Requested: {requested}, Available: {available}"
super().__init__(
message=message,
error_code="INSUFFICIENT_STOCK",
error_code="INSUFFICIENT_INVENTORY",
details={
"gtin": gtin,
"location": location,
@@ -48,8 +48,8 @@ class InsufficientStockException(BusinessLogicException):
)
class InvalidStockOperationException(ValidationException):
"""Raised when stock operation is invalid."""
class InvalidInventoryOperationException(ValidationException):
"""Raised when inventory operation is invalid."""
def __init__(
self,
@@ -67,15 +67,15 @@ class InvalidStockOperationException(ValidationException):
message=message,
details=details,
)
self.error_code = "INVALID_STOCK_OPERATION"
self.error_code = "INVALID_INVENTORY_OPERATION"
class StockValidationException(ValidationException):
"""Raised when stock data validation fails."""
class InventoryValidationException(ValidationException):
"""Raised when inventory data validation fails."""
def __init__(
self,
message: str = "Stock validation failed",
message: str = "Inventory validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
@@ -88,18 +88,18 @@ class StockValidationException(ValidationException):
field=field,
details=details,
)
self.error_code = "STOCK_VALIDATION_FAILED"
self.error_code = "INVENTORY_VALIDATION_FAILED"
class NegativeStockException(BusinessLogicException):
"""Raised when stock quantity would become negative."""
class NegativeInventoryException(BusinessLogicException):
"""Raised when inventory quantity would become negative."""
def __init__(self, gtin: str, location: str, resulting_quantity: int):
message = f"Stock operation would result in negative quantity ({resulting_quantity}) for GTIN '{gtin}' at '{location}'"
message = f"Inventory operation would result in negative quantity ({resulting_quantity}) for GTIN '{gtin}' at '{location}'"
super().__init__(
message=message,
error_code="NEGATIVE_STOCK_NOT_ALLOWED",
error_code="NEGATIVE_INVENTORY_NOT_ALLOWED",
details={
"gtin": gtin,
"location": location,
@@ -121,12 +121,12 @@ class InvalidQuantityException(ValidationException):
class LocationNotFoundException(ResourceNotFoundException):
"""Raised when stock location is not found."""
"""Raised when inventory location is not found."""
def __init__(self, location: str):
super().__init__(
resource_type="Location",
identifier=location,
message=f"Stock location '{location}' not found",
message=f"Inventory location '{location}' not found",
error_code="LOCATION_NOT_FOUND",
)

73
app/exceptions/order.py Normal file
View File

@@ -0,0 +1,73 @@
# app/exceptions/order.py
"""
Order management specific exceptions.
"""
from typing import Optional
from .base import (
ResourceNotFoundException,
ValidationException,
BusinessLogicException
)
class OrderNotFoundException(ResourceNotFoundException):
"""Raised when an order is not found."""
def __init__(self, order_identifier: str):
super().__init__(
resource_type="Order",
identifier=order_identifier,
message=f"Order '{order_identifier}' not found",
error_code="ORDER_NOT_FOUND"
)
class OrderAlreadyExistsException(ValidationException):
"""Raised when trying to create a duplicate order."""
def __init__(self, order_number: str):
super().__init__(
message=f"Order with number '{order_number}' already exists",
error_code="ORDER_ALREADY_EXISTS",
details={"order_number": order_number}
)
class OrderValidationException(ValidationException):
"""Raised when order data validation fails."""
def __init__(self, message: str, details: Optional[dict] = None):
super().__init__(
message=message,
error_code="ORDER_VALIDATION_FAILED",
details=details
)
class InvalidOrderStatusException(BusinessLogicException):
"""Raised when trying to set an invalid order status."""
def __init__(self, current_status: str, new_status: str):
super().__init__(
message=f"Cannot change order status from '{current_status}' to '{new_status}'",
error_code="INVALID_ORDER_STATUS_CHANGE",
details={
"current_status": current_status,
"new_status": new_status
}
)
class OrderCannotBeCancelledException(BusinessLogicException):
"""Raised when order cannot be cancelled."""
def __init__(self, order_number: str, reason: str):
super().__init__(
message=f"Order '{order_number}' cannot be cancelled: {reason}",
error_code="ORDER_CANNOT_BE_CANCELLED",
details={
"order_number": order_number,
"reason": reason
}
)

View File

@@ -1,34 +1,142 @@
# app/exceptions/vendor.py
# app/exceptions/product.py
"""
Vendor management specific exceptions.
Product (vendor catalog) specific exceptions.
"""
from typing import Optional
from .base import (
ResourceNotFoundException,
ConflictException
ConflictException,
ValidationException,
BusinessLogicException
)
class ProductAlreadyExistsException(ConflictException):
"""Raised when trying to add a product that already exists in vendor."""
def __init__(self, vendor_code: str, marketplace_product_id: str):
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a product is not found in vendor catalog."""
def __init__(self, product_id: int, vendor_id: Optional[int] = None):
details = {"product_id": product_id}
if vendor_id:
details["vendor_id"] = vendor_id
message = f"Product with ID '{product_id}' not found in vendor {vendor_id} catalog"
else:
message = f"Product with ID '{product_id}' not found"
super().__init__(
message=f"MarketplaceProduct '{marketplace_product_id}' already exists in vendor '{vendor_code}'",
resource_type="Product",
identifier=str(product_id),
message=message,
error_code="PRODUCT_NOT_FOUND",
details=details,
)
class ProductAlreadyExistsException(ConflictException):
"""Raised when trying to add a marketplace product that's already in vendor catalog."""
def __init__(self, vendor_id: int, marketplace_product_id: int):
super().__init__(
message=f"Marketplace product {marketplace_product_id} already exists in vendor {vendor_id} catalog",
error_code="PRODUCT_ALREADY_EXISTS",
details={
"vendor_code": vendor_code,
"vendor_id": vendor_id,
"marketplace_product_id": marketplace_product_id,
},
)
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a vendor product relationship is not found."""
class ProductNotInCatalogException(ResourceNotFoundException):
"""Raised when trying to access a product that's not in vendor's catalog."""
def __init__(self, vendor_code: str, marketplace_product_id: str):
def __init__(self, product_id: int, vendor_id: int):
super().__init__(
resource_type="Product",
identifier=f"{vendor_code}/{marketplace_product_id}",
message=f"MarketplaceProduct '{marketplace_product_id}' not found in vendor '{vendor_code}'",
error_code="PRODUCT_NOT_FOUND",
identifier=str(product_id),
message=f"Product {product_id} is not in vendor {vendor_id} catalog",
error_code="PRODUCT_NOT_IN_CATALOG",
details={
"product_id": product_id,
"vendor_id": vendor_id,
},
)
class ProductNotActiveException(BusinessLogicException):
"""Raised when trying to perform operations on inactive product."""
def __init__(self, product_id: int, vendor_id: int):
super().__init__(
message=f"Product {product_id} in vendor {vendor_id} catalog is not active",
error_code="PRODUCT_NOT_ACTIVE",
details={
"product_id": product_id,
"vendor_id": vendor_id,
},
)
class InvalidProductDataException(ValidationException):
"""Raised when product data is invalid."""
def __init__(
self,
message: str = "Invalid product data",
field: Optional[str] = None,
details: Optional[dict] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_PRODUCT_DATA"
class ProductValidationException(ValidationException):
"""Raised when product validation fails."""
def __init__(
self,
message: str = "Product validation failed",
field: Optional[str] = None,
validation_errors: Optional[dict] = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "PRODUCT_VALIDATION_FAILED"
class CannotDeleteProductWithInventoryException(BusinessLogicException):
"""Raised when trying to delete a product that has inventory."""
def __init__(self, product_id: int, inventory_count: int):
super().__init__(
message=f"Cannot delete product {product_id} - it has {inventory_count} inventory entries",
error_code="CANNOT_DELETE_PRODUCT_WITH_INVENTORY",
details={
"product_id": product_id,
"inventory_count": inventory_count,
},
)
class CannotDeleteProductWithOrdersException(BusinessLogicException):
"""Raised when trying to delete a product that has been ordered."""
def __init__(self, product_id: int, order_count: int):
super().__init__(
message=f"Cannot delete product {product_id} - it has {order_count} associated orders",
error_code="CANNOT_DELETE_PRODUCT_WITH_ORDERS",
details={
"product_id": product_id,
"order_count": order_count,
},
)

236
app/exceptions/team.py Normal file
View File

@@ -0,0 +1,236 @@
# app/exceptions/team.py
"""
Team management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import (
ResourceNotFoundException,
ConflictException,
ValidationException,
AuthorizationException,
BusinessLogicException
)
class TeamMemberNotFoundException(ResourceNotFoundException):
"""Raised when a team member is not found."""
def __init__(self, user_id: int, vendor_id: Optional[int] = None):
details = {"user_id": user_id}
if vendor_id:
details["vendor_id"] = vendor_id
message = f"Team member with user ID '{user_id}' not found in vendor {vendor_id}"
else:
message = f"Team member with user ID '{user_id}' not found"
super().__init__(
resource_type="TeamMember",
identifier=str(user_id),
message=message,
error_code="TEAM_MEMBER_NOT_FOUND",
details=details,
)
class TeamMemberAlreadyExistsException(ConflictException):
"""Raised when trying to add a user who is already a team member."""
def __init__(self, user_id: int, vendor_id: int):
super().__init__(
message=f"User {user_id} is already a team member of vendor {vendor_id}",
error_code="TEAM_MEMBER_ALREADY_EXISTS",
details={
"user_id": user_id,
"vendor_id": vendor_id,
},
)
class TeamInvitationNotFoundException(ResourceNotFoundException):
"""Raised when a team invitation is not found."""
def __init__(self, invitation_token: str):
super().__init__(
resource_type="TeamInvitation",
identifier=invitation_token,
message=f"Team invitation with token '{invitation_token}' not found or expired",
error_code="TEAM_INVITATION_NOT_FOUND",
)
class TeamInvitationExpiredException(BusinessLogicException):
"""Raised when trying to accept an expired invitation."""
def __init__(self, invitation_token: str):
super().__init__(
message=f"Team invitation has expired",
error_code="TEAM_INVITATION_EXPIRED",
details={"invitation_token": invitation_token},
)
class TeamInvitationAlreadyAcceptedException(ConflictException):
"""Raised when trying to accept an already accepted invitation."""
def __init__(self, invitation_token: str):
super().__init__(
message="Team invitation has already been accepted",
error_code="TEAM_INVITATION_ALREADY_ACCEPTED",
details={"invitation_token": invitation_token},
)
class UnauthorizedTeamActionException(AuthorizationException):
"""Raised when user tries to perform team action without permission."""
def __init__(self, action: str, user_id: Optional[int] = None, required_permission: Optional[str] = None):
details = {"action": action}
if user_id:
details["user_id"] = user_id
if required_permission:
details["required_permission"] = required_permission
super().__init__(
message=f"Unauthorized to perform action: {action}",
error_code="UNAUTHORIZED_TEAM_ACTION",
details=details,
)
class CannotRemoveOwnerException(BusinessLogicException):
"""Raised when trying to remove the vendor owner from team."""
def __init__(self, user_id: int, vendor_id: int):
super().__init__(
message="Cannot remove vendor owner from team",
error_code="CANNOT_REMOVE_OWNER",
details={
"user_id": user_id,
"vendor_id": vendor_id,
},
)
class CannotModifyOwnRoleException(BusinessLogicException):
"""Raised when user tries to modify their own role."""
def __init__(self, user_id: int):
super().__init__(
message="Cannot modify your own role",
error_code="CANNOT_MODIFY_OWN_ROLE",
details={"user_id": user_id},
)
class RoleNotFoundException(ResourceNotFoundException):
"""Raised when a role is not found."""
def __init__(self, role_id: int, vendor_id: Optional[int] = None):
details = {"role_id": role_id}
if vendor_id:
details["vendor_id"] = vendor_id
message = f"Role with ID '{role_id}' not found in vendor {vendor_id}"
else:
message = f"Role with ID '{role_id}' not found"
super().__init__(
resource_type="Role",
identifier=str(role_id),
message=message,
error_code="ROLE_NOT_FOUND",
details=details,
)
class InvalidRoleException(ValidationException):
"""Raised when role data is invalid."""
def __init__(
self,
message: str = "Invalid role data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_ROLE_DATA"
class InsufficientPermissionsException(AuthorizationException):
"""Raised when user lacks required permissions for an action."""
def __init__(
self,
required_permission: str,
user_id: Optional[int] = None,
action: Optional[str] = None,
):
details = {"required_permission": required_permission}
if user_id:
details["user_id"] = user_id
if action:
details["action"] = action
message = f"Insufficient permissions. Required: {required_permission}"
super().__init__(
message=message,
error_code="INSUFFICIENT_PERMISSIONS",
details=details,
)
class MaxTeamMembersReachedException(BusinessLogicException):
"""Raised when vendor has reached maximum team members limit."""
def __init__(self, max_members: int, vendor_id: int):
super().__init__(
message=f"Maximum number of team members reached ({max_members})",
error_code="MAX_TEAM_MEMBERS_REACHED",
details={
"max_members": max_members,
"vendor_id": vendor_id,
},
)
class TeamValidationException(ValidationException):
"""Raised when team operation validation fails."""
def __init__(
self,
message: str = "Team operation validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "TEAM_VALIDATION_FAILED"
class InvalidInvitationDataException(ValidationException):
"""Raised when team invitation data is invalid."""
def __init__(
self,
message: str = "Invalid invitation data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_INVITATION_DATA"

8
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
# app/routes/__init__.py
"""
Frontend route handlers.
"""
from .frontend import router
__all__ = ["router"]

158
app/routes/frontend.py Normal file
View File

@@ -0,0 +1,158 @@
# app/routes/frontend.py
"""
Frontend HTML route handlers.
Serves static HTML files for admin, vendor, and customer interfaces.
"""
from fastapi import APIRouter
from fastapi.responses import FileResponse
router = APIRouter(include_in_schema=False)
# ============================================================================
# ADMIN ROUTES
# ============================================================================
@router.get("/admin/")
@router.get("/admin/login")
async def admin_login():
"""Serve admin login page"""
return FileResponse("static/admin/login.html")
@router.get("/admin/dashboard")
async def admin_dashboard():
"""Serve admin dashboard page"""
return FileResponse("static/admin/dashboard.html")
@router.get("/admin/vendors")
async def admin_vendors():
"""Serve admin vendors management page"""
return FileResponse("static/admin/vendors.html")
# ============================================================================
# VENDOR ROUTES
# ============================================================================
@router.get("/vendor/")
@router.get("/vendor/login")
async def vendor_login():
"""Serve vendor login page"""
return FileResponse("static/vendor/login.html")
@router.get("/vendor/dashboard")
async def vendor_dashboard():
"""Serve vendor dashboard page"""
return FileResponse("static/vendor/dashboard.html")
@router.get("/vendor/admin/products")
async def vendor_products():
"""Serve vendor products management page"""
return FileResponse("static/vendor/admin/products.html")
@router.get("/vendor/admin/orders")
async def vendor_orders():
"""Serve vendor orders management page"""
return FileResponse("static/vendor/admin/orders.html")
@router.get("/vendor/admin/marketplace")
async def vendor_marketplace():
"""Serve vendor marketplace import page"""
return FileResponse("static/vendor/admin/marketplace.html")
@router.get("/vendor/admin/customers")
async def vendor_customers():
"""Serve vendor customers management page"""
return FileResponse("static/vendor/admin/customers.html")
@router.get("/vendor/admin/inventory")
async def vendor_inventory():
"""Serve vendor inventory management page"""
return FileResponse("static/vendor/admin/inventory.html")
@router.get("/vendor/admin/team")
async def vendor_team():
"""Serve vendor team management page"""
return FileResponse("static/vendor/admin/team.html")
# ============================================================================
# CUSTOMER/SHOP ROUTES
# ============================================================================
@router.get("/shop/")
@router.get("/shop/products")
async def shop_products():
"""Serve shop products catalog page"""
return FileResponse("static/shop/products.html")
@router.get("/shop/products/{product_id}")
async def shop_product_detail(product_id: int):
"""Serve product detail page"""
return FileResponse("static/shop/product.html")
@router.get("/shop/cart")
async def shop_cart():
"""Serve shopping cart page"""
return FileResponse("static/shop/cart.html")
@router.get("/shop/checkout")
async def shop_checkout():
"""Serve checkout page"""
return FileResponse("static/shop/checkout.html")
@router.get("/shop/account/register")
async def shop_register():
"""Serve customer registration page"""
return FileResponse("static/shop/account/register.html")
@router.get("/shop/account/login")
async def shop_login():
"""Serve customer login page"""
return FileResponse("static/shop/account/login.html")
@router.get("/shop/account/dashboard")
async def shop_account_dashboard():
"""Serve customer account dashboard"""
return FileResponse("static/shop/account/dashboard.html")
@router.get("/shop/account/orders")
async def shop_orders():
"""Serve customer orders history page"""
return FileResponse("static/shop/account/orders.html")
@router.get("/shop/account/orders/{order_id}")
async def shop_order_detail(order_id: int):
"""Serve customer order detail page"""
return FileResponse("static/shop/account/order-detail.html")
@router.get("/shop/account/profile")
async def shop_profile():
"""Serve customer profile page"""
return FileResponse("static/shop/account/profile.html")
@router.get("/shop/account/addresses")
async def shop_addresses():
"""Serve customer addresses management page"""
return FileResponse("static/shop/account/addresses.html")

View File

@@ -4,27 +4,35 @@ Admin service for managing users, vendors, and import jobs.
This module provides classes and functions for:
- User management and status control
- Vendor creation with owner user generation
- Vendor verification and activation
- Marketplace import job monitoring
- Platform statistics
"""
import logging
import secrets
import string
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from app.exceptions import (
UserNotFoundException,
UserStatusChangeException,
CannotModifySelfException,
VendorNotFoundException,
VendorAlreadyExistsException,
VendorVerificationException,
AdminOperationException,
ValidationException,
)
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.schemas.vendor import VendorCreate
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.vendor import Vendor
from models.database.vendor import Vendor, Role
from models.database.user import User
logger = logging.getLogger(__name__)
@@ -33,6 +41,10 @@ logger = logging.getLogger(__name__)
class AdminService:
"""Service class for admin operations following the application's service pattern."""
# ============================================================================
# USER MANAGEMENT
# ============================================================================
def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
"""Get paginated list of all users."""
try:
@@ -47,29 +59,14 @@ class AdminService:
def toggle_user_status(
self, db: Session, user_id: int, current_admin_id: int
) -> Tuple[User, str]:
"""
Toggle user active status.
Args:
db: Database session
user_id: ID of user to toggle
current_admin_id: ID of the admin performing the action
Returns:
Tuple of (updated_user, status_message)
Raises:
UserNotFoundException: If user not found
CannotModifySelfException: If trying to modify own account
UserStatusChangeException: If status change is not allowed
"""
"""Toggle user active status."""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent self-modification
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "deactivate account")
# Check if user is another admin - FIXED LOGIC
# Check if user is another admin
if user.role == "admin" and user.id != current_admin_id:
raise UserStatusChangeException(
user_id=user_id,
@@ -101,23 +98,150 @@ class AdminService:
reason="Database update failed"
)
def get_all_vendors(
self, db: Session, skip: int = 0, limit: int = 100
) -> Tuple[List[Vendor], int]:
# ============================================================================
# VENDOR MANAGEMENT
# ============================================================================
def create_vendor_with_owner(
self, db: Session, vendor_data: VendorCreate
) -> Tuple[Vendor, User, str]:
"""
Get paginated list of all vendors with total count.
Create vendor with owner user account.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
Tuple of (vendors_list, total_count)
Returns: (vendor, owner_user, temporary_password)
"""
try:
total = db.query(Vendor).count()
vendors =db.query(Vendor).offset(skip).limit(limit).all()
# Check if vendor code already exists
existing_vendor = db.query(Vendor).filter(
func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper()
).first()
if existing_vendor:
raise VendorAlreadyExistsException(vendor_data.vendor_code)
# Check if subdomain already exists
existing_subdomain = db.query(Vendor).filter(
func.lower(Vendor.subdomain) == vendor_data.subdomain.lower()
).first()
if existing_subdomain:
raise ValidationException(
f"Subdomain '{vendor_data.subdomain}' is already taken"
)
# Generate temporary password for owner
temp_password = self._generate_temp_password()
# Create owner user
from middleware.auth import AuthManager
auth_manager = AuthManager()
owner_username = f"{vendor_data.vendor_code.lower()}_owner"
owner_email = vendor_data.owner_email if hasattr(vendor_data,
'owner_email') else f"{owner_username}@{vendor_data.subdomain}.com"
# Check if user with this email already exists
existing_user = db.query(User).filter(
User.email == owner_email
).first()
if existing_user:
# Use existing user as owner
owner_user = existing_user
else:
# Create new owner user
owner_user = User(
email=owner_email,
username=owner_username,
hashed_password=auth_manager.hash_password(temp_password),
role="user", # Will be vendor owner through relationship
is_active=True,
)
db.add(owner_user)
db.flush() # Get owner_user.id
# Create vendor
vendor = Vendor(
vendor_code=vendor_data.vendor_code.upper(),
subdomain=vendor_data.subdomain.lower(),
name=vendor_data.name,
description=getattr(vendor_data, 'description', None),
owner_user_id=owner_user.id,
contact_email=owner_email,
contact_phone=getattr(vendor_data, 'contact_phone', None),
website=getattr(vendor_data, 'website', None),
business_address=getattr(vendor_data, 'business_address', None),
tax_number=getattr(vendor_data, 'tax_number', None),
letzshop_csv_url_fr=getattr(vendor_data, 'letzshop_csv_url_fr', None),
letzshop_csv_url_en=getattr(vendor_data, 'letzshop_csv_url_en', None),
letzshop_csv_url_de=getattr(vendor_data, 'letzshop_csv_url_de', None),
theme_config=getattr(vendor_data, 'theme_config', {}),
is_active=True,
is_verified=True,
)
db.add(vendor)
db.flush() # Get vendor.id
# Create default roles for vendor
self._create_default_roles(db, vendor.id)
db.commit()
db.refresh(vendor)
db.refresh(owner_user)
logger.info(
f"Vendor {vendor.vendor_code} created with owner {owner_user.username}"
)
# TODO: Send welcome email to owner with credentials
# self._send_vendor_welcome_email(owner_user, vendor, temp_password)
return vendor, owner_user, temp_password
except (VendorAlreadyExistsException, ValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to create vendor: {str(e)}")
raise AdminOperationException(
operation="create_vendor_with_owner",
reason=f"Failed to create vendor: {str(e)}"
)
def get_all_vendors(
self,
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
is_active: Optional[bool] = None,
is_verified: Optional[bool] = None
) -> Tuple[List[Vendor], int]:
"""Get paginated list of all vendors with filtering."""
try:
query = db.query(Vendor)
# Apply search filter
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Vendor.name.ilike(search_term),
Vendor.vendor_code.ilike(search_term),
Vendor.subdomain.ilike(search_term)
)
)
# Apply status filters
if is_active is not None:
query = query.filter(Vendor.is_active == is_active)
if is_verified is not None:
query = query.filter(Vendor.is_verified == is_verified)
total = query.count()
vendors = query.offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Failed to retrieve vendors: {str(e)}")
@@ -126,21 +250,12 @@ class AdminService:
reason="Database query failed"
)
def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID."""
return self._get_vendor_by_id_or_raise(db, vendor_id)
def verify_vendor(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle vendor verification status.
Args:
db: Database session
vendor_id: ID of vendor to verify/unverify
Returns:
Tuple of (updated_vendor, status_message)
Raises:
VendorNotFoundException: If vendor not found
VendorVerificationException: If verification fails
"""
"""Toggle vendor verification status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
@@ -148,7 +263,6 @@ class AdminService:
vendor.is_verified = not vendor.is_verified
vendor.updated_at = datetime.now(timezone.utc)
# Add verification timestamp if implementing audit trail
if vendor.is_verified:
vendor.verified_at = datetime.now(timezone.utc)
@@ -171,20 +285,7 @@ class AdminService:
)
def toggle_vendor_status(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle vendor active status.
Args:
db: Database session
vendor_id: ID of vendor to activate/deactivate
Returns:
Tuple of (updated_vendor, status_message)
Raises:
VendorNotFoundException: If vendor not found
AdminOperationException: If status change fails
"""
"""Toggle vendor active status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
@@ -198,7 +299,7 @@ class AdminService:
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return vendor , message
return vendor, message
except Exception as e:
db.rollback()
@@ -210,6 +311,39 @@ class AdminService:
target_id=str(vendor_id)
)
def delete_vendor(self, db: Session, vendor_id: int) -> str:
"""Delete vendor and all associated data."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
vendor_code = vendor.vendor_code
# TODO: Delete associated data in correct order
# - Delete orders
# - Delete customers
# - Delete products
# - Delete team members
# - Delete roles
# - Delete import jobs
db.delete(vendor)
db.commit()
logger.warning(f"Vendor {vendor_code} and all associated data deleted")
return f"Vendor {vendor_code} successfully deleted"
except Exception as e:
db.rollback()
logger.error(f"Failed to delete vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="delete_vendor",
reason="Database deletion failed"
)
# ============================================================================
# MARKETPLACE IMPORT JOBS
# ============================================================================
def get_marketplace_import_jobs(
self,
db: Session,
@@ -219,24 +353,10 @@ class AdminService:
skip: int = 0,
limit: int = 100,
) -> List[MarketplaceImportJobResponse]:
"""
Get filtered and paginated marketplace import jobs.
Args:
db: Database session
marketplace: Filter by marketplace name (case-insensitive partial match)
vendor_name: Filter by vendor name (case-insensitive partial match)
status: Filter by exact status
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of MarketplaceImportJobResponse objects
"""
"""Get filtered and paginated marketplace import jobs."""
try:
query = db.query(MarketplaceImportJob)
# Apply filters
if marketplace:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
@@ -246,7 +366,6 @@ class AdminService:
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)
@@ -263,17 +382,23 @@ class AdminService:
reason="Database query failed"
)
# ============================================================================
# STATISTICS
# ============================================================================
def get_user_statistics(self, db: Session) -> dict:
"""Get user statistics for admin dashboard."""
try:
total_users = db.query(User).count()
active_users = db.query(User).filter(User.is_active == True).count()
inactive_users = total_users - active_users
admin_users = db.query(User).filter(User.role == "admin").count()
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"admin_users": admin_users,
"activation_rate": (active_users / total_users * 100) if total_users > 0 else 0
}
except Exception as e:
@@ -289,10 +414,12 @@ class AdminService:
total_vendors = db.query(Vendor).count()
active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
verified_vendors = db.query(Vendor).filter(Vendor.is_verified == True).count()
inactive_vendors = total_vendors - active_vendors
return {
"total_vendors": total_vendors,
"active_vendors": active_vendors,
"inactive_vendors": inactive_vendors,
"verified_vendors": verified_vendors,
"verification_rate": (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0
}
@@ -303,7 +430,100 @@ class AdminService:
reason="Database query failed"
)
# Private helper methods
def get_recent_vendors(self, db: Session, limit: int = 5) -> List[dict]:
"""Get recently created vendors."""
try:
vendors = (
db.query(Vendor)
.order_by(Vendor.created_at.desc())
.limit(limit)
.all()
)
return [
{
"id": v.id,
"vendor_code": v.vendor_code,
"name": v.name,
"subdomain": v.subdomain,
"is_active": v.is_active,
"is_verified": v.is_verified,
"created_at": v.created_at
}
for v in vendors
]
except Exception as e:
logger.error(f"Failed to get recent vendors: {str(e)}")
return []
def get_recent_import_jobs(self, db: Session, limit: int = 10) -> List[dict]:
"""Get recent marketplace import jobs."""
try:
jobs = (
db.query(MarketplaceImportJob)
.order_by(MarketplaceImportJob.created_at.desc())
.limit(limit)
.all()
)
return [
{
"id": j.id,
"marketplace": j.marketplace,
"vendor_name": j.vendor_name,
"status": j.status,
"total_processed": j.total_processed or 0,
"created_at": j.created_at
}
for j in jobs
]
except Exception as e:
logger.error(f"Failed to get recent import jobs: {str(e)}")
return []
def get_product_statistics(self, db: Session) -> dict:
"""Get product statistics."""
# TODO: Implement when Product model is available
return {
"total_products": 0,
"active_products": 0,
"out_of_stock": 0
}
def get_order_statistics(self, db: Session) -> dict:
"""Get order statistics."""
# TODO: Implement when Order model is available
return {
"total_orders": 0,
"pending_orders": 0,
"completed_orders": 0
}
def get_import_statistics(self, db: Session) -> dict:
"""Get import job statistics."""
try:
total = db.query(MarketplaceImportJob).count()
completed = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.status == "completed"
).count()
failed = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.status == "failed"
).count()
return {
"total_imports": total,
"completed_imports": completed,
"failed_imports": failed,
"success_rate": (completed / total * 100) if total > 0 else 0
}
except Exception as e:
logger.error(f"Failed to get import statistics: {str(e)}")
return {"total_imports": 0, "completed_imports": 0, "failed_imports": 0, "success_rate": 0}
# ============================================================================
# PRIVATE HELPER METHODS
# ============================================================================
def _get_user_by_id_or_raise(self, db: Session, user_id: int) -> User:
"""Get user by ID or raise UserNotFoundException."""
user = db.query(User).filter(User.id == user_id).first()
@@ -314,9 +534,52 @@ class AdminService:
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundException."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor :
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
return vendor
def _generate_temp_password(self, length: int = 12) -> str:
"""Generate secure temporary password."""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
def _create_default_roles(self, db: Session, vendor_id: int):
"""Create default roles for a new vendor."""
default_roles = [
{
"name": "Owner",
"permissions": ["*"] # Full access
},
{
"name": "Manager",
"permissions": [
"products.*", "orders.*", "customers.view",
"inventory.*", "team.view"
]
},
{
"name": "Editor",
"permissions": [
"products.view", "products.edit",
"orders.view", "inventory.view"
]
},
{
"name": "Viewer",
"permissions": [
"products.view", "orders.view",
"customers.view", "inventory.view"
]
}
]
for role_data in default_roles:
role = Role(
vendor_id=vendor_id,
name=role_data["name"],
permissions=role_data["permissions"]
)
db.add(role)
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
"""Convert database model to response schema."""
@@ -338,5 +601,5 @@ class AdminService:
)
# Create service instance following the same pattern as marketplace_product_service
# Create service instance
admin_service = AdminService()

View File

@@ -0,0 +1,184 @@
# app/services/cart_service.py
"""
Shopping cart service.
This module provides:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
"""
import logging
from typing import Dict, List, Optional
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import and_
from models.database.product import Product
from models.database.vendor import Vendor
from app.exceptions import (
ProductNotFoundException,
ValidationException,
InsufficientInventoryException
)
logger = logging.getLogger(__name__)
class CartService:
"""Service for managing shopping carts."""
def get_cart(
self,
db: Session,
vendor_id: int,
session_id: str
) -> Dict:
"""
Get cart contents for a session.
Note: This is a simple in-memory implementation.
In production, you'd store carts in Redis or database.
"""
# For now, return empty cart structure
# TODO: Implement persistent cart storage
return {
"vendor_id": vendor_id,
"session_id": session_id,
"items": [],
"subtotal": 0.0,
"total": 0.0
}
def add_to_cart(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int = 1
) -> Dict:
"""
Add product to cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
quantity: Quantity to add
Returns:
Updated cart
Raises:
ProductNotFoundException: If product not found
InsufficientInventoryException: If not enough inventory
"""
# Verify product exists and belongs to vendor
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id,
Product.is_active == True
)
).first()
if not product:
raise ProductNotFoundException(str(product_id))
# Check inventory
if product.available_inventory < quantity:
raise InsufficientInventoryException(
product_id=product_id,
requested=quantity,
available=product.available_inventory
)
# TODO: Add to persistent cart storage
# For now, return success response
logger.info(
f"Added product {product_id} (qty: {quantity}) to cart "
f"for session {session_id}"
)
return {
"message": "Product added to cart",
"product_id": product_id,
"quantity": quantity
}
def update_cart_item(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int
) -> Dict:
"""Update quantity of item in cart."""
if quantity < 1:
raise ValidationException("Quantity must be at least 1")
# Verify product
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id
)
).first()
if not product:
raise ProductNotFoundException(str(product_id))
# Check inventory
if product.available_inventory < quantity:
raise InsufficientInventoryException(
product_id=product_id,
requested=quantity,
available=product.available_inventory
)
# TODO: Update persistent cart
logger.info(f"Updated cart item {product_id} quantity to {quantity}")
return {
"message": "Cart updated",
"product_id": product_id,
"quantity": quantity
}
def remove_from_cart(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int
) -> Dict:
"""Remove item from cart."""
# TODO: Remove from persistent cart
logger.info(f"Removed product {product_id} from cart {session_id}")
return {
"message": "Item removed from cart",
"product_id": product_id
}
def clear_cart(
self,
db: Session,
vendor_id: int,
session_id: str
) -> Dict:
"""Clear all items from cart."""
# TODO: Clear persistent cart
logger.info(f"Cleared cart for session {session_id}")
return {
"message": "Cart cleared"
}
# Create service instance
cart_service = CartService()

View File

@@ -0,0 +1,407 @@
# app/services/customer_service.py
"""
Customer management service.
Handles customer registration, authentication, and profile management
with complete vendor isolation.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_
from models.database.customer import Customer, CustomerAddress
from models.database.vendor import Vendor
from models.schemas.customer import CustomerRegister, CustomerUpdate
from models.schemas.auth import UserLogin
from app.exceptions.customer import (
CustomerNotFoundException,
CustomerAlreadyExistsException,
CustomerNotActiveException,
InvalidCustomerCredentialsException,
CustomerValidationException,
DuplicateCustomerEmailException
)
from app.exceptions.vendor import VendorNotFoundException, VendorNotActiveException
from app.services.auth_service import AuthService
logger = logging.getLogger(__name__)
class CustomerService:
"""Service for managing vendor-scoped customers."""
def __init__(self):
self.auth_service = AuthService()
def register_customer(
self,
db: Session,
vendor_id: int,
customer_data: CustomerRegister
) -> Customer:
"""
Register a new customer for a specific vendor.
Args:
db: Database session
vendor_id: Vendor ID
customer_data: Customer registration data
Returns:
Customer: Created customer object
Raises:
VendorNotFoundException: If vendor doesn't exist
VendorNotActiveException: If vendor is not active
DuplicateCustomerEmailException: If email already exists for this vendor
CustomerValidationException: If customer data is invalid
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
if not vendor.is_active:
raise VendorNotActiveException(vendor.vendor_code)
# Check if email already exists for this vendor
existing_customer = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == customer_data.email.lower()
)
).first()
if existing_customer:
raise DuplicateCustomerEmailException(customer_data.email, vendor.vendor_code)
# Generate unique customer number for this vendor
customer_number = self._generate_customer_number(db, vendor_id, vendor.vendor_code)
# Hash password
hashed_password = self.auth_service.hash_password(customer_data.password)
# Create customer
customer = Customer(
vendor_id=vendor_id,
email=customer_data.email.lower(),
hashed_password=hashed_password,
first_name=customer_data.first_name,
last_name=customer_data.last_name,
phone=customer_data.phone,
customer_number=customer_number,
marketing_consent=customer_data.marketing_consent if hasattr(customer_data, 'marketing_consent') else False,
is_active=True
)
try:
db.add(customer)
db.commit()
db.refresh(customer)
logger.info(
f"Customer registered successfully: {customer.email} "
f"(ID: {customer.id}, Number: {customer.customer_number}) "
f"for vendor {vendor.vendor_code}"
)
return customer
except Exception as e:
db.rollback()
logger.error(f"Error registering customer: {str(e)}")
raise CustomerValidationException(
message="Failed to register customer",
details={"error": str(e)}
)
def login_customer(
self,
db: Session,
vendor_id: int,
credentials: UserLogin
) -> Dict[str, Any]:
"""
Authenticate customer and generate JWT token.
Args:
db: Database session
vendor_id: Vendor ID
credentials: Login credentials
Returns:
Dict containing customer and token data
Raises:
VendorNotFoundException: If vendor doesn't exist
InvalidCustomerCredentialsException: If credentials are invalid
CustomerNotActiveException: If customer account is inactive
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Find customer by email (vendor-scoped)
customer = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == credentials.username.lower()
)
).first()
if not customer:
raise InvalidCustomerCredentialsException()
# Verify password
if not self.auth_service.verify_password(
credentials.password,
customer.hashed_password
):
raise InvalidCustomerCredentialsException()
# Check if customer is active
if not customer.is_active:
raise CustomerNotActiveException(customer.email)
# Generate JWT token with customer context
token_data = self.auth_service.create_access_token(
data={
"sub": str(customer.id),
"email": customer.email,
"vendor_id": vendor_id,
"type": "customer"
}
)
logger.info(
f"Customer login successful: {customer.email} "
f"for vendor {vendor.vendor_code}"
)
return {
"customer": customer,
"token_data": token_data
}
def get_customer(
self,
db: Session,
vendor_id: int,
customer_id: int
) -> Customer:
"""
Get customer by ID with vendor isolation.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Customer: Customer object
Raises:
CustomerNotFoundException: If customer not found
"""
customer = db.query(Customer).filter(
and_(
Customer.id == customer_id,
Customer.vendor_id == vendor_id
)
).first()
if not customer:
raise CustomerNotFoundException(str(customer_id))
return customer
def get_customer_by_email(
self,
db: Session,
vendor_id: int,
email: str
) -> Optional[Customer]:
"""
Get customer by email (vendor-scoped).
Args:
db: Database session
vendor_id: Vendor ID
email: Customer email
Returns:
Optional[Customer]: Customer object or None
"""
return db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == email.lower()
)
).first()
def update_customer(
self,
db: Session,
vendor_id: int,
customer_id: int,
customer_data: CustomerUpdate
) -> Customer:
"""
Update customer profile.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
customer_data: Updated customer data
Returns:
Customer: Updated customer object
Raises:
CustomerNotFoundException: If customer not found
CustomerValidationException: If update data is invalid
"""
customer = self.get_customer(db, vendor_id, customer_id)
# Update fields
update_data = customer_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "email" and value:
# Check if new email already exists for this vendor
existing = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == value.lower(),
Customer.id != customer_id
)
).first()
if existing:
raise DuplicateCustomerEmailException(value, "vendor")
setattr(customer, field, value.lower())
elif hasattr(customer, field):
setattr(customer, field, value)
try:
db.commit()
db.refresh(customer)
logger.info(f"Customer updated: {customer.email} (ID: {customer.id})")
return customer
except Exception as e:
db.rollback()
logger.error(f"Error updating customer: {str(e)}")
raise CustomerValidationException(
message="Failed to update customer",
details={"error": str(e)}
)
def deactivate_customer(
self,
db: Session,
vendor_id: int,
customer_id: int
) -> Customer:
"""
Deactivate customer account.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Customer: Deactivated customer object
Raises:
CustomerNotFoundException: If customer not found
"""
customer = self.get_customer(db, vendor_id, customer_id)
customer.is_active = False
db.commit()
db.refresh(customer)
logger.info(f"Customer deactivated: {customer.email} (ID: {customer.id})")
return customer
def update_customer_stats(
self,
db: Session,
customer_id: int,
order_total: float
) -> None:
"""
Update customer statistics after order.
Args:
db: Database session
customer_id: Customer ID
order_total: Order total amount
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.total_orders += 1
customer.total_spent += order_total
customer.last_order_date = datetime.utcnow()
db.commit()
logger.debug(f"Updated stats for customer {customer.email}")
def _generate_customer_number(
self,
db: Session,
vendor_id: int,
vendor_code: str
) -> str:
"""
Generate unique customer number for vendor.
Format: {VENDOR_CODE}-CUST-{SEQUENCE}
Example: VENDORA-CUST-00001
Args:
db: Database session
vendor_id: Vendor ID
vendor_code: Vendor code
Returns:
str: Unique customer number
"""
# Get count of customers for this vendor
count = db.query(Customer).filter(
Customer.vendor_id == vendor_id
).count()
# Generate number with padding
sequence = str(count + 1).zfill(5)
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
# Ensure uniqueness (in case of deletions)
while db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.customer_number == customer_number
)
).first():
count += 1
sequence = str(count + 1).zfill(5)
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
return customer_number
# Singleton instance
customer_service = CustomerService()

View File

@@ -0,0 +1,578 @@
# app/services/inventory_service.py
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from app.exceptions import (
InventoryNotFoundException,
InsufficientInventoryException,
InvalidInventoryOperationException,
InventoryValidationException,
NegativeInventoryException,
InvalidQuantityException,
ValidationException,
ProductNotFoundException,
)
from models.schemas.inventory import (
InventoryCreate,
InventoryAdjust,
InventoryUpdate,
InventoryReserve,
InventoryLocationResponse,
ProductInventorySummary
)
from models.database.inventory import Inventory
from models.database.product import Product
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class InventoryService:
"""Service for inventory operations with vendor isolation."""
def set_inventory(
self, db: Session, vendor_id: int, inventory_data: InventoryCreate
) -> Inventory:
"""
Set exact inventory quantity for a product at a location (replaces existing).
Args:
db: Database session
vendor_id: Vendor ID (from middleware)
inventory_data: Inventory data
Returns:
Inventory object
"""
try:
# Validate product belongs to vendor
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
# Validate location
location = self._validate_location(inventory_data.location)
# Validate quantity
self._validate_quantity(inventory_data.quantity, allow_zero=True)
# Check if inventory entry exists
existing = self._get_inventory_entry(
db, inventory_data.product_id, location
)
if existing:
old_qty = existing.quantity
existing.quantity = inventory_data.quantity
existing.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing)
logger.info(
f"Set inventory for product {inventory_data.product_id} at {location}: "
f"{old_qty}{inventory_data.quantity}"
)
return existing
else:
# Create new inventory entry
new_inventory = Inventory(
product_id=inventory_data.product_id,
vendor_id=vendor_id,
location=location,
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin, # Optional reference
)
db.add(new_inventory)
db.commit()
db.refresh(new_inventory)
logger.info(
f"Created inventory for product {inventory_data.product_id} at {location}: "
f"{inventory_data.quantity}"
)
return new_inventory
except (ProductNotFoundException, InvalidQuantityException, InventoryValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error setting inventory: {str(e)}")
raise ValidationException("Failed to set inventory")
def adjust_inventory(
self, db: Session, vendor_id: int, inventory_data: InventoryAdjust
) -> Inventory:
"""
Adjust inventory by adding or removing quantity.
Positive quantity = add, negative = remove.
Args:
db: Database session
vendor_id: Vendor ID
inventory_data: Adjustment data
Returns:
Updated Inventory object
"""
try:
# Validate product belongs to vendor
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
# Validate location
location = self._validate_location(inventory_data.location)
# Check if inventory exists
existing = self._get_inventory_entry(db, inventory_data.product_id, location)
if not existing:
# Create new if adding, error if removing
if inventory_data.quantity < 0:
raise InventoryNotFoundException(
f"No inventory found for product {inventory_data.product_id} at {location}"
)
# Create with positive quantity
new_inventory = Inventory(
product_id=inventory_data.product_id,
vendor_id=vendor_id,
location=location,
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin,
)
db.add(new_inventory)
db.commit()
db.refresh(new_inventory)
logger.info(
f"Created inventory for product {inventory_data.product_id} at {location}: "
f"+{inventory_data.quantity}"
)
return new_inventory
# Adjust existing inventory
old_qty = existing.quantity
new_qty = old_qty + inventory_data.quantity
# Validate resulting quantity
if new_qty < 0:
raise InsufficientInventoryException(
f"Insufficient inventory. Available: {old_qty}, "
f"Requested removal: {abs(inventory_data.quantity)}"
)
existing.quantity = new_qty
existing.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing)
logger.info(
f"Adjusted inventory for product {inventory_data.product_id} at {location}: "
f"{old_qty} {'+' if inventory_data.quantity >= 0 else ''}{inventory_data.quantity} = {new_qty}"
)
return existing
except (ProductNotFoundException, InventoryNotFoundException,
InsufficientInventoryException, InventoryValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error adjusting inventory: {str(e)}")
raise ValidationException("Failed to adjust inventory")
def reserve_inventory(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Reserve inventory for an order (increases reserved_quantity).
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
# Validate product
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
# Validate location and quantity
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
# Get inventory entry
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Check available quantity
available = inventory.quantity - inventory.reserved_quantity
if available < reserve_data.quantity:
raise InsufficientInventoryException(
f"Insufficient available inventory. Available: {available}, "
f"Requested: {reserve_data.quantity}"
)
# Reserve inventory
inventory.reserved_quantity += reserve_data.quantity
inventory.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inventory)
logger.info(
f"Reserved {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (ProductNotFoundException, InventoryNotFoundException,
InsufficientInventoryException, InvalidQuantityException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error reserving inventory: {str(e)}")
raise ValidationException("Failed to reserve inventory")
def release_reservation(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Release reserved inventory (decreases reserved_quantity).
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
# Validate product
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Validate reserved quantity
if inventory.reserved_quantity < reserve_data.quantity:
logger.warning(
f"Attempting to release more than reserved. Reserved: {inventory.reserved_quantity}, "
f"Requested: {reserve_data.quantity}"
)
inventory.reserved_quantity = 0
else:
inventory.reserved_quantity -= reserve_data.quantity
inventory.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inventory)
logger.info(
f"Released {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (ProductNotFoundException, InventoryNotFoundException, InvalidQuantityException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error releasing reservation: {str(e)}")
raise ValidationException("Failed to release reservation")
def fulfill_reservation(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Fulfill a reservation (decreases both quantity and reserved_quantity).
Use when order is shipped/completed.
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Validate quantities
if inventory.quantity < reserve_data.quantity:
raise InsufficientInventoryException(
f"Insufficient inventory. Available: {inventory.quantity}, "
f"Requested: {reserve_data.quantity}"
)
if inventory.reserved_quantity < reserve_data.quantity:
logger.warning(
f"Fulfilling more than reserved. Reserved: {inventory.reserved_quantity}, "
f"Fulfilling: {reserve_data.quantity}"
)
# Fulfill (remove from both quantity and reserved)
inventory.quantity -= reserve_data.quantity
inventory.reserved_quantity = max(
0, inventory.reserved_quantity - reserve_data.quantity
)
inventory.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inventory)
logger.info(
f"Fulfilled {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (ProductNotFoundException, InventoryNotFoundException,
InsufficientInventoryException, InvalidQuantityException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error fulfilling reservation: {str(e)}")
raise ValidationException("Failed to fulfill reservation")
def get_product_inventory(
self, db: Session, vendor_id: int, product_id: int
) -> ProductInventorySummary:
"""
Get inventory summary for a product across all locations.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
ProductInventorySummary
"""
try:
product = self._get_vendor_product(db, vendor_id, product_id)
inventory_entries = (
db.query(Inventory)
.filter(Inventory.product_id == product_id)
.all()
)
if not inventory_entries:
return ProductInventorySummary(
product_id=product_id,
vendor_id=vendor_id,
product_sku=product.product_id,
product_title=product.marketplace_product.title,
total_quantity=0,
total_reserved=0,
total_available=0,
locations=[],
)
total_qty = sum(inv.quantity for inv in inventory_entries)
total_reserved = sum(inv.reserved_quantity for inv in inventory_entries)
total_available = sum(inv.available_quantity for inv in inventory_entries)
locations = [
InventoryLocationResponse(
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
)
for inv in inventory_entries
]
return ProductInventorySummary(
product_id=product_id,
vendor_id=vendor_id,
product_sku=product.product_id,
product_title=product.marketplace_product.title,
total_quantity=total_qty,
total_reserved=total_reserved,
total_available=total_available,
locations=locations,
)
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting product inventory: {str(e)}")
raise ValidationException("Failed to retrieve product inventory")
def get_vendor_inventory(
self, db: Session, vendor_id: int, skip: int = 0, limit: int = 100,
location: Optional[str] = None, low_stock_threshold: Optional[int] = None
) -> List[Inventory]:
"""
Get all inventory for a vendor with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
location: Filter by location
low_stock_threshold: Filter items below threshold
Returns:
List of Inventory objects
"""
try:
query = db.query(Inventory).filter(Inventory.vendor_id == vendor_id)
if location:
query = query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock_threshold is not None:
query = query.filter(Inventory.quantity <= low_stock_threshold)
return query.offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Error getting vendor inventory: {str(e)}")
raise ValidationException("Failed to retrieve vendor inventory")
def update_inventory(
self, db: Session, vendor_id: int, inventory_id: int,
inventory_update: InventoryUpdate
) -> Inventory:
"""Update inventory entry."""
try:
inventory = self._get_inventory_by_id(db, inventory_id)
# Verify ownership
if inventory.vendor_id != vendor_id:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
# Update fields
if inventory_update.quantity is not None:
self._validate_quantity(inventory_update.quantity, allow_zero=True)
inventory.quantity = inventory_update.quantity
if inventory_update.reserved_quantity is not None:
self._validate_quantity(inventory_update.reserved_quantity, allow_zero=True)
inventory.reserved_quantity = inventory_update.reserved_quantity
if inventory_update.location:
inventory.location = self._validate_location(inventory_update.location)
inventory.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inventory)
logger.info(f"Updated inventory {inventory_id}")
return inventory
except (InventoryNotFoundException, InvalidQuantityException, InventoryValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating inventory: {str(e)}")
raise ValidationException("Failed to update inventory")
def delete_inventory(
self, db: Session, vendor_id: int, inventory_id: int
) -> bool:
"""Delete inventory entry."""
try:
inventory = self._get_inventory_by_id(db, inventory_id)
# Verify ownership
if inventory.vendor_id != vendor_id:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
db.delete(inventory)
db.commit()
logger.info(f"Deleted inventory {inventory_id}")
return True
except InventoryNotFoundException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error deleting inventory: {str(e)}")
raise ValidationException("Failed to delete inventory")
# Private helper methods
def _get_vendor_product(self, db: Session, vendor_id: int, product_id: int) -> Product:
"""Get product and verify it belongs to vendor."""
product = db.query(Product).filter(
Product.id == product_id,
Product.vendor_id == vendor_id
).first()
if not product:
raise ProductNotFoundException(f"Product {product_id} not found in your catalog")
return product
def _get_inventory_entry(
self, db: Session, product_id: int, location: str
) -> Optional[Inventory]:
"""Get inventory entry by product and location."""
return (
db.query(Inventory)
.filter(
Inventory.product_id == product_id,
Inventory.location == location
)
.first()
)
def _get_inventory_by_id(self, db: Session, inventory_id: int) -> Inventory:
"""Get inventory by ID or raise exception."""
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
if not inventory:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
return inventory
def _validate_location(self, location: str) -> str:
"""Validate and normalize location."""
if not location or not location.strip():
raise InventoryValidationException("Location is required")
return location.strip().upper()
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
"""Validate quantity value."""
if quantity is None:
raise InvalidQuantityException("Quantity is required")
if not isinstance(quantity, int):
raise InvalidQuantityException("Quantity must be an integer")
if quantity < 0:
raise InvalidQuantityException("Quantity cannot be negative")
if not allow_zero and quantity == 0:
raise InvalidQuantityException("Quantity must be positive")
# Create service instance
inventory_service = InventoryService()

View File

@@ -1,31 +1,21 @@
# app/services/marketplace_import_job_service.py
"""
Marketplace service for managing import jobs and marketplace integrations.
This module provides classes and functions for:
- Import job creation and management
- Vendor access validation
- Import job status tracking and updates
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
VendorNotFoundException,
UnauthorizedVendorAccessException,
ImportJobNotFoundException,
ImportJobNotOwnedException,
ImportJobCannotBeCancelledException,
ImportJobCannotBeDeletedException,
ValidationException,
)
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
MarketplaceImportJobRequest)
from models.schemas.marketplace_import_job import (
MarketplaceImportJobResponse,
MarketplaceImportJobRequest
)
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.vendor import Vendor
from models.database.user import User
@@ -34,49 +24,14 @@ logger = logging.getLogger(__name__)
class MarketplaceImportJobService:
"""Service class for Marketplace operations following the application's service pattern."""
def validate_vendor_access(self, db: Session, vendor_code: str, user: User) -> Vendor:
"""
Validate that the vendor exists and user has access to it.
Args:
db: Database session
vendor_code: Vendor code to validate
user: User requesting access
Returns:
Vendor object if access is valid
Raises:
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks access
"""
try:
# Use case-insensitive query to handle both uppercase and lowercase codes
vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
)
if not vendor :
raise VendorNotFoundException(vendor_code)
# Check permissions: admin can import for any vendor, others only for their own
if user.role != "admin" and vendor.owner_id != user.id:
raise UnauthorizedVendorAccessException(vendor_code, user.id)
return vendor
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error validating vendor access: {str(e)}")
raise ValidationException("Failed to validate vendor access")
"""Service class for Marketplace operations."""
def create_import_job(
self, db: Session, request: MarketplaceImportJobRequest, user: User
self,
db: Session,
request: MarketplaceImportJobRequest,
vendor: Vendor, # CHANGED: Vendor object from middleware
user: User
) -> MarketplaceImportJob:
"""
Create a new marketplace import job.
@@ -84,29 +39,20 @@ class MarketplaceImportJobService:
Args:
db: Database session
request: Import request data
vendor: Vendor object (from middleware)
user: User creating the job
Returns:
Created MarketplaceImportJob object
Raises:
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks vendor access
ValidationException: If job creation fails
"""
try:
# Validate vendor access first
vendor = self.validate_vendor_access(db, request.vendor_code, user)
# Create marketplace import job record
import_job = MarketplaceImportJob(
status="pending",
source_url=request.url,
source_url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id, # Foreign key to vendors table
vendor_name=vendor.vendor_name, # Use vendor.vendor_name (the display name)
vendor_id=vendor.id,
user_id=user.id,
created_at=datetime.now(timezone.utc),
)
db.add(import_job)
@@ -115,36 +61,21 @@ class MarketplaceImportJobService:
logger.info(
f"Created marketplace import job {import_job.id}: "
f"{request.marketplace} -> {vendor.vendor_name} (vendor_code: {vendor.vendor_code}) by user {user.username}"
f"{request.marketplace} -> {vendor.name} (code: {vendor.vendor_code}) "
f"by user {user.username}"
)
return import_job
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error creating import job: {str(e)}")
raise ValidationException("Failed to create import job")
def get_import_job_by_id(
self, db: Session, job_id: int, user: User
self, db: Session, job_id: int, user: User
) -> MarketplaceImportJob:
"""
Get a marketplace import job by ID with access control.
Args:
db: Database session
job_id: Import job ID
user: User requesting the job
Returns:
MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access to job
"""
"""Get a marketplace import job by ID with access control."""
try:
job = (
db.query(MarketplaceImportJob)
@@ -162,48 +93,35 @@ class MarketplaceImportJobService:
return job
except (ImportJobNotFoundException, ImportJobNotOwnedException):
raise # Re-raise custom exceptions
raise
except Exception as e:
logger.error(f"Error getting import job {job_id}: {str(e)}")
raise ValidationException("Failed to retrieve import job")
def get_import_jobs(
self,
db: Session,
user: User,
marketplace: Optional[str] = None,
vendor_name: Optional[str] = None,
skip: int = 0,
limit: int = 50,
self,
db: Session,
vendor: Vendor, # ADDED: Vendor filter
user: User,
marketplace: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> List[MarketplaceImportJob]:
"""
Get marketplace import jobs with filtering and access control.
Args:
db: Database session
user: User requesting jobs
marketplace: Optional marketplace filter
vendor_name: Optional vendor name filter
skip: Number of records to skip
limit: Maximum records to return
Returns:
List of MarketplaceImportJob objects
"""
"""Get marketplace import jobs for a specific vendor."""
try:
query = db.query(MarketplaceImportJob)
query = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor.id
)
# Users can only see their own jobs, admins can see all
# Users can only see their own jobs, admins can see all vendor jobs
if user.role != "admin":
query = query.filter(MarketplaceImportJob.user_id == user.id)
# Apply filters
# Apply marketplace filter
if marketplace:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
)
if vendor_name:
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
# Order by creation date (newest first) and apply pagination
jobs = (
@@ -219,100 +137,8 @@ class MarketplaceImportJobService:
logger.error(f"Error getting import jobs: {str(e)}")
raise ValidationException("Failed to retrieve import jobs")
def update_job_status(
self, db: Session, job_id: int, status: str, **kwargs
) -> MarketplaceImportJob:
"""
Update marketplace import job status and other fields.
Args:
db: Database session
job_id: Import job ID
status: New status
**kwargs: Additional fields to update
Returns:
Updated MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ValidationException: If update fails
"""
try:
job = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.id == job_id)
.first()
)
if not job:
raise ImportJobNotFoundException(job_id)
job.status = status
# Update optional fields if provided
allowed_fields = [
'imported_count', 'updated_count', 'total_processed',
'error_count', 'error_message', 'started_at', 'completed_at'
]
for field in allowed_fields:
if field in kwargs:
setattr(job, field, kwargs[field])
db.commit()
db.refresh(job)
logger.info(f"Updated marketplace import job {job_id} status to {status}")
return job
except ImportJobNotFoundException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error updating job {job_id} status: {str(e)}")
raise ValidationException("Failed to update job status")
def get_job_stats(self, db: Session, user: User) -> dict:
"""
Get statistics about marketplace import jobs for a user.
Args:
db: Database session
user: User to get stats for
Returns:
Dictionary containing job statistics
"""
try:
query = db.query(MarketplaceImportJob)
# Users can only see their own jobs, admins can see all
if user.role != "admin":
query = query.filter(MarketplaceImportJob.user_id == user.id)
total_jobs = query.count()
pending_jobs = query.filter(MarketplaceImportJob.status == "pending").count()
running_jobs = query.filter(MarketplaceImportJob.status == "running").count()
completed_jobs = query.filter(
MarketplaceImportJob.status == "completed"
).count()
failed_jobs = query.filter(MarketplaceImportJob.status == "failed").count()
return {
"total_jobs": total_jobs,
"pending_jobs": pending_jobs,
"running_jobs": running_jobs,
"completed_jobs": completed_jobs,
"failed_jobs": failed_jobs,
}
except Exception as e:
logger.error(f"Error getting job stats: {str(e)}")
raise ValidationException("Failed to retrieve job statistics")
def convert_to_response_model(
self, job: MarketplaceImportJob
self, job: MarketplaceImportJob
) -> MarketplaceImportJobResponse:
"""Convert database model to API response model."""
return MarketplaceImportJobResponse(
@@ -320,10 +146,9 @@ class MarketplaceImportJobService:
status=job.status,
marketplace=job.marketplace,
vendor_id=job.vendor_id,
vendor_code=(
job.vendor.vendor_code if job.vendor else None
), # Add this optional field via relationship
vendor_name=job.vendor_name,
vendor_code=job.vendor.vendor_code if job.vendor else None, # FIXED
vendor_name=job.vendor.name if job.vendor else None, # FIXED: from relationship
source_url=job.source_url,
imported=job.imported_count or 0,
updated=job.updated_count or 0,
total_processed=job.total_processed or 0,
@@ -334,84 +159,7 @@ class MarketplaceImportJobService:
completed_at=job.completed_at,
)
def cancel_import_job(
self, db: Session, job_id: int, user: User
) -> MarketplaceImportJob:
"""
Cancel a pending or running import job.
Args:
db: Database session
job_id: Import job ID
user: User requesting cancellation
Returns:
Updated MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access
ImportJobCannotBeCancelledException: If job can't be cancelled
"""
try:
job = self.get_import_job_by_id(db, job_id, user)
if job.status not in ["pending", "running"]:
raise ImportJobCannotBeCancelledException(job_id, job.status)
job.status = "cancelled"
job.completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
logger.info(f"Cancelled marketplace import job {job_id}")
return job
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeCancelledException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error cancelling job {job_id}: {str(e)}")
raise ValidationException("Failed to cancel import job")
def delete_import_job(self, db: Session, job_id: int, user: User) -> bool:
"""
Delete a marketplace import job.
Args:
db: Database session
job_id: Import job ID
user: User requesting deletion
Returns:
True if deletion successful
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access
ImportJobCannotBeDeletedException: If job can't be deleted
"""
try:
job = self.get_import_job_by_id(db, job_id, user)
# Only allow deletion of completed, failed, or cancelled jobs
if job.status in ["pending", "running"]:
raise ImportJobCannotBeDeletedException(job_id, job.status)
db.delete(job)
db.commit()
logger.info(f"Deleted marketplace import job {job_id}")
return True
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeDeletedException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error deleting job {job_id}: {str(e)}")
raise ValidationException("Failed to delete import job")
# ... other methods (cancel, delete, etc.) remain similar ...
# Create service instance
marketplace_import_job_service = MarketplaceImportJobService()

View File

@@ -5,7 +5,7 @@ MarketplaceProduct service for managing product operations and data processing.
This module provides classes and functions for:
- MarketplaceProduct CRUD operations with validation
- Advanced product filtering and search
- Stock information integration
- Inventory information integration
- CSV export functionality
"""
import csv
@@ -26,9 +26,9 @@ from app.exceptions import (
)
from app.services.marketplace_import_job_service import marketplace_import_job_service
from models.schemas.marketplace_product import MarketplaceProductCreate, MarketplaceProductUpdate
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
from models.schemas.inventory import InventoryLocationResponse, InventorySummaryResponse
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.inventory import Inventory
from app.utils.data_processing import GTINProcessor, PriceProcessor
logger = logging.getLogger(__name__)
@@ -170,7 +170,7 @@ class MarketplaceProductService:
if vendor_name:
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
if search:
# Search in title, description, marketplace, and vendor_name
# Search in title, description, marketplace, and name
search_term = f"%{search}%"
query = query.filter(
(MarketplaceProduct.title.ilike(search_term))
@@ -240,7 +240,7 @@ class MarketplaceProductService:
def delete_product(self, db: Session, marketplace_product_id: str) -> bool:
"""
Delete product and associated stock.
Delete product and associated inventory.
Args:
db: Database session
@@ -255,9 +255,9 @@ class MarketplaceProductService:
try:
product = self.get_product_by_id_or_raise(db, marketplace_product_id)
# Delete associated stock entries if GTIN exists
# Delete associated inventory entries if GTIN exists
if product.gtin:
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
db.query(Inventory).filter(Inventory.gtin == product.gtin).delete()
db.delete(product)
db.commit()
@@ -272,34 +272,34 @@ class MarketplaceProductService:
logger.error(f"Error deleting product {marketplace_product_id}: {str(e)}")
raise ValidationException("Failed to delete product")
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
def get_inventory_info(self, db: Session, gtin: str) -> Optional[InventorySummaryResponse]:
"""
Get stock information for a product by GTIN.
Get inventory information for a product by GTIN.
Args:
db: Database session
gtin: GTIN to look up stock for
gtin: GTIN to look up inventory for
Returns:
StockSummaryResponse if stock found, None otherwise
InventorySummaryResponse if inventory found, None otherwise
"""
try:
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
if not stock_entries:
inventory_entries = db.query(Inventory).filter(Inventory.gtin == gtin).all()
if not inventory_entries:
return None
total_quantity = sum(entry.quantity for entry in stock_entries)
total_quantity = sum(entry.quantity for entry in inventory_entries)
locations = [
StockLocationResponse(location=entry.location, quantity=entry.quantity)
for entry in stock_entries
InventoryLocationResponse(location=entry.location, quantity=entry.quantity)
for entry in inventory_entries
]
return StockSummaryResponse(
return InventorySummaryResponse(
gtin=gtin, total_quantity=total_quantity, locations=locations
)
except Exception as e:
logger.error(f"Error getting stock info for GTIN {gtin}: {str(e)}")
logger.error(f"Error getting inventory info for GTIN {gtin}: {str(e)}")
return None
import csv
@@ -333,7 +333,7 @@ class MarketplaceProductService:
headers = [
"marketplace_product_id", "title", "description", "link", "image_link",
"availability", "price", "currency", "brand", "gtin",
"marketplace", "vendor_name"
"marketplace", "name"
]
writer.writerow(headers)
yield output.getvalue()
@@ -413,7 +413,7 @@ class MarketplaceProductService:
normalized = product_data.copy()
# Trim whitespace from string fields
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'vendor_name']
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'name']
for field in string_fields:
if field in normalized and normalized[field]:
normalized[field] = normalized[field].strip()

View File

@@ -0,0 +1,376 @@
# app/services/order_service.py
"""
Order service for order management.
This module provides:
- Order creation from cart
- Order status management
- Order retrieval and filtering
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional, Tuple
import random
import string
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from models.database.order import Order, OrderItem
from models.database.customer import Customer, CustomerAddress
from models.database.product import Product
from models.schemas.order import OrderCreate, OrderUpdate, OrderAddressCreate
from app.exceptions import (
OrderNotFoundException,
ValidationException,
InsufficientInventoryException,
CustomerNotFoundException
)
logger = logging.getLogger(__name__)
class OrderService:
"""Service for order operations."""
def _generate_order_number(self, db: Session, vendor_id: int) -> str:
"""
Generate unique order number.
Format: ORD-{VENDOR_ID}-{TIMESTAMP}-{RANDOM}
Example: ORD-1-20250110-A1B2C3
"""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
# Ensure uniqueness
while db.query(Order).filter(Order.order_number == order_number).first():
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
return order_number
def _create_customer_address(
self,
db: Session,
vendor_id: int,
customer_id: int,
address_data: OrderAddressCreate,
address_type: str
) -> CustomerAddress:
"""Create a customer address for order."""
address = CustomerAddress(
vendor_id=vendor_id,
customer_id=customer_id,
address_type=address_type,
first_name=address_data.first_name,
last_name=address_data.last_name,
company=address_data.company,
address_line_1=address_data.address_line_1,
address_line_2=address_data.address_line_2,
city=address_data.city,
postal_code=address_data.postal_code,
country=address_data.country,
is_default=False
)
db.add(address)
db.flush() # Get ID without committing
return address
def create_order(
self,
db: Session,
vendor_id: int,
order_data: OrderCreate
) -> Order:
"""
Create a new order.
Args:
db: Database session
vendor_id: Vendor ID
order_data: Order creation data
Returns:
Created Order object
Raises:
ValidationException: If order data is invalid
InsufficientInventoryException: If not enough inventory
"""
try:
# Validate customer exists if provided
customer_id = order_data.customer_id
if customer_id:
customer = db.query(Customer).filter(
and_(
Customer.id == customer_id,
Customer.vendor_id == vendor_id
)
).first()
if not customer:
raise CustomerNotFoundException(str(customer_id))
else:
# Guest checkout - create guest customer
# TODO: Implement guest customer creation
raise ValidationException("Guest checkout not yet implemented")
# Create shipping address
shipping_address = self._create_customer_address(
db=db,
vendor_id=vendor_id,
customer_id=customer_id,
address_data=order_data.shipping_address,
address_type="shipping"
)
# Create billing address (use shipping if not provided)
if order_data.billing_address:
billing_address = self._create_customer_address(
db=db,
vendor_id=vendor_id,
customer_id=customer_id,
address_data=order_data.billing_address,
address_type="billing"
)
else:
billing_address = shipping_address
# Calculate order totals
subtotal = 0.0
order_items_data = []
for item_data in order_data.items:
# Get product
product = db.query(Product).filter(
and_(
Product.id == item_data.product_id,
Product.vendor_id == vendor_id,
Product.is_active == True
)
).first()
if not product:
raise ValidationException(f"Product {item_data.product_id} not found")
# Check inventory
if product.available_inventory < item_data.quantity:
raise InsufficientInventoryException(
product_id=product.id,
requested=item_data.quantity,
available=product.available_inventory
)
# Calculate item total
unit_price = product.sale_price if product.sale_price else product.price
if not unit_price:
raise ValidationException(f"Product {product.id} has no price")
item_total = unit_price * item_data.quantity
subtotal += item_total
order_items_data.append({
"product_id": product.id,
"product_name": product.marketplace_product.title,
"product_sku": product.product_id,
"quantity": item_data.quantity,
"unit_price": unit_price,
"total_price": item_total
})
# Calculate tax and shipping (simple implementation)
tax_amount = 0.0 # TODO: Implement tax calculation
shipping_amount = 5.99 if subtotal < 50 else 0.0 # Free shipping over €50
discount_amount = 0.0 # TODO: Implement discounts
total_amount = subtotal + tax_amount + shipping_amount - discount_amount
# Generate order number
order_number = self._generate_order_number(db, vendor_id)
# Create order
order = Order(
vendor_id=vendor_id,
customer_id=customer_id,
order_number=order_number,
status="pending",
subtotal=subtotal,
tax_amount=tax_amount,
shipping_amount=shipping_amount,
discount_amount=discount_amount,
total_amount=total_amount,
currency="EUR",
shipping_address_id=shipping_address.id,
billing_address_id=billing_address.id,
shipping_method=order_data.shipping_method,
customer_notes=order_data.customer_notes
)
db.add(order)
db.flush() # Get order ID
# Create order items
for item_data in order_items_data:
order_item = OrderItem(
order_id=order.id,
**item_data
)
db.add(order_item)
db.commit()
db.refresh(order)
logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: €{total_amount:.2f}"
)
return order
except (ValidationException, InsufficientInventoryException, CustomerNotFoundException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating order: {str(e)}")
raise ValidationException(f"Failed to create order: {str(e)}")
def get_order(
self,
db: Session,
vendor_id: int,
order_id: int
) -> Order:
"""Get order by ID."""
order = db.query(Order).filter(
and_(
Order.id == order_id,
Order.vendor_id == vendor_id
)
).first()
if not order:
raise OrderNotFoundException(str(order_id))
return order
def get_vendor_orders(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
customer_id: Optional[int] = None
) -> Tuple[List[Order], int]:
"""
Get orders for vendor with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
status: Filter by status
customer_id: Filter by customer
Returns:
Tuple of (orders, total_count)
"""
query = db.query(Order).filter(Order.vendor_id == vendor_id)
if status:
query = query.filter(Order.status == status)
if customer_id:
query = query.filter(Order.customer_id == customer_id)
# Order by most recent first
query = query.order_by(Order.created_at.desc())
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 100
) -> Tuple[List[Order], int]:
"""Get orders for a specific customer."""
return self.get_vendor_orders(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
customer_id=customer_id
)
def update_order_status(
self,
db: Session,
vendor_id: int,
order_id: int,
order_update: OrderUpdate
) -> Order:
"""
Update order status and tracking information.
Args:
db: Database session
vendor_id: Vendor ID
order_id: Order ID
order_update: Update data
Returns:
Updated Order object
"""
try:
order = self.get_order(db, vendor_id, order_id)
# Update status with timestamps
if order_update.status:
order.status = order_update.status
# Update timestamp based on status
now = datetime.now(timezone.utc)
if order_update.status == "shipped" and not order.shipped_at:
order.shipped_at = now
elif order_update.status == "delivered" and not order.delivered_at:
order.delivered_at = now
elif order_update.status == "cancelled" and not order.cancelled_at:
order.cancelled_at = now
# Update tracking number
if order_update.tracking_number:
order.tracking_number = order_update.tracking_number
# Update internal notes
if order_update.internal_notes:
order.internal_notes = order_update.internal_notes
order.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(order)
logger.info(f"Order {order.order_number} updated: status={order.status}")
return order
except OrderNotFoundException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating order: {str(e)}")
raise ValidationException(f"Failed to update order: {str(e)}")
# Create service instance
order_service = OrderService()

View File

@@ -0,0 +1,247 @@
# app/services/product_service.py
"""
Product service for vendor catalog management.
This module provides:
- Product catalog CRUD operations
- Product publishing from marketplace staging
- Product search and filtering
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.exceptions import (
ProductNotFoundException,
ProductAlreadyExistsException,
ValidationException,
)
from models.schemas.product import ProductCreate, ProductUpdate
from models.database.product import Product
from models.database.marketplace_product import MarketplaceProduct
logger = logging.getLogger(__name__)
class ProductService:
"""Service for vendor catalog product operations."""
def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product:
"""
Get a product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
Product object
Raises:
ProductNotFoundException: If product not found
"""
try:
product = db.query(Product).filter(
Product.id == product_id,
Product.vendor_id == vendor_id
).first()
if not product:
raise ProductNotFoundException(f"Product {product_id} not found")
return product
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting product: {str(e)}")
raise ValidationException("Failed to retrieve product")
def create_product(
self, db: Session, vendor_id: int, product_data: ProductCreate
) -> Product:
"""
Add a product from marketplace to vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_data: Product creation data
Returns:
Created Product object
Raises:
ProductAlreadyExistsException: If product already in catalog
ValidationException: If marketplace product not found
"""
try:
# Verify marketplace product exists and belongs to vendor
marketplace_product = db.query(MarketplaceProduct).filter(
MarketplaceProduct.id == product_data.marketplace_product_id,
MarketplaceProduct.vendor_id == vendor_id
).first()
if not marketplace_product:
raise ValidationException(
f"Marketplace product {product_data.marketplace_product_id} not found"
)
# Check if already in catalog
existing = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.marketplace_product_id == product_data.marketplace_product_id
).first()
if existing:
raise ProductAlreadyExistsException(
f"Product already exists in catalog"
)
# Create product
product = Product(
vendor_id=vendor_id,
marketplace_product_id=product_data.marketplace_product_id,
product_id=product_data.product_id,
price=product_data.price,
sale_price=product_data.sale_price,
currency=product_data.currency,
availability=product_data.availability,
condition=product_data.condition,
is_featured=product_data.is_featured,
is_active=True,
min_quantity=product_data.min_quantity,
max_quantity=product_data.max_quantity,
)
db.add(product)
db.commit()
db.refresh(product)
logger.info(
f"Added product {product.id} to vendor {vendor_id} catalog"
)
return product
except (ProductAlreadyExistsException, ValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating product: {str(e)}")
raise ValidationException("Failed to create product")
def update_product(
self, db: Session, vendor_id: int, product_id: int, product_update: ProductUpdate
) -> Product:
"""
Update product in vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
product_update: Update data
Returns:
Updated Product object
"""
try:
product = self.get_product(db, vendor_id, product_id)
# Update fields
update_data = product_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(product, key, value)
product.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(product)
logger.info(f"Updated product {product_id} in vendor {vendor_id} catalog")
return product
except ProductNotFoundException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating product: {str(e)}")
raise ValidationException("Failed to update product")
def delete_product(self, db: Session, vendor_id: int, product_id: int) -> bool:
"""
Remove product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
True if deleted
"""
try:
product = self.get_product(db, vendor_id, product_id)
db.delete(product)
db.commit()
logger.info(f"Deleted product {product_id} from vendor {vendor_id} catalog")
return True
except ProductNotFoundException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error deleting product: {str(e)}")
raise ValidationException("Failed to delete product")
def get_vendor_products(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None,
is_featured: Optional[bool] = None,
) -> Tuple[List[Product], int]:
"""
Get products in vendor catalog with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
is_active: Filter by active status
is_featured: Filter by featured status
Returns:
Tuple of (products, total_count)
"""
try:
query = db.query(Product).filter(Product.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except Exception as e:
logger.error(f"Error getting vendor products: {str(e)}")
raise ValidationException("Failed to retrieve products")
# Create service instance
product_service = ProductService()

View File

@@ -2,71 +2,281 @@
"""
Statistics service for generating system analytics and metrics.
This module provides classes and functions for:
- Comprehensive system statistics
- Marketplace-specific analytics
- Performance metrics and data insights
- Cached statistics for performance
This module provides:
- System-wide statistics (admin)
- Vendor-specific statistics
- Marketplace analytics
- Performance metrics
"""
import logging
from typing import Any, Dict, List
from datetime import datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.exceptions import (
VendorNotFoundException,
AdminOperationException,
)
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.product import Product
from models.database.inventory import Inventory
from models.database.vendor import Vendor
from models.database.order import Order
from models.database.customer import Customer
from models.database.marketplace_import_job import MarketplaceImportJob
logger = logging.getLogger(__name__)
class StatsService:
"""Service class for statistics operations following the application's service pattern."""
"""Service for statistics operations."""
# ========================================================================
# VENDOR-SPECIFIC STATISTICS
# ========================================================================
def get_vendor_stats(self, db: Session, vendor_id: int) -> Dict[str, Any]:
"""
Get statistics for a specific vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Dictionary with vendor statistics
Raises:
VendorNotFoundException: If vendor doesn't exist
AdminOperationException: If database query fails
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
try:
# Catalog statistics
total_catalog_products = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.is_active == True
).count()
featured_products = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.is_featured == True,
Product.is_active == True
).count()
# Staging statistics
staging_products = db.query(MarketplaceProduct).filter(
MarketplaceProduct.vendor_id == vendor_id
).count()
# Inventory statistics
total_inventory = db.query(
func.sum(Inventory.quantity)
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
reserved_inventory = db.query(
func.sum(Inventory.reserved_quantity)
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
inventory_locations = db.query(
func.count(func.distinct(Inventory.location))
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
# Import statistics
total_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id
).count()
successful_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id,
MarketplaceImportJob.status == "completed"
).count()
# Orders
total_orders = db.query(Order).filter(
Order.vendor_id == vendor_id
).count()
# Customers
total_customers = db.query(Customer).filter(
Customer.vendor_id == vendor_id
).count()
return {
"catalog": {
"total_products": total_catalog_products,
"featured_products": featured_products,
"active_products": total_catalog_products,
},
"staging": {
"imported_products": staging_products,
},
"inventory": {
"total_quantity": int(total_inventory),
"reserved_quantity": int(reserved_inventory),
"available_quantity": int(total_inventory - reserved_inventory),
"locations_count": inventory_locations,
},
"imports": {
"total_imports": total_imports,
"successful_imports": successful_imports,
"success_rate": (successful_imports / total_imports * 100) if total_imports > 0 else 0,
},
"orders": {
"total_orders": total_orders,
},
"customers": {
"total_customers": total_customers,
},
}
except VendorNotFoundException:
raise
except Exception as e:
logger.error(f"Failed to retrieve vendor statistics for vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="get_vendor_stats",
reason=f"Database query failed: {str(e)}",
target_type="vendor",
target_id=str(vendor_id)
)
def get_vendor_analytics(
self, db: Session, vendor_id: int, period: str = "30d"
) -> Dict[str, Any]:
"""
Get vendor analytics for a time period.
Args:
db: Database session
vendor_id: Vendor ID
period: Time period (7d, 30d, 90d, 1y)
Returns:
Analytics data
Raises:
VendorNotFoundException: If vendor doesn't exist
AdminOperationException: If database query fails
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
try:
# Parse period
days = self._parse_period(period)
start_date = datetime.utcnow() - timedelta(days=days)
# Import activity
recent_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id,
MarketplaceImportJob.created_at >= start_date
).count()
# Products added to catalog
products_added = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.created_at >= start_date
).count()
# Inventory changes
inventory_entries = db.query(Inventory).filter(
Inventory.vendor_id == vendor_id
).count()
return {
"period": period,
"start_date": start_date.isoformat(),
"imports": {
"count": recent_imports,
},
"catalog": {
"products_added": products_added,
},
"inventory": {
"total_locations": inventory_entries,
},
}
except VendorNotFoundException:
raise
except Exception as e:
logger.error(f"Failed to retrieve vendor analytics for vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="get_vendor_analytics",
reason=f"Database query failed: {str(e)}",
target_type="vendor",
target_id=str(vendor_id)
)
# ========================================================================
# SYSTEM-WIDE STATISTICS (ADMIN)
# ========================================================================
def get_comprehensive_stats(self, db: Session) -> Dict[str, Any]:
"""
Get comprehensive statistics with marketplace data.
Get comprehensive system statistics for admin dashboard.
Args:
db: Database session
Returns:
Dictionary containing all statistics data
Dictionary with comprehensive statistics
Raises:
ValidationException: If statistics generation fails
AdminOperationException: If database query fails
"""
try:
# Use more efficient queries with proper indexes
total_products = self._get_product_count(db)
# Vendors
total_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
# Products
total_catalog_products = db.query(Product).count()
unique_brands = self._get_unique_brands_count(db)
unique_categories = self._get_unique_categories_count(db)
unique_marketplaces = self._get_unique_marketplaces_count(db)
unique_vendors = self._get_unique_vendors_count(db)
# Stock statistics
stock_stats = self._get_stock_statistics(db)
# Marketplaces
unique_marketplaces = (
db.query(MarketplaceProduct.marketplace)
.filter(MarketplaceProduct.marketplace.isnot(None))
.distinct()
.count()
)
stats_data = {
"total_products": total_products,
# Inventory
inventory_stats = self._get_inventory_statistics(db)
return {
"total_products": total_catalog_products,
"unique_brands": unique_brands,
"unique_categories": unique_categories,
"unique_marketplaces": unique_marketplaces,
"unique_vendors": unique_vendors,
"total_stock_entries": stock_stats["total_stock_entries"],
"total_inventory_quantity": stock_stats["total_inventory_quantity"],
"unique_vendors": total_vendors,
"total_inventory_entries": inventory_stats.get("total_entries", 0),
"total_inventory_quantity": inventory_stats.get("total_quantity", 0),
}
logger.info(
f"Generated comprehensive stats: {total_products} products, {unique_marketplaces} marketplaces"
)
return stats_data
except Exception as e:
logger.error(f"Error getting comprehensive stats: {str(e)}")
raise ValidationException("Failed to retrieve system statistics")
logger.error(f"Failed to retrieve comprehensive statistics: {str(e)}")
raise AdminOperationException(
operation="get_comprehensive_stats",
reason=f"Database query failed: {str(e)}"
)
def get_marketplace_breakdown_stats(self, db: Session) -> List[Dict[str, Any]]:
"""
@@ -76,13 +286,12 @@ class StatsService:
db: Database session
Returns:
List of dictionaries containing marketplace statistics
List of marketplace statistics
Raises:
ValidationException: If marketplace statistics generation fails
AdminOperationException: If database query fails
"""
try:
# Query to get stats per marketplace
marketplace_stats = (
db.query(
MarketplaceProduct.marketplace,
@@ -95,7 +304,7 @@ class StatsService:
.all()
)
stats_list = [
return [
{
"marketplace": stat.marketplace,
"total_products": stat.total_products,
@@ -105,103 +314,35 @@ class StatsService:
for stat in marketplace_stats
]
logger.info(
f"Generated marketplace breakdown stats for {len(stats_list)} marketplaces"
except Exception as e:
logger.error(f"Failed to retrieve marketplace breakdown statistics: {str(e)}")
raise AdminOperationException(
operation="get_marketplace_breakdown_stats",
reason=f"Database query failed: {str(e)}"
)
return stats_list
except Exception as e:
logger.error(f"Error getting marketplace breakdown stats: {str(e)}")
raise ValidationException("Failed to retrieve marketplace statistics")
# ========================================================================
# PRIVATE HELPER METHODS
# ========================================================================
def get_product_statistics(self, db: Session) -> Dict[str, Any]:
"""
Get detailed product statistics.
Args:
db: Database session
Returns:
Dictionary containing product statistics
"""
try:
stats = {
"total_products": self._get_product_count(db),
"unique_brands": self._get_unique_brands_count(db),
"unique_categories": self._get_unique_categories_count(db),
"unique_marketplaces": self._get_unique_marketplaces_count(db),
"unique_vendors": self._get_unique_vendors_count(db),
"products_with_gtin": self._get_products_with_gtin_count(db),
"products_with_images": self._get_products_with_images_count(db),
}
return stats
except Exception as e:
logger.error(f"Error getting product statistics: {str(e)}")
raise ValidationException("Failed to retrieve product statistics")
def get_stock_statistics(self, db: Session) -> Dict[str, Any]:
"""
Get stock-related statistics.
Args:
db: Database session
Returns:
Dictionary containing stock statistics
"""
try:
return self._get_stock_statistics(db)
except Exception as e:
logger.error(f"Error getting stock statistics: {str(e)}")
raise ValidationException("Failed to retrieve stock statistics")
def get_marketplace_details(self, db: Session, marketplace: str) -> Dict[str, Any]:
"""
Get detailed statistics for a specific marketplace.
Args:
db: Database session
marketplace: Marketplace name
Returns:
Dictionary containing marketplace details
"""
try:
if not marketplace or not marketplace.strip():
raise ValidationException("Marketplace name is required")
product_count = self._get_products_by_marketplace_count(db, marketplace)
brands = self._get_brands_by_marketplace(db, marketplace)
vendors =self._get_vendors_by_marketplace(db, marketplace)
return {
"marketplace": marketplace,
"total_products": product_count,
"unique_brands": len(brands),
"unique_vendors": len(vendors),
"brands": brands,
"vendors": vendors,
}
except ValidationException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting marketplace details for {marketplace}: {str(e)}")
raise ValidationException("Failed to retrieve marketplace details")
# Private helper methods
def _get_product_count(self, db: Session) -> int:
"""Get total product count."""
return db.query(MarketplaceProduct).count()
def _parse_period(self, period: str) -> int:
"""Parse period string to days."""
period_map = {
"7d": 7,
"30d": 30,
"90d": 90,
"1y": 365,
}
return period_map.get(period, 30)
def _get_unique_brands_count(self, db: Session) -> int:
"""Get count of unique brands."""
return (
db.query(MarketplaceProduct.brand)
.filter(MarketplaceProduct.brand.isnot(None), MarketplaceProduct.brand != "")
.filter(
MarketplaceProduct.brand.isnot(None),
MarketplaceProduct.brand != ""
)
.distinct()
.count()
)
@@ -218,81 +359,19 @@ class StatsService:
.count()
)
def _get_unique_marketplaces_count(self, db: Session) -> int:
"""Get count of unique marketplaces."""
return (
db.query(MarketplaceProduct.marketplace)
.filter(MarketplaceProduct.marketplace.isnot(None), MarketplaceProduct.marketplace != "")
.distinct()
.count()
)
def _get_unique_vendors_count(self, db: Session) -> int:
"""Get count of unique vendors."""
return (
db.query(MarketplaceProduct.vendor_name)
.filter(MarketplaceProduct.vendor_name.isnot(None), MarketplaceProduct.vendor_name != "")
.distinct()
.count()
)
def _get_products_with_gtin_count(self, db: Session) -> int:
"""Get count of products with GTIN."""
return (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.gtin.isnot(None), MarketplaceProduct.gtin != "")
.count()
)
def _get_products_with_images_count(self, db: Session) -> int:
"""Get count of products with images."""
return (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.image_link.isnot(None), MarketplaceProduct.image_link != "")
.count()
)
def _get_stock_statistics(self, db: Session) -> Dict[str, int]:
"""Get stock-related statistics."""
total_stock_entries = db.query(Stock).count()
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
def _get_inventory_statistics(self, db: Session) -> Dict[str, int]:
"""Get inventory-related statistics."""
total_entries = db.query(Inventory).count()
total_quantity = db.query(func.sum(Inventory.quantity)).scalar() or 0
total_reserved = db.query(func.sum(Inventory.reserved_quantity)).scalar() or 0
return {
"total_stock_entries": total_stock_entries,
"total_inventory_quantity": total_inventory,
"total_entries": total_entries,
"total_quantity": int(total_quantity),
"total_reserved": int(total_reserved),
"total_available": int(total_quantity - total_reserved),
}
def _get_brands_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique brands for a specific marketplace."""
brands = (
db.query(MarketplaceProduct.brand)
.filter(
MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.brand.isnot(None),
MarketplaceProduct.brand != "",
)
.distinct()
.all()
)
return [brand[0] for brand in brands]
def _get_vendors_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique vendors for a specific marketplace."""
vendors =(
db.query(MarketplaceProduct.vendor_name)
.filter(
MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.vendor_name.isnot(None),
MarketplaceProduct.vendor_name != "",
)
.distinct()
.all()
)
return [vendor [0] for vendor in vendors]
def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
"""Get product count for a specific marketplace."""
return db.query(MarketplaceProduct).filter(MarketplaceProduct.marketplace == marketplace).count()
# Create service instance following the same pattern as other services
# Create service instance
stats_service = StatsService()

View File

@@ -1,570 +0,0 @@
# app/services/stock_service.py
"""
Stock service for managing inventory operations.
This module provides classes and functions for:
- Stock quantity management (set, add, remove)
- Stock information retrieval and validation
- Location-based inventory tracking
- GTIN normalization and validation
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from app.exceptions import (
StockNotFoundException,
InsufficientStockException,
InvalidStockOperationException,
StockValidationException,
NegativeStockException,
InvalidQuantityException,
ValidationException,
)
from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse,
StockSummaryResponse, StockUpdate)
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from app.utils.data_processing import GTINProcessor
logger = logging.getLogger(__name__)
class StockService:
"""Service class for stock operations following the application's service pattern."""
def __init__(self):
"""Class constructor."""
self.gtin_processor = GTINProcessor()
def set_stock(self, db: Session, stock_data: StockCreate) -> Stock:
"""
Set exact stock quantity for a GTIN at a specific location (replaces existing quantity).
Args:
db: Database session
stock_data: Stock creation data
Returns:
Stock object with updated quantity
Raises:
InvalidQuantityException: If quantity is negative
StockValidationException: If GTIN or location is invalid
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=True)
# Check if stock entry already exists for this GTIN and location
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if existing_stock:
# Update existing stock (SET to exact quantity)
old_quantity = existing_stock.quantity
existing_stock.quantity = stock_data.quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Updated stock for GTIN {normalized_gtin} at {location}: {old_quantity}{stock_data.quantity}"
)
return existing_stock
else:
# Create new stock entry
new_stock = Stock(
gtin=normalized_gtin,
location=location,
quantity=stock_data.quantity
)
db.add(new_stock)
db.commit()
db.refresh(new_stock)
logger.info(
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
)
return new_stock
except (InvalidQuantityException, StockValidationException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error setting stock: {str(e)}")
raise ValidationException("Failed to set stock")
def add_stock(self, db: Session, stock_data: StockAdd) -> Stock:
"""
Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity).
Args:
db: Database session
stock_data: Stock addition data
Returns:
Stock object with updated quantity
Raises:
InvalidQuantityException: If quantity is not positive
StockValidationException: If GTIN or location is invalid
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=False)
# Check if stock entry already exists for this GTIN and location
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if existing_stock:
# Add to existing stock
old_quantity = existing_stock.quantity
existing_stock.quantity += stock_data.quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Added stock for GTIN {normalized_gtin} at {location}: "
f"{old_quantity} + {stock_data.quantity} = {existing_stock.quantity}"
)
return existing_stock
else:
# Create new stock entry with the quantity
new_stock = Stock(
gtin=normalized_gtin,
location=location,
quantity=stock_data.quantity
)
db.add(new_stock)
db.commit()
db.refresh(new_stock)
logger.info(
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
)
return new_stock
except (InvalidQuantityException, StockValidationException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error adding stock: {str(e)}")
raise ValidationException("Failed to add stock")
def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock:
"""
Remove quantity from existing stock for a GTIN at a specific location.
Args:
db: Database session
stock_data: Stock removal data
Returns:
Stock object with updated quantity
Raises:
StockNotFoundException: If no stock found for GTIN/location
InsufficientStockException: If not enough stock available
InvalidQuantityException: If quantity is not positive
NegativeStockException: If operation would result in negative stock
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=False)
# Find existing stock entry
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if not existing_stock:
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
# Check if we have enough stock to remove
if existing_stock.quantity < stock_data.quantity:
raise InsufficientStockException(
gtin=normalized_gtin,
location=location,
requested=stock_data.quantity,
available=existing_stock.quantity
)
# Remove from existing stock
old_quantity = existing_stock.quantity
new_quantity = existing_stock.quantity - stock_data.quantity
# Validate resulting quantity
if new_quantity < 0:
raise NegativeStockException(normalized_gtin, location, new_quantity)
existing_stock.quantity = new_quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Removed stock for GTIN {normalized_gtin} at {location}: "
f"{old_quantity} - {stock_data.quantity} = {existing_stock.quantity}"
)
return existing_stock
except (StockValidationException, StockNotFoundException, InsufficientStockException, InvalidQuantityException,
NegativeStockException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error removing stock: {str(e)}")
raise ValidationException("Failed to remove stock")
def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse:
"""
Get all stock locations and total quantity for a specific GTIN.
Args:
db: Database session
gtin: GTIN to look up
Returns:
StockSummaryResponse with locations and totals
Raises:
StockNotFoundException: If no stock found for GTIN
StockValidationException: If GTIN is invalid
"""
try:
normalized_gtin = self._validate_and_normalize_gtin(gtin)
# Get all stock entries for this GTIN
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
if not stock_entries:
raise StockNotFoundException(normalized_gtin, identifier_type="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(MarketplaceProduct).filter(MarketplaceProduct.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,
)
except (StockNotFoundException, StockValidationException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting stock by GTIN {gtin}: {str(e)}")
raise ValidationException("Failed to retrieve stock information")
def get_total_stock(self, db: Session, gtin: str) -> dict:
"""
Get total quantity in stock for a specific GTIN.
Args:
db: Database session
gtin: GTIN to look up
Returns:
Dictionary with total stock information
Raises:
StockNotFoundException: If no stock found for GTIN
StockValidationException: If GTIN is invalid
"""
try:
normalized_gtin = self._validate_and_normalize_gtin(gtin)
# Calculate total stock
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
if not total_stock:
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
total_quantity = sum(entry.quantity for entry in total_stock)
# Get product info for context
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.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),
}
except (StockNotFoundException, StockValidationException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}")
raise ValidationException("Failed to retrieve total stock")
def get_all_stock(
self,
db: Session,
skip: int = 0,
limit: int = 100,
location: Optional[str] = None,
gtin: Optional[str] = None,
) -> List[Stock]:
"""
Get all stock entries with optional filtering.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
location: Optional location filter
gtin: Optional GTIN filter
Returns:
List of Stock objects
"""
try:
query = db.query(Stock)
if location:
query = query.filter(Stock.location.ilike(f"%{location}%"))
if gtin:
normalized_gtin = self._normalize_gtin(gtin)
if normalized_gtin:
query = query.filter(Stock.gtin == normalized_gtin)
return query.offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Error getting all stock: {str(e)}")
raise ValidationException("Failed to retrieve stock entries")
def update_stock(
self, db: Session, stock_id: int, stock_update: StockUpdate
) -> Stock:
"""
Update stock quantity for a specific stock entry.
Args:
db: Database session
stock_id: Stock entry ID
stock_update: Update data
Returns:
Updated Stock object
Raises:
StockNotFoundException: If stock entry not found
InvalidQuantityException: If quantity is invalid
"""
try:
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
# Validate new quantity
self._validate_quantity(stock_update.quantity, allow_zero=True)
stock_entry.quantity = stock_update.quantity
stock_entry.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(stock_entry)
logger.info(
f"Updated stock entry {stock_id} to quantity {stock_update.quantity}"
)
return stock_entry
except (StockNotFoundException, InvalidQuantityException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error updating stock {stock_id}: {str(e)}")
raise ValidationException("Failed to update stock")
def delete_stock(self, db: Session, stock_id: int) -> bool:
"""
Delete a stock entry.
Args:
db: Database session
stock_id: Stock entry ID
Returns:
True if deletion successful
Raises:
StockNotFoundException: If stock entry not found
"""
try:
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
gtin = stock_entry.gtin
location = stock_entry.location
db.delete(stock_entry)
db.commit()
logger.info(f"Deleted stock entry {stock_id} for GTIN {gtin} at {location}")
return True
except StockNotFoundException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error deleting stock {stock_id}: {str(e)}")
raise ValidationException("Failed to delete stock entry")
def get_stock_summary_by_location(self, db: Session, location: str) -> dict:
"""
Get stock summary for a specific location.
Args:
db: Database session
location: Location to summarize
Returns:
Dictionary with location stock summary
"""
try:
normalized_location = self._validate_and_normalize_location(location)
stock_entries = db.query(Stock).filter(Stock.location == normalized_location).all()
if not stock_entries:
return {
"location": normalized_location,
"total_items": 0,
"total_quantity": 0,
"unique_gtins": 0,
}
total_quantity = sum(entry.quantity for entry in stock_entries)
unique_gtins = len(set(entry.gtin for entry in stock_entries))
return {
"location": normalized_location,
"total_items": len(stock_entries),
"total_quantity": total_quantity,
"unique_gtins": unique_gtins,
}
except StockValidationException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting stock summary for location {location}: {str(e)}")
raise ValidationException("Failed to retrieve location stock summary")
def get_low_stock_items(self, db: Session, threshold: int = 10) -> List[dict]:
"""
Get items with stock below threshold.
Args:
db: Database session
threshold: Stock threshold to consider "low"
Returns:
List of low stock items with details
"""
try:
if threshold < 0:
raise InvalidQuantityException(threshold, "Threshold must be non-negative")
low_stock_entries = db.query(Stock).filter(Stock.quantity <= threshold).all()
low_stock_items = []
for entry in low_stock_entries:
# Get product info if available
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.gtin == entry.gtin).first()
low_stock_items.append({
"gtin": entry.gtin,
"location": entry.location,
"current_quantity": entry.quantity,
"product_title": product.title if product else None,
"marketplace_product_id": product.marketplace_product_id if product else None,
})
return low_stock_items
except InvalidQuantityException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting low stock items: {str(e)}")
raise ValidationException("Failed to retrieve low stock items")
# Private helper methods
def _validate_and_normalize_gtin(self, gtin: str) -> str:
"""Validate and normalize GTIN format."""
if not gtin or not gtin.strip():
raise StockValidationException("GTIN is required", field="gtin")
normalized_gtin = self._normalize_gtin(gtin)
if not normalized_gtin:
raise StockValidationException("Invalid GTIN format", field="gtin")
return normalized_gtin
def _validate_and_normalize_location(self, location: str) -> str:
"""Validate and normalize location."""
if not location or not location.strip():
raise StockValidationException("Location is required", field="location")
return location.strip().upper()
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
"""Validate quantity value."""
if quantity is None:
raise InvalidQuantityException(quantity, "Quantity is required")
if not isinstance(quantity, int):
raise InvalidQuantityException(quantity, "Quantity must be an integer")
if quantity < 0:
raise InvalidQuantityException(quantity, "Quantity cannot be negative")
if not allow_zero and quantity == 0:
raise InvalidQuantityException(quantity, "Quantity must be positive")
def _normalize_gtin(self, gtin_value) -> Optional[str]:
"""Normalize GTIN format using the GTINProcessor."""
try:
return self.gtin_processor.normalize(gtin_value)
except Exception as e:
logger.error(f"Error normalizing GTIN {gtin_value}: {str(e)}")
return None
def _get_stock_entry(self, db: Session, gtin: str, location: str) -> Optional[Stock]:
"""Get stock entry by GTIN and location."""
return (
db.query(Stock)
.filter(Stock.gtin == gtin, Stock.location == location)
.first()
)
def _get_stock_by_id_or_raise(self, db: Session, stock_id: int) -> Stock:
"""Get stock by ID or raise exception."""
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
if not stock_entry:
raise StockNotFoundException(str(stock_id))
return stock_entry
# Create service instance
stock_service = StockService()

View File

@@ -0,0 +1,214 @@
# app/services/team_service.py
"""
Team service for vendor team management.
This module provides:
- Team member invitation
- Role management
- Team member CRUD operations
"""
import logging
from typing import List, Dict, Any
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.exceptions import (
ValidationException,
UnauthorizedVendorAccessException,
)
from models.database.vendor import VendorUser, Role
from models.database.user import User
logger = logging.getLogger(__name__)
class TeamService:
"""Service for team management operations."""
def get_team_members(
self, db: Session, vendor_id: int, current_user: User
) -> List[Dict[str, Any]]:
"""
Get all team members for vendor.
Args:
db: Database session
vendor_id: Vendor ID
current_user: Current user
Returns:
List of team members
"""
try:
vendor_users = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.is_active == True
).all()
members = []
for vu in vendor_users:
members.append({
"id": vu.user_id,
"email": vu.user.email,
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"role": vu.role.name,
"role_id": vu.role_id,
"is_active": vu.is_active,
"joined_at": vu.created_at,
})
return members
except Exception as e:
logger.error(f"Error getting team members: {str(e)}")
raise ValidationException("Failed to retrieve team members")
def invite_team_member(
self, db: Session, vendor_id: int, invitation_data: dict, current_user: User
) -> Dict[str, Any]:
"""
Invite a new team member.
Args:
db: Database session
vendor_id: Vendor ID
invitation_data: Invitation details
current_user: Current user
Returns:
Invitation result
"""
try:
# TODO: Implement full invitation flow with email
# For now, return placeholder
return {
"message": "Team invitation feature coming soon",
"email": invitation_data.get("email"),
"role": invitation_data.get("role"),
}
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")
raise ValidationException("Failed to invite team member")
def update_team_member(
self,
db: Session,
vendor_id: int,
user_id: int,
update_data: dict,
current_user: User
) -> Dict[str, Any]:
"""
Update team member role or status.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to update
update_data: Update data
current_user: Current user
Returns:
Updated member info
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.user_id == user_id
).first()
if not vendor_user:
raise ValidationException("Team member not found")
# Update fields
if "role_id" in update_data:
vendor_user.role_id = update_data["role_id"]
if "is_active" in update_data:
vendor_user.is_active = update_data["is_active"]
vendor_user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(vendor_user)
return {
"message": "Team member updated successfully",
"user_id": user_id,
}
except Exception as e:
db.rollback()
logger.error(f"Error updating team member: {str(e)}")
raise ValidationException("Failed to update team member")
def remove_team_member(
self, db: Session, vendor_id: int, user_id: int, current_user: User
) -> bool:
"""
Remove team member from vendor.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to remove
current_user: Current user
Returns:
True if removed
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.user_id == user_id
).first()
if not vendor_user:
raise ValidationException("Team member not found")
# Soft delete
vendor_user.is_active = False
vendor_user.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(f"Removed user {user_id} from vendor {vendor_id}")
return True
except Exception as e:
db.rollback()
logger.error(f"Error removing team member: {str(e)}")
raise ValidationException("Failed to remove team member")
def get_vendor_roles(self, db: Session, vendor_id: int) -> List[Dict[str, Any]]:
"""
Get available roles for vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of roles
"""
try:
roles = db.query(Role).filter(Role.vendor_id == vendor_id).all()
return [
{
"id": role.id,
"name": role.name,
"permissions": role.permissions,
}
for role in roles
]
except Exception as e:
logger.error(f"Error getting vendor roles: {str(e)}")
raise ValidationException("Failed to retrieve roles")
# Create service instance
team_service = TeamService()

View File

@@ -77,7 +77,7 @@ class VendorService:
new_vendor = Vendor(
**vendor_dict,
owner_id=current_user.id,
owner_user_id=current_user.id,
is_active=True,
is_verified=(current_user.role == "admin"),
)
@@ -129,7 +129,7 @@ class VendorService:
if current_user.role != "admin":
query = query.filter(
(Vendor.is_active == True)
& ((Vendor.is_verified == True) | (Vendor.owner_id == current_user.id))
& ((Vendor.is_verified == True) | (Vendor.owner_user_id == current_user.id))
)
else:
# Admin can apply filters
@@ -295,7 +295,7 @@ class VendorService:
raise InvalidVendorDataException("Vendor code is required", field="vendor_code")
if not vendor_data.vendor_name or not vendor_data.vendor_name.strip():
raise InvalidVendorDataException("Vendor name is required", field="vendor_name")
raise InvalidVendorDataException("Vendor name is required", field="name")
# Validate vendor code format (alphanumeric, underscores, hyphens)
import re
@@ -310,7 +310,7 @@ class VendorService:
if user.role == "admin":
return # Admins have no limit
user_vendor_count = db.query(Vendor).filter(Vendor.owner_id == user.id).count()
user_vendor_count = db.query(Vendor).filter(Vendor.owner_user_id == user.id).count()
max_vendors = 5 # Configure this as needed
if user_vendor_count >= max_vendors:
@@ -345,7 +345,7 @@ class VendorService:
def _can_access_vendor(self, vendor : Vendor, user: User) -> bool:
"""Check if user can access vendor."""
# Admins and owners can always access
if user.role == "admin" or vendor.owner_id == user.id:
if user.role == "admin" or vendor.owner_user_id == user.id:
return True
# Others can only access active and verified vendors
@@ -353,7 +353,7 @@ class VendorService:
def _is_vendor_owner(self, vendor : Vendor, user: User) -> bool:
"""Check if user is vendor owner."""
return vendor.owner_id == user.id
return vendor.owner_user_id == user.id
# Create service instance following the same pattern as other services
vendor_service = VendorService()

View File

@@ -1,32 +1,29 @@
# app/tasks/background_tasks.py
"""Summary description ....
This module provides classes and functions for:
- ....
- ....
- ....
"""
import logging
from datetime import datetime, timezone
from app.core.database import SessionLocal
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.vendor import Vendor
from app.utils.csv_processor import CSVProcessor
logger = logging.getLogger(__name__)
async def process_marketplace_import(
job_id: int, url: str, marketplace: str, vendor_name: str, batch_size: int = 1000
job_id: int,
url: str,
marketplace: str,
vendor_id: int, # FIXED: Changed from vendor_name to vendor_id
batch_size: int = 1000
):
"""Background task to process marketplace CSV import."""
db = SessionLocal()
csv_processor = CSVProcessor()
job = None # Initialize job variable
job = None
try:
# Update job status
# Get the import job
job = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.id == job_id)
@@ -36,15 +33,33 @@ async def process_marketplace_import(
logger.error(f"Import job {job_id} not found")
return
# Get vendor information
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
logger.error(f"Vendor {vendor_id} not found for import job {job_id}")
job.status = "failed"
job.error_message = f"Vendor {vendor_id} not found"
job.completed_at = datetime.now(timezone.utc)
db.commit()
return
# Update job status
job.status = "processing"
job.started_at = datetime.now(timezone.utc)
db.commit()
logger.info(f"Processing import: Job {job_id}, Marketplace: {marketplace}")
logger.info(
f"Processing import: Job {job_id}, Marketplace: {marketplace}, "
f"Vendor: {vendor.name} ({vendor.vendor_code})"
)
# Process CSV
# Process CSV with vendor_id
result = await csv_processor.process_marketplace_csv_from_url(
url, marketplace, vendor_name, batch_size, db
url,
marketplace,
vendor_id, # FIXED: Pass vendor_id instead of vendor_name
batch_size,
db
)
# Update job with results
@@ -60,11 +75,15 @@ async def process_marketplace_import(
job.error_message = f"{result['errors']} rows had errors"
db.commit()
logger.info(f"Import job {job_id} completed successfully")
logger.info(
f"Import job {job_id} completed: "
f"imported={result['imported']}, updated={result['updated']}, "
f"errors={result.get('errors', 0)}"
)
except Exception as e:
logger.error(f"Import job {job_id} failed: {e}")
if job is not None: # Only update if job was found
logger.error(f"Import job {job_id} failed: {e}", exc_info=True)
if job is not None:
try:
job.status = "failed"
job.error_message = str(e)
@@ -73,12 +92,7 @@ async def process_marketplace_import(
except Exception as commit_error:
logger.error(f"Failed to update job status: {commit_error}")
db.rollback()
# Don't re-raise the exception - background tasks should handle errors internally
# and update the job status accordingly. Only log the error.
pass
finally:
# Close the database session only if it's not a mock
# In tests, we use the same session so we shouldn't close it
if hasattr(db, "close") and callable(getattr(db, "close")):
try:
db.close()

View File

@@ -235,7 +235,7 @@ class CSVProcessor:
"updated": updated,
"errors": errors,
"marketplace": marketplace,
"vendor_name": vendor_name,
"name": vendor_name,
}
async def _process_marketplace_batch(
@@ -263,7 +263,7 @@ class CSVProcessor:
# Add marketplace and vendor information
product_data["marketplace"] = marketplace
product_data["vendor_name"] = vendor_name
product_data["name"] = vendor_name
# Validate required fields
if not product_data.get("marketplace_product_id"):

View File

@@ -0,0 +1,311 @@
@echo off
setlocal enabledelayedexpansion
echo ========================================
echo FastAPI Project Structure Builder
echo (Safe Mode - Won't Override Existing Files)
echo ========================================
echo.
:: Create root directories
call :CreateDir "app"
call :CreateDir "app\api"
call :CreateDir "app\api\v1"
call :CreateDir "app\api\v1\admin"
call :CreateDir "app\api\v1\vendor"
call :CreateDir "app\api\v1\public"
call :CreateDir "app\api\v1\public\vendors"
call :CreateDir "app\api\v1\shared"
call :CreateDir "app\core"
call :CreateDir "app\exceptions"
call :CreateDir "app\services"
call :CreateDir "tasks"
call :CreateDir "models"
call :CreateDir "models\database"
call :CreateDir "models\schema"
call :CreateDir "middleware"
call :CreateDir "storage"
call :CreateDir "static"
call :CreateDir "static\admin"
call :CreateDir "static\vendor"
call :CreateDir "static\vendor\admin"
call :CreateDir "static\vendor\admin\marketplace"
call :CreateDir "static\shop"
call :CreateDir "static\shop\account"
call :CreateDir "static\css"
call :CreateDir "static\css\admin"
call :CreateDir "static\css\vendor"
call :CreateDir "static\css\shop"
call :CreateDir "static\css\shared"
call :CreateDir "static\css\themes"
call :CreateDir "static\js"
call :CreateDir "static\js\shared"
call :CreateDir "static\js\admin"
call :CreateDir "static\js\vendor"
call :CreateDir "static\js\shop"
echo.
echo Creating Python files...
echo.
:: Root files
call :CreateFile "main.py" "# FastAPI application entry point"
:: API files
call :CreateFile "app\api\deps.py" "# Common dependencies"
call :CreateFile "app\api\main.py" "# API router setup"
call :CreateFile "app\api\__init__.py" ""
call :CreateFile "app\api\v1\__init__.py" ""
:: Admin API files
call :CreateFile "app\api\v1\admin\__init__.py" ""
call :CreateFile "app\api\v1\admin\auth.py" "# Admin authentication"
call :CreateFile "app\api\v1\admin\vendors.py" "# Vendor management (CRUD, bulk import)"
call :CreateFile "app\api\v1\admin\dashboard.py" "# Admin dashboard & statistics"
call :CreateFile "app\api\v1\admin\users.py" "# User management across vendors"
call :CreateFile "app\api\v1\admin\marketplace.py" "# System-wide marketplace monitoring"
call :CreateFile "app\api\v1\admin\monitoring.py" "# Platform monitoring & alerts"
:: Vendor API files
call :CreateFile "app\api\v1\vendor\__init__.py" ""
call :CreateFile "app\api\v1\vendor\auth.py" "# Vendor team authentication"
call :CreateFile "app\api\v1\vendor\dashboard.py" "# Vendor dashboard & statistics"
call :CreateFile "app\api\v1\vendor\products.py" "# Vendor catalog management (Product table)"
call :CreateFile "app\api\v1\vendor\marketplace.py" "# Marketplace import & selection (MarketplaceProduct table)"
call :CreateFile "app\api\v1\vendor\orders.py" "# Vendor order management"
call :CreateFile "app\api\v1\vendor\customers.py" "# Vendor customer management"
call :CreateFile "app\api\v1\vendor\teams.py" "# Team member management"
call :CreateFile "app\api\v1\vendor\inventory.py" "# Inventory operations (vendor catalog products)"
call :CreateFile "app\api\v1\vendor\payments.py" "# Payment configuration & processing"
call :CreateFile "app\api\v1\vendor\media.py" "# File and media management"
call :CreateFile "app\api\v1\vendor\notifications.py" "# Notification management"
call :CreateFile "app\api\v1\vendor\settings.py" "# Vendor settings & configuration"
:: Public API files
call :CreateFile "app\api\v1\public\__init__.py" ""
call :CreateFile "app\api\v1\public\vendors\shop.py" "# Public shop info"
call :CreateFile "app\api\v1\public\vendors\products.py" "# Public product catalog (Product table only)"
call :CreateFile "app\api\v1\public\vendors\search.py" "# Product search functionality"
call :CreateFile "app\api\v1\public\vendors\cart.py" "# Shopping cart operations"
call :CreateFile "app\api\v1\public\vendors\orders.py" "# Order placement"
call :CreateFile "app\api\v1\public\vendors\payments.py" "# Payment processing"
call :CreateFile "app\api\v1\public\vendors\auth.py" "# Customer authentication"
:: Shared API files
call :CreateFile "app\api\v1\shared\health.py" "# Health checks"
call :CreateFile "app\api\v1\shared\webhooks.py" "# External webhooks (Stripe, etc.)"
call :CreateFile "app\api\v1\shared\uploads.py" "# File upload handling"
:: Core files
call :CreateFile "app\core\__init__.py" ""
call :CreateFile "app\core\config.py" "# Configuration settings"
call :CreateFile "app\core\database.py" "# Database setup"
call :CreateFile "app\core\lifespan.py" "# App lifecycle management"
:: Exception files
call :CreateFile "app\exceptions\__init__.py" "# All exception exports"
call :CreateFile "app\exceptions\base.py" "# Base exception classes"
call :CreateFile "app\exceptions\handler.py" "# Unified FastAPI exception handlers"
call :CreateFile "app\exceptions\auth.py" "# Authentication/authorization exceptions"
call :CreateFile "app\exceptions\admin.py" "# Admin operation exceptions"
call :CreateFile "app\exceptions\marketplace.py" "# Import/marketplace exceptions"
call :CreateFile "app\exceptions\marketplace_product.py" "# Marketplace staging exceptions"
call :CreateFile "app\exceptions\product.py" "# Vendor catalog exceptions"
call :CreateFile "app\exceptions\vendor.py" "# Vendor management exceptions"
call :CreateFile "app\exceptions\customer.py" "# Customer management exceptions"
call :CreateFile "app\exceptions\order.py" "# Order management exceptions"
call :CreateFile "app\exceptions\payment.py" "# Payment processing exceptions"
call :CreateFile "app\exceptions\inventory.py" "# Inventory management exceptions"
call :CreateFile "app\exceptions\media.py" "# Media/file management exceptions"
call :CreateFile "app\exceptions\notification.py" "# Notification exceptions"
call :CreateFile "app\exceptions\search.py" "# Search exceptions"
call :CreateFile "app\exceptions\monitoring.py" "# Monitoring exceptions"
call :CreateFile "app\exceptions\backup.py" "# Backup/recovery exceptions"
:: Service files
call :CreateFile "app\services\__init__.py" ""
call :CreateFile "app\services\auth_service.py" "# Authentication/authorization services"
call :CreateFile "app\services\admin_service.py" "# Admin services"
call :CreateFile "app\services\vendor_service.py" "# Vendor management services"
call :CreateFile "app\services\customer_service.py" "# Customer services (vendor-scoped)"
call :CreateFile "app\services\team_service.py" "# Team management services"
call :CreateFile "app\services\marketplace_service.py" "# Marketplace import services (MarketplaceProduct)"
call :CreateFile "app\services\marketplace_product_service.py" "# Marketplace staging services"
call :CreateFile "app\services\product_service.py" "# Vendor catalog services (Product)"
call :CreateFile "app\services\order_service.py" "# Order services (vendor-scoped)"
call :CreateFile "app\services\payment_service.py" "# Payment processing services"
call :CreateFile "app\services\inventory_service.py" "# Inventory services (vendor catalog)"
call :CreateFile "app\services\media_service.py" "# File and media management services"
call :CreateFile "app\services\notification_service.py" "# Email/notification services"
call :CreateFile "app\services\search_service.py" "# Search and indexing services"
call :CreateFile "app\services\cache_service.py" "# Caching services"
call :CreateFile "app\services\audit_service.py" "# Audit logging services"
call :CreateFile "app\services\monitoring_service.py" "# Application monitoring services"
call :CreateFile "app\services\backup_service.py" "# Backup and recovery services"
call :CreateFile "app\services\configuration_service.py" "# Configuration management services"
call :CreateFile "app\services\stats_service.py" "# Statistics services (vendor-aware)"
:: Task files
call :CreateFile "tasks\__init__.py" ""
call :CreateFile "tasks\task_manager.py" "# Celery configuration and task management"
call :CreateFile "tasks\marketplace_import.py" "# Marketplace CSV import tasks"
call :CreateFile "tasks\email_tasks.py" "# Email sending tasks"
call :CreateFile "tasks\media_processing.py" "# Image processing and optimization tasks"
call :CreateFile "tasks\search_indexing.py" "# Search index maintenance tasks"
call :CreateFile "tasks\analytics_tasks.py" "# Analytics and reporting tasks"
call :CreateFile "tasks\cleanup_tasks.py" "# Data cleanup and maintenance tasks"
call :CreateFile "tasks\backup_tasks.py" "# Backup and recovery tasks"
:: Database model files
call :CreateFile "models\__init__.py" ""
call :CreateFile "models\database\__init__.py" "# Import all models for easy access"
call :CreateFile "models\database\base.py" "# Base model class and common mixins"
call :CreateFile "models\database\user.py" "# User model (with vendor relationships)"
call :CreateFile "models\database\vendor.py" "# Vendor, VendorUser, Role models"
call :CreateFile "models\database\customer.py" "# Customer, CustomerAddress models (vendor-scoped)"
call :CreateFile "models\database\marketplace_product.py" "# MarketplaceProduct model (staging data)"
call :CreateFile "models\database\product.py" "# Product model (vendor catalog)"
call :CreateFile "models\database\order.py" "# Order, OrderItem models (vendor-scoped)"
call :CreateFile "models\database\payment.py" "# Payment, PaymentMethod, VendorPaymentConfig models"
call :CreateFile "models\database\inventory.py" "# Inventory, InventoryMovement models (catalog products)"
call :CreateFile "models\database\marketplace.py" "# MarketplaceImportJob model"
call :CreateFile "models\database\media.py" "# MediaFile, ProductMedia models"
call :CreateFile "models\database\notification.py" "# NotificationTemplate, NotificationQueue, NotificationLog models"
call :CreateFile "models\database\search.py" "# SearchIndex, SearchQuery models"
call :CreateFile "models\database\audit.py" "# AuditLog, DataExportLog models"
call :CreateFile "models\database\monitoring.py" "# PerformanceMetric, ErrorLog, SystemAlert models"
call :CreateFile "models\database\backup.py" "# BackupLog, RestoreLog models"
call :CreateFile "models\database\configuration.py" "# PlatformConfig, VendorConfig, FeatureFlag models"
call :CreateFile "models\database\task.py" "# TaskLog model"
call :CreateFile "models\database\admin.py" "# Admin-specific models"
:: Schema model files
call :CreateFile "models\schema\__init__.py" "# Common imports"
call :CreateFile "models\schema\base.py" "# Base Pydantic models"
call :CreateFile "models\schema\auth.py" "# Login, Token, User response models"
call :CreateFile "models\schema\vendor.py" "# Vendor management models"
call :CreateFile "models\schema\customer.py" "# Customer request/response models"
call :CreateFile "models\schema\team.py" "# Team management models"
call :CreateFile "models\schema\marketplace_product.py" "# Marketplace staging models"
call :CreateFile "models\schema\product.py" "# Vendor catalog models"
call :CreateFile "models\schema\order.py" "# Order models (vendor-scoped)"
call :CreateFile "models\schema\payment.py" "# Payment models"
call :CreateFile "models\schema\inventory.py" "# Inventory operation models"
call :CreateFile "models\schema\marketplace.py" "# Marketplace import job models"
call :CreateFile "models\schema\media.py" "# Media/file management models"
call :CreateFile "models\schema\notification.py" "# Notification models"
call :CreateFile "models\schema\search.py" "# Search models"
call :CreateFile "models\schema\monitoring.py" "# Monitoring models"
call :CreateFile "models\schema\admin.py" "# Admin operation models"
call :CreateFile "models\schema\stats.py" "# Statistics response models"
:: Middleware files
call :CreateFile "middleware\__init__.py" ""
call :CreateFile "middleware\auth.py" "# JWT authentication"
call :CreateFile "middleware\vendor_context.py" "# Vendor context detection and injection"
call :CreateFile "middleware\rate_limiter.py" "# Rate limiting"
call :CreateFile "middleware\logging_middleware.py" "# Request logging"
call :CreateFile "middleware\decorators.py" "# Cross-cutting concern decorators"
:: Storage files
call :CreateFile "storage\__init__.py" ""
call :CreateFile "storage\backends.py" "# Storage backend implementations"
call :CreateFile "storage\utils.py" "# Storage utilities"
:: HTML files - Admin
call :CreateFile "static\admin\login.html" "<!-- Admin login page -->"
call :CreateFile "static\admin\dashboard.html" "<!-- Admin dashboard -->"
call :CreateFile "static\admin\vendors.html" "<!-- Vendor management -->"
call :CreateFile "static\admin\users.html" "<!-- User management -->"
call :CreateFile "static\admin\marketplace.html" "<!-- System-wide marketplace monitoring -->"
call :CreateFile "static\admin\monitoring.html" "<!-- System monitoring -->"
:: HTML files - Vendor
call :CreateFile "static\vendor\login.html" "<!-- Vendor team login -->"
call :CreateFile "static\vendor\dashboard.html" "<!-- Vendor dashboard -->"
call :CreateFile "static\vendor\admin\products.html" "<!-- Catalog management (Product table) -->"
call :CreateFile "static\vendor\admin\marketplace\imports.html" "<!-- Import jobs & history -->"
call :CreateFile "static\vendor\admin\marketplace\browse.html" "<!-- Browse marketplace products (staging) -->"
call :CreateFile "static\vendor\admin\marketplace\selected.html" "<!-- Selected products (pre-publish) -->"
call :CreateFile "static\vendor\admin\marketplace\config.html" "<!-- Marketplace configuration -->"
call :CreateFile "static\vendor\admin\orders.html" "<!-- Order management -->"
call :CreateFile "static\vendor\admin\customers.html" "<!-- Customer management -->"
call :CreateFile "static\vendor\admin\teams.html" "<!-- Team management -->"
call :CreateFile "static\vendor\admin\inventory.html" "<!-- Inventory management (catalog products) -->"
call :CreateFile "static\vendor\admin\payments.html" "<!-- Payment configuration -->"
call :CreateFile "static\vendor\admin\media.html" "<!-- Media library -->"
call :CreateFile "static\vendor\admin\notifications.html" "<!-- Notification templates & logs -->"
call :CreateFile "static\vendor\admin\settings.html" "<!-- Vendor settings -->"
:: HTML files - Shop
call :CreateFile "static\shop\home.html" "<!-- Shop homepage -->"
call :CreateFile "static\shop\products.html" "<!-- Product catalog (Product table only) -->"
call :CreateFile "static\shop\product.html" "<!-- Product detail page -->"
call :CreateFile "static\shop\search.html" "<!-- Search results page -->"
call :CreateFile "static\shop\cart.html" "<!-- Shopping cart -->"
call :CreateFile "static\shop\checkout.html" "<!-- Checkout process -->"
call :CreateFile "static\shop\account\login.html" "<!-- Customer login -->"
call :CreateFile "static\shop\account\register.html" "<!-- Customer registration -->"
call :CreateFile "static\shop\account\profile.html" "<!-- Customer profile -->"
call :CreateFile "static\shop\account\orders.html" "<!-- Order history -->"
call :CreateFile "static\shop\account\addresses.html" "<!-- Address management -->"
:: JavaScript files - Shared
call :CreateFile "static\js\shared\vendor-context.js" "// Vendor context detection & management"
call :CreateFile "static\js\shared\api-client.js" "// API communication utilities"
call :CreateFile "static\js\shared\notification.js" "// Notification handling"
call :CreateFile "static\js\shared\media-upload.js" "// File upload utilities"
call :CreateFile "static\js\shared\search.js" "// Search functionality"
:: JavaScript files - Admin
call :CreateFile "static\js\admin\dashboard.js" "// Admin dashboard"
call :CreateFile "static\js\admin\vendors.js" "// Vendor management"
call :CreateFile "static\js\admin\monitoring.js" "// System monitoring"
call :CreateFile "static\js\admin\analytics.js" "// Admin analytics"
:: JavaScript files - Vendor
call :CreateFile "static\js\vendor\products.js" "// Catalog management"
call :CreateFile "static\js\vendor\marketplace.js" "// Marketplace integration"
call :CreateFile "static\js\vendor\orders.js" "// Order management"
call :CreateFile "static\js\vendor\payments.js" "// Payment configuration"
call :CreateFile "static\js\vendor\media.js" "// Media management"
call :CreateFile "static\js\vendor\dashboard.js" "// Vendor dashboard"
:: JavaScript files - Shop
call :CreateFile "static\js\shop\catalog.js" "// Product browsing"
call :CreateFile "static\js\shop\search.js" "// Product search"
call :CreateFile "static\js\shop\cart.js" "// Shopping cart"
call :CreateFile "static\js\shop\checkout.js" "// Checkout process"
call :CreateFile "static\js\shop\account.js" "// Customer account"
echo.
echo ========================================
echo Build Complete!
echo ========================================
echo.
goto :eof
:: Function to create directory if it doesn't exist
:CreateDir
if not exist "%~1" (
mkdir "%~1"
echo [CREATED] Directory: %~1
) else (
echo [EXISTS] Directory: %~1
)
goto :eof
:: Function to create file if it doesn't exist
:CreateFile
if not exist "%~1" (
echo %~2 > "%~1"
echo [CREATED] File: %~1
) else (
echo [SKIPPED] File: %~1 (already exists)
)
goto :eof

View File

@@ -46,10 +46,10 @@ All API endpoints are versioned using URL path versioning:
- Shop-product associations
- Shop statistics
### Stock (`/stock/`)
### Inventory (`/inventory/`)
- Inventory management
- Stock movements
- Stock reporting
- Inventory movements
- Inventory reporting
### Marketplace (`/marketplace/`)
- Import job management

View File

@@ -32,7 +32,7 @@ app/exceptions/
├── marketplace.py # Import/marketplace exceptions
├── product.py # MarketplaceProduct management exceptions
├── shop.py # Shop management exceptions
└── stock.py # Stock management exceptions
└── inventory.py # Inventory management exceptions
```
## Core Concepts

View File

@@ -136,10 +136,10 @@ export const ERROR_MESSAGES = {
PRODUCT_ALREADY_EXISTS: 'A product with this ID already exists.',
INVALID_PRODUCT_DATA: 'Please check the product information and try again.',
// Stock errors
INSUFFICIENT_STOCK: 'Not enough stock available for this operation.',
STOCK_NOT_FOUND: 'No stock information found for this product.',
NEGATIVE_STOCK_NOT_ALLOWED: 'Stock quantity cannot be negative.',
// Inventory errors
INSUFFICIENT_INVENTORY: 'Not enough inventory available for this operation.',
INVENTORY_NOT_FOUND: 'No inventory information found for this product.',
NEGATIVE_INVENTORY_NOT_ALLOWED: 'Inventory quantity cannot be negative.',
// Shop errors
SHOP_NOT_FOUND: 'Shop not found or no longer available.',
@@ -431,26 +431,26 @@ apiClient.interceptors.response.use(
);
```
#### Stock Management Errors
#### Inventory Management Errors
```javascript
// components/StockManager.jsx
const handleStockUpdate = async (gtin, location, quantity) => {
// components/InventoryManager.jsx
const handleInventoryUpdate = async (gtin, location, quantity) => {
try {
await updateStock(gtin, location, quantity);
notificationManager.notify('success', 'Stock updated successfully');
await updateInventory(gtin, location, quantity);
notificationManager.notify('success', 'Inventory updated successfully');
} catch (error) {
switch (error.errorCode) {
case 'INSUFFICIENT_STOCK':
case 'INSUFFICIENT_INVENTORY':
const { available_quantity, requested_quantity } = error.details;
notificationManager.notify('error',
`Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
);
break;
case 'STOCK_NOT_FOUND':
notificationManager.notify('error', 'No stock record found for this product');
case 'INVENTORY_NOT_FOUND':
notificationManager.notify('error', 'No inventory record found for this product');
break;
case 'NEGATIVE_STOCK_NOT_ALLOWED':
notificationManager.notify('error', 'Stock quantity cannot be negative');
case 'NEGATIVE_INVENTORY_NOT_ALLOWED':
notificationManager.notify('error', 'Inventory quantity cannot be negative');
break;
default:
notificationManager.notifyError(error);

View File

@@ -97,7 +97,7 @@ if __name__ == "__main__":
"description": "A test product for demonstration",
"price": "19.99",
"brand": "Test Brand",
"availability": "in stock",
"availability": "in inventory",
}
product_result = create_product(admin_token, sample_product)
@@ -207,7 +207,7 @@ curl -X POST "http://localhost:8000/products" \
"description": "A test product for demonstration",
"price": "19.99",
"brand": "Test Brand",
"availability": "in stock"
"availability": "in inventory"
}'
```

View File

@@ -9,7 +9,7 @@ Letzshop Import is a powerful web application that enables:
- **MarketplaceProduct Management**: Create, update, and manage product catalogs
- **Shop Management**: Multi-shop support with individual configurations
- **CSV Import**: Bulk import products from various marketplace formats
- **Stock Management**: Track inventory across multiple locations
- **Inventory Management**: Track inventory across multiple locations
- **User Management**: Role-based access control for different user types
- **Marketplace Integration**: Import from various marketplace platforms

View File

@@ -1,10 +0,0 @@
# init.sql
-- Initial database setup
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create indexes for better performance
-- These will be managed by Alembic in production

38
main.py
View File

@@ -1,18 +1,22 @@
# main.py
import logging
from datetime import datetime, timezone
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.main import api_router
from app.routes.frontend import router as frontend_router
from app.core.config import settings
from app.core.database import get_db
from app.core.lifespan import lifespan
from app.exceptions.handler import setup_exception_handlers
from app.exceptions import ServiceUnavailableException
from middleware.vendor_context import vendor_context_middleware
logger = logging.getLogger(__name__)
@@ -36,12 +40,20 @@ app.add_middleware(
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix="/api/v1")
# Add vendor context middleware (ADDED - must be after CORS)
app.middleware("http")(vendor_context_middleware)
# ========================================
# MOUNT STATIC FILES - ADD THIS SECTION
# ========================================
app.mount("/static", StaticFiles(directory="static"), name="static")
# ========================================
# Include API router
app.include_router(api_router, prefix="/api")
app.include_router(frontend_router)
# Public Routes (no authentication required)
# Core application endpoints (Public Routes, no authentication required)
@app.get("/", include_in_schema=False)
async def root():
"""Redirect root to documentation"""
@@ -65,14 +77,20 @@ def health_check(db: Session = Depends(get_db)):
"complete": "/documentation",
},
"features": [
"JWT Authentication",
"Marketplace-aware product import",
"Multi-vendor product management",
"Stock management with location tracking",
"Multi-tenant architecture with vendor isolation",
"JWT Authentication with role-based access control",
"Marketplace product import and curation",
"Vendor catalog management",
"Product-based inventory tracking",
"Stripe Connect payment processing",
],
"supported_marketplaces": [
"Letzshop",
],
"deployment_modes": [
"Subdomain-based (production): vendor.platform.com",
"Path-based (development): /vendor/vendorname/",
],
"auth_required": "Most endpoints require Bearer token authentication",
}
except Exception as e:
@@ -80,18 +98,14 @@ def health_check(db: Session = Depends(get_db)):
raise ServiceUnavailableException("Service unhealthy")
# Documentation redirect endpoints
@app.get("/documentation", response_class=HTMLResponse, include_in_schema=False)
async def documentation():
"""Redirect to MkDocs documentation"""
# Development
"""Redirect to documentation"""
if settings.debug:
return RedirectResponse(url="http://localhost:8001")
# Production
return RedirectResponse(url=settings.documentation_url)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -0,0 +1,166 @@
# middleware/vendor_context.py
import logging
from typing import Optional
from fastapi import Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.core.database import get_db
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class VendorContextManager:
"""Manages vendor context detection for multi-tenant routing."""
@staticmethod
def detect_vendor_context(request: Request) -> Optional[dict]:
"""
Detect vendor context from request.
Returns dict with vendor info or None if not found.
"""
host = request.headers.get("host", "")
path = request.url.path
# Method 1: Subdomain detection (production)
if "." in host:
parts = host.split(".")
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
"subdomain": subdomain,
"detection_method": "subdomain",
"host": host
}
# Method 2: Path-based detection (development)
if path.startswith("/vendor/"):
path_parts = path.split("/")
if len(path_parts) >= 3:
subdomain = path_parts[2]
return {
"subdomain": subdomain,
"detection_method": "path",
"path_prefix": f"/vendor/{subdomain}",
"host": host
}
return None
@staticmethod
def get_vendor_from_context(db: Session, context: dict) -> Optional[Vendor]:
"""Get vendor from database using context information."""
if not context or "subdomain" not in context:
return None
# Query vendor by subdomain (case-insensitive)
vendor = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == context["subdomain"].lower())
.filter(Vendor.is_active == True) # Only active vendors
.first()
)
return vendor
@staticmethod
def extract_clean_path(request: Request, vendor_context: Optional[dict]) -> str:
"""Extract clean path without vendor prefix for routing."""
if not vendor_context:
return request.url.path
if vendor_context.get("detection_method") == "path":
path_prefix = vendor_context.get("path_prefix", "")
path = request.url.path
if path.startswith(path_prefix):
clean_path = path[len(path_prefix):]
return clean_path if clean_path else "/"
return request.url.path
@staticmethod
def is_admin_request(request: Request) -> bool:
"""Check if request is for admin interface."""
host = request.headers.get("host", "")
path = request.url.path
if host.startswith("admin."):
return True
if "/admin" in path:
return True
return False
@staticmethod
def is_api_request(request: Request) -> bool:
"""Check if request is for API endpoints."""
return request.url.path.startswith("/api/")
async def vendor_context_middleware(request: Request, call_next):
"""
Middleware to inject vendor context into request state.
"""
# Skip vendor detection for admin, API, and system requests
if (VendorContextManager.is_admin_request(request) or
VendorContextManager.is_api_request(request) or
request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]):
return await call_next(request)
# Detect vendor context
vendor_context = VendorContextManager.detect_vendor_context(request)
if vendor_context:
db_gen = get_db()
db = next(db_gen)
try:
vendor = VendorContextManager.get_vendor_from_context(db, vendor_context)
if vendor:
request.state.vendor = vendor
request.state.vendor_context = vendor_context
request.state.clean_path = VendorContextManager.extract_clean_path(
request, vendor_context
)
logger.debug(
f"Vendor context: {vendor.name} ({vendor.subdomain}) "
f"via {vendor_context['detection_method']}"
)
else:
logger.warning(
f"Vendor not found for subdomain: {vendor_context['subdomain']}"
)
request.state.vendor = None
request.state.vendor_context = vendor_context
finally:
db.close()
else:
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
return await call_next(request)
def get_current_vendor(request: Request) -> Optional[Vendor]:
"""Helper function to get current vendor from request state."""
return getattr(request.state, "vendor", None)
def require_vendor_context():
"""Dependency to require vendor context in endpoints."""
def dependency(request: Request):
vendor = get_current_vendor(request)
if not vendor:
from fastapi import HTTPException
raise HTTPException(
status_code=404,
detail="Vendor not found or not active"
)
return vendor
return dependency

View File

@@ -5,7 +5,7 @@
from .database.base import Base
from .database.user import User
from .database.marketplace_product import MarketplaceProduct
from .database.stock import Stock
from .database.inventory import Inventory
from .database.vendor import Vendor
from .database.product import Product
from .database.marketplace_import_job import MarketplaceImportJob
@@ -18,7 +18,7 @@ __all__ = [
"Base",
"User",
"MarketplaceProduct",
"Stock",
"Inventory",
"Vendor",
"Product",
"MarketplaceImportJob",

View File

@@ -2,9 +2,11 @@
"""Database models package."""
from .base import Base
from .customer import Customer
from .order import Order
from .user import User
from .marketplace_product import MarketplaceProduct
from .stock import Stock
from .inventory import Inventory
from .vendor import Vendor
from .product import Product
from .marketplace_import_job import MarketplaceImportJob
@@ -13,7 +15,9 @@ __all__ = [
"Base",
"User",
"MarketplaceProduct",
"Stock",
"Inventory",
"Customer",
"Order",
"Vendor",
"Product",
"MarketplaceImportJob",

View File

@@ -0,0 +1,64 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Numeric
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class Customer(Base, TimestampMixin):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
email = Column(String(255), nullable=False, index=True) # Unique within vendor scope
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
customer_number = Column(String(100), nullable=False, index=True) # Vendor-specific ID
preferences = Column(JSON, default=dict)
marketing_consent = Column(Boolean, default=False)
last_order_date = Column(DateTime)
total_orders = Column(Integer, default=0)
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="customers")
addresses = relationship("CustomerAddress", back_populates="customer")
orders = relationship("Order", back_populates="customer")
def __repr__(self):
return f"<Customer(id={self.id}, vendor_id={self.vendor_id}, email='{self.email}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerAddress(Base, TimestampMixin):
__tablename__ = "customer_addresses"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
address_type = Column(String(50), nullable=False) # 'billing', 'shipping'
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
company = Column(String(200))
address_line_1 = Column(String(255), nullable=False)
address_line_2 = Column(String(255))
city = Column(String(100), nullable=False)
postal_code = Column(String(20), nullable=False)
country = Column(String(100), nullable=False)
is_default = Column(Boolean, default=False)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="addresses")
def __repr__(self):
return f"<CustomerAddress(id={self.id}, customer_id={self.customer_id}, type='{self.address_type}')>"

View File

@@ -0,0 +1,41 @@
# models/database/inventory.py
from datetime import datetime
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Inventory(Base, TimestampMixin):
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0)
# Optional: Keep GTIN for reference/reporting
gtin = Column(String, index=True)
# Relationships
product = relationship("Product", back_populates="inventory_entries")
vendor = relationship("Vendor")
# Constraints
__table_args__ = (
UniqueConstraint("product_id", "location", name="uq_inventory_product_location"),
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
Index("idx_inventory_product_location", "product_id", "location"),
)
def __repr__(self):
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
@property
def available_quantity(self):
"""Calculate available quantity (total - reserved)."""
return max(0, self.quantity - self.reserved_quantity)

View File

@@ -1,10 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,20 +11,17 @@ class MarketplaceImportJob(Base, TimestampMixin):
__tablename__ = "marketplace_import_jobs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Import configuration
marketplace = Column(String, nullable=False, index=True, default="Letzshop")
source_url = Column(String, nullable=False)
# Status tracking
status = Column(
String, nullable=False, default="pending"
) # pending, processing, completed, failed, completed_with_errors
source_url = Column(String, nullable=False)
marketplace = Column(
String, nullable=False, index=True, default="Letzshop"
) # Index for marketplace filtering
vendor_name = Column(String, nullable=False, index=True) # Index for vendor filtering
vendor_id = Column(
Integer, ForeignKey("vendors.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)
@@ -35,28 +30,26 @@ class MarketplaceImportJob(Base, TimestampMixin):
total_processed = Column(Integer, default=0)
# Error handling
error_message = Column(String)
error_message = Column(Text)
# Timestamps
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Relationship to user
user = relationship("User", foreign_keys=[user_id])
# Relationships
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
user = relationship("User", foreign_keys=[user_id])
# Additional indexes for marketplace import job queries
# Indexes for performance
__table_args__ = (
Index(
"idx_marketplace_import_user_marketplace", "user_id", "marketplace"
), # User's marketplace imports
Index("idx_marketplace_import_vendor_status", "status"), # Vendor import status
Index("idx_marketplace_import_vendor_id", "vendor_id"),
Index("idx_import_vendor_status", "vendor_id", "status"),
Index("idx_import_vendor_created", "vendor_id", "created_at"),
Index("idx_import_user_marketplace", "user_id", "marketplace"),
)
def __repr__(self):
return (
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', vendor='{self.vendor_name}', "
f"status='{self.status}', imported={self.imported_count})>"
f"<MarketplaceImportJob(id={self.id}, vendor_id={self.vendor_id}, "
f"marketplace='{self.marketplace}', status='{self.status}', "
f"imported={self.imported_count})>"
)

View File

@@ -21,7 +21,7 @@ class MarketplaceProduct(Base, TimestampMixin):
availability = Column(String, index=True) # Index for filtering
price = Column(String)
brand = Column(String, index=True) # Index for filtering
gtin = Column(String, index=True) # Index for stock lookups
gtin = Column(String, index=True) # Index for inventory lookups
mpn = Column(String)
condition = Column(String)
adult = Column(String)
@@ -57,13 +57,6 @@ class MarketplaceProduct(Base, TimestampMixin):
) # Index for marketplace filtering
vendor_name = Column(String, index=True, nullable=True) # Index for vendor filtering
# Relationship to stock (one-to-many via GTIN)
stock_entries = relationship(
"Stock",
foreign_keys="Stock.gtin",
primaryjoin="MarketplaceProduct.gtin == Stock.gtin",
viewonly=True,
)
product = relationship("Product", back_populates="marketplace_product")
# Additional indexes for marketplace queries

86
models/database/order.py Normal file
View File

@@ -0,0 +1,86 @@
# models/database/order.py
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text, Boolean
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Order(Base, TimestampMixin):
"""Customer orders."""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
order_number = Column(String, nullable=False, unique=True, index=True)
# Order status
status = Column(String, nullable=False, default="pending", index=True)
# pending, processing, shipped, delivered, cancelled, refunded
# Financial
subtotal = Column(Float, nullable=False)
tax_amount = Column(Float, default=0.0)
shipping_amount = Column(Float, default=0.0)
discount_amount = Column(Float, default=0.0)
total_amount = Column(Float, nullable=False)
currency = Column(String, default="EUR")
# Addresses (stored as IDs)
shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
billing_address_id = Column(Integer, ForeignKey("customer_addresses.id"), nullable=False)
# Shipping
shipping_method = Column(String, nullable=True)
tracking_number = Column(String, nullable=True)
# Notes
customer_notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True)
# Timestamps
paid_at = Column(DateTime, nullable=True)
shipped_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
cancelled_at = Column(DateTime, nullable=True)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id])
billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id])
def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', status='{self.status}')>"
class OrderItem(Base, TimestampMixin):
"""Individual items in an order."""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Product details at time of order (snapshot)
product_name = Column(String, nullable=False)
product_sku = Column(String, nullable=True)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
total_price = Column(Float, nullable=False)
# Inventory tracking
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product")
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id})>"

View File

@@ -1,13 +1,12 @@
# models/database/product.py
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
__tablename__ = "products"
@@ -15,12 +14,12 @@ class Product(Base, TimestampMixin):
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
# Vendor-specific overrides (can override the main product data)
product_id = Column(String) # Vendor's internal product ID
price = Column(Float) # Override main product price
# Vendor-specific overrides
product_id = Column(String) # Vendor's internal SKU
price = Column(Float)
sale_price = Column(Float)
currency = Column(String)
availability = Column(String) # Override availability
availability = Column(String)
condition = Column(String)
# Vendor-specific metadata
@@ -28,13 +27,14 @@ class Product(Base, TimestampMixin):
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory management
# Inventory settings
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Relationships
vendor = relationship("Vendor", back_populates="product")
vendor = relationship("Vendor", back_populates="products")
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
inventory_entries = relationship("Inventory", back_populates="product", cascade="all, delete-orphan")
# Constraints
__table_args__ = (
@@ -42,3 +42,16 @@ class Product(Base, TimestampMixin):
Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "vendor_id", "is_featured"),
)
def __repr__(self):
return f"<Product(id={self.id}, vendor_id={self.vendor_id}, product_id='{self.product_id}')>"
@property
def total_inventory(self):
"""Calculate total inventory across all locations."""
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self):
"""Calculate available inventory (total - reserved)."""
return sum(inv.available_quantity for inv in self.inventory_entries)

View File

@@ -1,35 +0,0 @@
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Stock(Base, TimestampMixin):
__tablename__ = "stock"
id = Column(Integer, primary_key=True, index=True)
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
vendor_id = Column(Integer, ForeignKey("vendors.id")) # Optional: vendor -specific stock
# Relationships
vendor = relationship("Vendor")
# Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = (
UniqueConstraint("gtin", "location", name="uq_stock_gtin_location"),
Index(
"idx_stock_gtin_location", "gtin", "location"
), # Composite index for efficient queries
)
def __repr__(self):
return f"<Stock(gtin='{self.gtin}', location='{self.location}', quantity={self.quantity})>"

View File

@@ -14,6 +14,8 @@ class User(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # user, admin, vendor_owner
is_active = Column(Boolean, default=True, nullable=False)
@@ -23,7 +25,16 @@ class User(Base, TimestampMixin):
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"
)
# Vendor relationships
owned_vendors = relationship("Vendor", back_populates="owner")
vendor_memberships = relationship("VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username

View File

@@ -1,7 +1,6 @@
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text)
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
@@ -13,15 +12,22 @@ class Vendor(Base, TimestampMixin):
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB"
vendor_name = Column(String, nullable=False) # Display name
subdomain = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String, nullable=False) # Display name
description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
theme_config = Column(JSON, default=dict)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
website = Column(String)
# Letzshop URLs - multi-language support
letzshop_csv_url_fr = Column(String)
letzshop_csv_url_en = Column(String)
letzshop_csv_url_de = Column(String)
# Business information
business_address = Column(Text)
tax_number = Column(String)
@@ -32,7 +38,47 @@ class Vendor(Base, TimestampMixin):
# Relationships
owner = relationship("User", back_populates="owned_vendors")
product = relationship("Product", back_populates="vendor")
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="vendor"
)
vendor_users = relationship("VendorUser", back_populates="vendor")
products = relationship("Product", back_populates="vendor")
customers = relationship("Customer", back_populates="vendor")
orders = relationship("Order", back_populates="vendor")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
def __repr__(self):
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
invited_by = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
user = relationship("User", foreign_keys=[user_id], back_populates="vendor_memberships")
inviter = relationship("User", foreign_keys=[invited_by])
role = relationship("Role", back_populates="vendor_users")
def __repr__(self):
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id})>"
class Role(Base, TimestampMixin):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
name = Column(String(100), nullable=False) # "Owner", "Manager", "Editor", "Viewer"
permissions = Column(JSON, default=list) # ["products.create", "orders.view", etc.]
# Relationships
vendor = relationship("Vendor")
vendor_users = relationship("VendorUser", back_populates="role")
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"

View File

@@ -7,7 +7,7 @@ from . import base
from . import marketplace_import_job
from . import marketplace_product
from . import stats
from . import stock
from . import inventory
from . import vendor
# Common imports for convenience
from .base import * # Base Pydantic models
@@ -16,7 +16,7 @@ __all__ = [
"base",
"auth",
"marketplace_product",
"stock",
"inventory.py",
"vendor",
"marketplace_import_job",
"stats",

193
models/schemas/customer.py Normal file
View File

@@ -0,0 +1,193 @@
# models/schemas/customer.py
"""
Pydantic schemas for customer-related operations.
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, EmailStr, Field, field_validator
# ============================================================================
# Customer Registration & Authentication
# ============================================================================
class CustomerRegister(BaseModel):
"""Schema for customer registration."""
email: EmailStr = Field(..., description="Customer email address")
password: str = Field(
...,
min_length=8,
description="Password (minimum 8 characters)"
)
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
phone: Optional[str] = Field(None, max_length=50)
marketing_consent: bool = Field(default=False)
@field_validator('email')
@classmethod
def email_lowercase(cls, v: str) -> str:
"""Convert email to lowercase."""
return v.lower()
@field_validator('password')
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isalpha() for char in v):
raise ValueError("Password must contain at least one letter")
return v
class CustomerUpdate(BaseModel):
"""Schema for updating customer profile."""
email: Optional[EmailStr] = None
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
phone: Optional[str] = Field(None, max_length=50)
marketing_consent: Optional[bool] = None
@field_validator('email')
@classmethod
def email_lowercase(cls, v: Optional[str]) -> Optional[str]:
"""Convert email to lowercase."""
return v.lower() if v else None
# ============================================================================
# Customer Response
# ============================================================================
class CustomerResponse(BaseModel):
"""Schema for customer response (excludes password)."""
id: int
vendor_id: int
email: str
first_name: Optional[str]
last_name: Optional[str]
phone: Optional[str]
customer_number: str
marketing_consent: bool
last_order_date: Optional[datetime]
total_orders: int
total_spent: Decimal
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {
"from_attributes": True
}
@property
def full_name(self) -> str:
"""Get customer full name."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerListResponse(BaseModel):
"""Schema for paginated customer list."""
customers: List[CustomerResponse]
total: int
page: int
per_page: int
total_pages: int
# ============================================================================
# Customer Address
# ============================================================================
class CustomerAddressCreate(BaseModel):
"""Schema for creating customer address."""
address_type: str = Field(..., pattern="^(billing|shipping)$")
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: Optional[str] = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: Optional[str] = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country: str = Field(..., min_length=2, max_length=100)
is_default: bool = Field(default=False)
class CustomerAddressUpdate(BaseModel):
"""Schema for updating customer address."""
address_type: Optional[str] = Field(None, pattern="^(billing|shipping)$")
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
company: Optional[str] = Field(None, max_length=200)
address_line_1: Optional[str] = Field(None, min_length=1, max_length=255)
address_line_2: Optional[str] = Field(None, max_length=255)
city: Optional[str] = Field(None, min_length=1, max_length=100)
postal_code: Optional[str] = Field(None, min_length=1, max_length=20)
country: Optional[str] = Field(None, min_length=2, max_length=100)
is_default: Optional[bool] = None
class CustomerAddressResponse(BaseModel):
"""Schema for customer address response."""
id: int
vendor_id: int
customer_id: int
address_type: str
first_name: str
last_name: str
company: Optional[str]
address_line_1: str
address_line_2: Optional[str]
city: str
postal_code: str
country: str
is_default: bool
created_at: datetime
updated_at: datetime
model_config = {
"from_attributes": True
}
# ============================================================================
# Customer Statistics
# ============================================================================
class CustomerStatsResponse(BaseModel):
"""Schema for customer statistics."""
customer_id: int
total_orders: int
total_spent: Decimal
average_order_value: Decimal
last_order_date: Optional[datetime]
first_order_date: Optional[datetime]
lifetime_value: Decimal
# ============================================================================
# Customer Preferences
# ============================================================================
class CustomerPreferencesUpdate(BaseModel):
"""Schema for updating customer preferences."""
marketing_consent: Optional[bool] = None
language: Optional[str] = Field(None, max_length=10)
currency: Optional[str] = Field(None, max_length=3)
notification_preferences: Optional[Dict[str, bool]] = None

View File

@@ -0,0 +1,77 @@
# models/schema/inventory.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class InventoryBase(BaseModel):
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
class InventoryCreate(InventoryBase):
"""Set exact inventory quantity (replaces existing)."""
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
class InventoryAdjust(InventoryBase):
"""Add or remove inventory quantity."""
quantity: int = Field(..., description="Quantity to add (positive) or remove (negative)")
class InventoryUpdate(BaseModel):
"""Update inventory fields."""
quantity: Optional[int] = Field(None, ge=0)
reserved_quantity: Optional[int] = Field(None, ge=0)
location: Optional[str] = None
class InventoryReserve(BaseModel):
"""Reserve inventory for orders."""
product_id: int
location: str
quantity: int = Field(..., gt=0)
class InventoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
vendor_id: int
location: str
quantity: int
reserved_quantity: int
gtin: Optional[str]
created_at: datetime
updated_at: datetime
@property
def available_quantity(self):
return max(0, self.quantity - self.reserved_quantity)
class InventoryLocationResponse(BaseModel):
location: str
quantity: int
reserved_quantity: int
available_quantity: int
class ProductInventorySummary(BaseModel):
"""Inventory summary for a product."""
product_id: int
vendor_id: int
product_sku: Optional[str]
product_title: str
total_quantity: int
total_reserved: int
total_available: int
locations: List[InventoryLocationResponse]
class InventoryListResponse(BaseModel):
inventories: List[InventoryResponse]
total: int
skip: int
limit: int

View File

@@ -1,41 +1,71 @@
# models/schemas/marketplace_import_job.py - Keep URL validation, remove business constraints
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
class MarketplaceImportJobRequest(BaseModel):
url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
vendor_code: str = Field(..., description="Vendor code to associate products with")
batch_size: Optional[int] = Field(1000, description="Processing batch size")
# Removed: gt=0, le=10000 constraints - let service handle
"""Request schema for triggering marketplace import.
@field_validator("url")
Note: vendor_id is injected by middleware, not from request body.
"""
source_url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
batch_size: Optional[int] = Field(1000, description="Processing batch size", ge=100, le=10000)
@field_validator("source_url")
@classmethod
def validate_url(cls, v):
# Keep URL format validation for security
# Basic URL security validation
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
@field_validator("marketplace", "vendor_code")
@classmethod
def validate_strings(cls, v):
return v.strip()
@field_validator("marketplace")
@classmethod
def validate_marketplace(cls, v):
return v.strip()
class MarketplaceImportJobResponse(BaseModel):
"""Response schema for marketplace import job."""
model_config = ConfigDict(from_attributes=True)
job_id: int
status: str
marketplace: str
vendor_id: int
vendor_code: Optional[str] = None
vendor_name: str
message: Optional[str] = None
imported: Optional[int] = 0
updated: Optional[int] = 0
total_processed: Optional[int] = 0
error_count: Optional[int] = 0
vendor_code: str # Populated from vendor relationship
vendor_name: str # Populated from vendor relationship
marketplace: str
source_url: str
status: str
# Counts
imported: int = 0
updated: int = 0
total_processed: int = 0
error_count: int = 0
# Error details
error_message: Optional[str] = None
created_at: Optional[datetime] = None
# Timestamps
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class MarketplaceImportJobListResponse(BaseModel):
"""Response schema for list of import jobs."""
jobs: list[MarketplaceImportJobResponse]
total: int
skip: int
limit: int
class MarketplaceImportJobStatusUpdate(BaseModel):
"""Schema for updating import job status (internal use)."""
status: str
imported_count: Optional[int] = None
updated_count: Optional[int] = None
error_count: Optional[int] = None
total_processed: Optional[int] = None
error_message: Optional[str] = None

View File

@@ -2,7 +2,7 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from models.schemas.stock import StockSummaryResponse
from models.schemas.inventory import ProductInventorySummary
class MarketplaceProductBase(BaseModel):
marketplace_product_id: Optional[str] = None
@@ -68,4 +68,4 @@ class MarketplaceProductListResponse(BaseModel):
class MarketplaceProductDetailResponse(BaseModel):
product: MarketplaceProductResponse
stock_info: Optional[StockSummaryResponse] = None
inventory_info: Optional[ProductInventorySummary] = None

170
models/schemas/order.py Normal file
View File

@@ -0,0 +1,170 @@
# models/schemas/order.py
"""
Pydantic schemas for order operations.
"""
from datetime import datetime
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
# ============================================================================
# Order Item Schemas
# ============================================================================
class OrderItemCreate(BaseModel):
"""Schema for creating an order item."""
product_id: int
quantity: int = Field(..., ge=1)
class OrderItemResponse(BaseModel):
"""Schema for order item response."""
model_config = ConfigDict(from_attributes=True)
id: int
order_id: int
product_id: int
product_name: str
product_sku: Optional[str]
quantity: int
unit_price: float
total_price: float
inventory_reserved: bool
inventory_fulfilled: bool
created_at: datetime
updated_at: datetime
# ============================================================================
# Order Address Schemas
# ============================================================================
class OrderAddressCreate(BaseModel):
"""Schema for order address (shipping/billing)."""
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: Optional[str] = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: Optional[str] = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country: str = Field(..., min_length=2, max_length=100)
class OrderAddressResponse(BaseModel):
"""Schema for order address response."""
model_config = ConfigDict(from_attributes=True)
id: int
address_type: str
first_name: str
last_name: str
company: Optional[str]
address_line_1: str
address_line_2: Optional[str]
city: str
postal_code: str
country: str
# ============================================================================
# Order Create/Update Schemas
# ============================================================================
class OrderCreate(BaseModel):
"""Schema for creating an order."""
customer_id: Optional[int] = None # Optional for guest checkout
items: List[OrderItemCreate] = Field(..., min_length=1)
# Addresses
shipping_address: OrderAddressCreate
billing_address: Optional[OrderAddressCreate] = None # Use shipping if not provided
# Optional fields
shipping_method: Optional[str] = None
customer_notes: Optional[str] = Field(None, max_length=1000)
# Cart/session info
session_id: Optional[str] = None
class OrderUpdate(BaseModel):
"""Schema for updating order status."""
status: Optional[str] = Field(
None,
pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
)
tracking_number: Optional[str] = None
internal_notes: Optional[str] = None
# ============================================================================
# Order Response Schemas
# ============================================================================
class OrderResponse(BaseModel):
"""Schema for order response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
customer_id: int
order_number: str
status: str
# Financial
subtotal: float
tax_amount: float
shipping_amount: float
discount_amount: float
total_amount: float
currency: str
# Shipping
shipping_method: Optional[str]
tracking_number: Optional[str]
# Notes
customer_notes: Optional[str]
internal_notes: Optional[str]
# Timestamps
created_at: datetime
updated_at: datetime
paid_at: Optional[datetime]
shipped_at: Optional[datetime]
delivered_at: Optional[datetime]
cancelled_at: Optional[datetime]
class OrderDetailResponse(OrderResponse):
"""Schema for detailed order response with items and addresses."""
items: List[OrderItemResponse]
shipping_address: OrderAddressResponse
billing_address: OrderAddressResponse
class OrderListResponse(BaseModel):
"""Schema for paginated order list."""
orders: List[OrderResponse]
total: int
skip: int
limit: int
# ============================================================================
# Order Statistics
# ============================================================================
class OrderStatsResponse(BaseModel):
"""Schema for order statistics."""
total_orders: int
pending_orders: int
processing_orders: int
shipped_orders: int
delivered_orders: int
cancelled_orders: int
total_revenue: Decimal
average_order_value: Decimal

View File

@@ -1,25 +1,40 @@
# product.py - Keep basic format validation, remove business logic
import re
# models/schema/product.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from models.schemas.marketplace_product import MarketplaceProductResponse
from models.schemas.inventory import InventoryLocationResponse
class ProductCreate(BaseModel):
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to vendor ")
product_id: Optional[str] = None
price: Optional[float] = None # Removed: ge=0 constraint
sale_price: Optional[float] = None # Removed: ge=0 constraint
marketplace_product_id: int = Field(..., description="MarketplaceProduct ID to add to vendor catalog")
product_id: Optional[str] = Field(None, description="Vendor's internal SKU/product ID")
price: Optional[float] = Field(None, ge=0)
sale_price: Optional[float] = Field(None, ge=0)
currency: Optional[str] = None
availability: Optional[str] = None
condition: Optional[str] = None
is_featured: bool = Field(False, description="Featured product flag")
min_quantity: int = Field(1, description="Minimum order quantity")
max_quantity: Optional[int] = None # Removed: ge=1 constraint
# Service will validate price ranges and quantity logic
is_featured: bool = False
min_quantity: int = Field(1, ge=1)
max_quantity: Optional[int] = Field(None, ge=1)
class ProductUpdate(BaseModel):
product_id: Optional[str] = None
price: Optional[float] = Field(None, ge=0)
sale_price: Optional[float] = Field(None, ge=0)
currency: Optional[str] = None
availability: Optional[str] = None
condition: Optional[str] = None
is_featured: Optional[bool] = None
is_active: Optional[bool] = None
min_quantity: Optional[int] = Field(None, ge=1)
max_quantity: Optional[int] = Field(None, ge=1)
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
marketplace_product: MarketplaceProductResponse
@@ -31,7 +46,24 @@ class ProductResponse(BaseModel):
condition: Optional[str]
is_featured: bool
is_active: bool
display_order: int
min_quantity: int
max_quantity: Optional[int]
created_at: datetime
updated_at: datetime
# Include inventory summary
total_inventory: Optional[int] = None
available_inventory: Optional[int] = None
class ProductDetailResponse(ProductResponse):
"""Product with full inventory details."""
inventory_locations: List[InventoryLocationResponse] = []
class ProductListResponse(BaseModel):
products: List[ProductResponse]
total: int
skip: int
limit: int

View File

@@ -11,7 +11,7 @@ class StatsResponse(BaseModel):
unique_categories: int
unique_marketplaces: int = 0
unique_vendors: int = 0
total_stock_entries: int = 0
total_inventory_entries: int = 0
total_inventory_quantity: int = 0

View File

@@ -1,39 +0,0 @@
# stock.py - Remove business logic validation constraints
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class StockBase(BaseModel):
gtin: str = Field(..., description="GTIN identifier")
location: str = Field(..., description="Storage location")
class StockCreate(StockBase):
quantity: int = Field(..., description="Initial stock quantity")
# Removed: ge=0 constraint - let service handle negative validation
class StockAdd(StockBase):
quantity: int = Field(..., description="Quantity to add/remove")
# Removed: gt=0 constraint - let service handle zero/negative validation
class StockUpdate(BaseModel):
quantity: int = Field(..., description="New stock quantity")
# Removed: ge=0 constraint - let service handle negative validation
class StockResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
gtin: str
location: str
quantity: int
created_at: datetime
updated_at: datetime
class StockLocationResponse(BaseModel):
location: str
quantity: int
class StockSummaryResponse(BaseModel):
gtin: str
total_quantity: int
locations: List[StockLocationResponse]
product_title: Optional[str] = None

View File

@@ -1,36 +1,72 @@
# vendor.py - Keep basic format validation, remove business logic
# models/schemas/vendor.py
import re
from datetime import datetime
from typing import List, Optional
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class VendorCreate(BaseModel):
vendor_code: str = Field(..., description="Unique vendor identifier")
vendor_name: str = Field(..., description="Display name of the vendor ")
description: Optional[str] = Field(None, description="Vendor description")
contact_email: Optional[str] = None
"""Schema for creating a new vendor."""
vendor_code: str = Field(..., description="Unique vendor identifier (e.g., TECHSTORE)")
subdomain: str = Field(..., description="Unique subdomain for the vendor")
name: str = Field(..., description="Display name of the vendor")
description: Optional[str] = None
# Owner information - REQUIRED for admin creation
owner_email: str = Field(..., description="Email for the vendor owner account")
# Contact information
contact_phone: Optional[str] = None
website: Optional[str] = None
# Business information
business_address: Optional[str] = None
tax_number: Optional[str] = None
# Removed: min_length, max_length constraints - let service handle
@field_validator("contact_email")
# Letzshop CSV URLs (multi-language support)
letzshop_csv_url_fr: Optional[str] = None
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
# Theme configuration
theme_config: Optional[Dict] = Field(default_factory=dict)
@field_validator("owner_email")
@classmethod
def validate_contact_email(cls, v):
# Keep basic format validation for data integrity
if v and ("@" not in v or "." not in v):
raise ValueError("Invalid email format")
def validate_owner_email(cls, v):
if not v or "@" not in v or "." not in v:
raise ValueError("Valid email address required for vendor owner")
return v.lower()
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
# Basic subdomain validation: lowercase alphanumeric with hyphens
if v and not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', v):
raise ValueError("Subdomain must contain only lowercase letters, numbers, and hyphens")
return v.lower() if v else v
@field_validator("vendor_code")
@classmethod
def validate_vendor_code(cls, v):
# Ensure vendor code is uppercase for consistency
return v.upper() if v else v
class VendorUpdate(BaseModel):
vendor_name: Optional[str] = None
"""Schema for updating vendor information."""
name: Optional[str] = None
description: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
website: Optional[str] = None
business_address: Optional[str] = None
tax_number: Optional[str] = None
letzshop_csv_url_fr: Optional[str] = None
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
theme_config: Optional[Dict] = None
is_active: Optional[bool] = None
@field_validator("contact_email")
@classmethod
@@ -39,25 +75,66 @@ class VendorUpdate(BaseModel):
raise ValueError("Invalid email format")
return v.lower() if v else v
class VendorResponse(BaseModel):
"""Schema for vendor response data."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
vendor_name: str
subdomain: str
name: str
description: Optional[str]
owner_id: int
owner_user_id: int
# Contact information
contact_email: Optional[str]
contact_phone: Optional[str]
website: Optional[str]
# Business information
business_address: Optional[str]
tax_number: Optional[str]
# Letzshop URLs
letzshop_csv_url_fr: Optional[str]
letzshop_csv_url_en: Optional[str]
letzshop_csv_url_de: Optional[str]
# Theme configuration
theme_config: Dict
# Status flags
is_active: bool
is_verified: bool
# Timestamps
created_at: datetime
updated_at: datetime
class VendorListResponse(BaseModel):
"""Schema for paginated vendor list."""
vendors: List[VendorResponse]
total: int
skip: int
limit: int
class VendorSummary(BaseModel):
"""Lightweight vendor summary for dropdowns and quick references."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_code: str
subdomain: str
name: str
is_active: bool
class VendorCreateResponse(VendorResponse):
"""Extended response for vendor creation with owner credentials."""
owner_email: str
owner_username: str
temporary_password: str
login_url: Optional[str] = None

View File

@@ -35,7 +35,7 @@ markers =
performance: marks tests as performance and load tests
auth: marks tests as authentication and authorization tests
products: marks tests as product management functionality
stock: marks tests as stock and inventory management
inventory: marks tests as inventory and inventory management
vendors: marks tests as vendor management functionality
admin: marks tests as admin functionality and permissions
marketplace: marks tests as marketplace import functionality

View File

@@ -65,7 +65,7 @@ def verify_database_setup():
# Expected tables from your models
expected_tables = [
'users', 'products', 'stock', 'vendors', 'products',
'users', 'products', 'inventory', 'vendors', 'products',
'marketplace_import_jobs', 'alembic_version'
]
@@ -132,7 +132,7 @@ def verify_model_structure():
# Import specific models
from models.database.user import User
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.inventory import Inventory
from models.database.vendor import Vendor
from models.database.product import Product
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -149,7 +149,7 @@ def verify_model_structure():
print("[OK] API models package imported")
# Test specific API model imports
api_modules = ['base', 'auth', 'product', 'stock', 'vendor ', 'marketplace', 'admin', 'stats']
api_modules = ['base', 'auth', 'product', 'inventory', 'vendor ', 'marketplace', 'admin', 'stats']
for module in api_modules:
try:
__import__(f'models.api.{module}')
@@ -173,7 +173,7 @@ def check_project_structure():
"models/database/base.py",
"models/database/user.py",
"models/database/marketplace_products.py",
"models/database/stock.py",
"models/database/inventory.py",
"app/core/config.py",
"alembic/env.py",
"alembic.ini"
@@ -221,7 +221,7 @@ if __name__ == "__main__":
print("Next steps:")
print(" 1. Run 'make dev' to start your FastAPI server")
print(" 2. Visit http://localhost:8000/docs for interactive API docs")
print(" 3. Use your API endpoints for authentication, products, stock, etc.")
print(" 3. Use your API endpoints for authentication, products, inventory, etc.")
sys.exit(0)
else:
print()

604
static/admin/dashboard.html Normal file
View File

@@ -0,0 +1,604 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
</head>
<body>
<!-- Header -->
<header class="admin-header">
<div class="header-left">
<h1>🔐 Admin Dashboard</h1>
</div>
<div class="header-right">
<span class="user-info">Welcome, <strong id="adminUsername">Admin</strong></span>
<button class="btn-logout" onclick="handleLogout()">Logout</button>
</div>
</header>
<!-- Main Container -->
<div class="admin-container">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav>
<ul class="nav-menu">
<li class="nav-item">
<a href="#" class="nav-link active" onclick="showSection('dashboard')">
📊 Dashboard
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('vendors')">
🏪 Vendors
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('users')">
👥 Users
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showSection('imports')">
📦 Import Jobs
</a>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="admin-content">
<!-- Dashboard View -->
<div id="dashboardView">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Total Vendors</div>
</div>
<div class="stat-icon">🏪</div>
</div>
<div class="stat-value" id="totalVendors">-</div>
<div class="stat-subtitle">
<span id="activeVendors">-</span> active
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Total Users</div>
</div>
<div class="stat-icon">👥</div>
</div>
<div class="stat-value" id="totalUsers">-</div>
<div class="stat-subtitle">
<span id="activeUsers">-</span> active
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Verified Vendors</div>
</div>
<div class="stat-icon"></div>
</div>
<div class="stat-value" id="verifiedVendors">-</div>
<div class="stat-subtitle">
<span id="verificationRate">-</span>% verification rate
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<div>
<div class="stat-title">Import Jobs</div>
</div>
<div class="stat-icon">📦</div>
</div>
<div class="stat-value" id="totalImports">-</div>
<div class="stat-subtitle">
<span id="completedImports">-</span> completed
</div>
</div>
</div>
<!-- Recent Vendors -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Vendors</h2>
<button class="btn-primary" onclick="showSection('vendors')">View All</button>
</div>
<div id="recentVendorsList">
<div class="loading">Loading recent vendors...</div>
</div>
</div>
<!-- Recent Import Jobs -->
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Recent Import Jobs</h2>
<button class="btn-primary" onclick="showSection('imports')">View All</button>
</div>
<div id="recentImportsList">
<div class="loading">Loading recent imports...</div>
</div>
</div>
</div>
<!-- Vendors View -->
<div id="vendorsView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Vendor Management</h2>
<button class="btn-primary" onclick="window.location.href='/static/admin/vendors.html'">
Create New Vendor
</button>
</div>
<div id="vendorsList">
<div class="loading">Loading vendors...</div>
</div>
</div>
</div>
<!-- Users View -->
<div id="usersView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">User Management</h2>
</div>
<div id="usersList">
<div class="loading">Loading users...</div>
</div>
</div>
</div>
<!-- Imports View -->
<div id="importsView" style="display: none;">
<div class="content-section">
<div class="section-header">
<h2 class="section-title">Import Jobs</h2>
</div>
<div id="importsList">
<div class="loading">Loading import jobs...</div>
</div>
</div>
</div>
</main>
</div>
<script>
const API_BASE_URL = '/api/v1';
let currentSection = 'dashboard';
// Check authentication
function checkAuth() {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (!token || !user) {
window.location.href = '/static/admin/login.html';
return false;
}
try {
const userData = JSON.parse(user);
if (userData.role !== 'admin') {
alert('Access denied. Admin privileges required.');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
return false;
}
document.getElementById('adminUsername').textContent = userData.username;
return true;
} catch (e) {
window.location.href = '/static/admin/login.html';
return false;
}
}
// Logout
function handleLogout() {
if (confirm('Are you sure you want to logout?')) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
}
}
// API Call with auth
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('admin_token');
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
};
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
});
if (response.status === 401) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/static/admin/login.html';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'API request failed');
}
return response.json();
}
// Load dashboard data
async function loadDashboard() {
try {
const data = await apiCall('/admin/dashboard');
// Update stats
document.getElementById('totalVendors').textContent = data.vendors.total_vendors || 0;
document.getElementById('activeVendors').textContent = data.vendors.active_vendors || 0;
document.getElementById('verifiedVendors').textContent = data.vendors.verified_vendors || 0;
document.getElementById('verificationRate').textContent = Math.round(data.vendors.verification_rate || 0);
document.getElementById('totalUsers').textContent = data.users.total_users || 0;
document.getElementById('activeUsers').textContent = data.users.active_users || 0;
// Display recent vendors
displayRecentVendors(data.recent_vendors || []);
// Display recent imports
displayRecentImports(data.recent_imports || []);
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
// Display recent vendors
function displayRecentVendors(vendors) {
const container = document.getElementById('recentVendorsList');
if (vendors.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<p>No vendors yet</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${vendors.map(v => `
<tr>
<td><strong>${v.vendor_code}</strong></td>
<td>${v.name}</td>
<td>${v.subdomain}</td>
<td>
${v.is_verified ? '<span class="badge badge-success">Verified</span>' : '<span class="badge badge-warning">Pending</span>'}
${v.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(v.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Display recent imports
function displayRecentImports(imports) {
const container = document.getElementById('recentImportsList');
if (imports.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No import jobs yet</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${imports.map(j => `
<tr>
<td>#${j.id}</td>
<td>${j.marketplace}</td>
<td>${j.vendor_name || '-'}</td>
<td>
${j.status === 'completed' ? '<span class="badge badge-success">Completed</span>' :
j.status === 'failed' ? '<span class="badge badge-danger">Failed</span>' :
'<span class="badge badge-warning">Processing</span>'}
</td>
<td>${j.total_processed || 0}</td>
<td>${new Date(j.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Show section
function showSection(section) {
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
event.target.classList.add('active');
// Hide all views
document.getElementById('dashboardView').style.display = 'none';
document.getElementById('vendorsView').style.display = 'none';
document.getElementById('usersView').style.display = 'none';
document.getElementById('importsView').style.display = 'none';
// Show selected view
currentSection = section;
switch(section) {
case 'dashboard':
document.getElementById('dashboardView').style.display = 'block';
loadDashboard();
break;
case 'vendors':
document.getElementById('vendorsView').style.display = 'block';
loadVendors();
break;
case 'users':
document.getElementById('usersView').style.display = 'block';
loadUsers();
break;
case 'imports':
document.getElementById('importsView').style.display = 'block';
loadImports();
break;
}
}
// Load vendors
async function loadVendors() {
try {
const data = await apiCall('/admin/vendors?limit=100');
displayVendorsList(data.vendors);
} catch (error) {
console.error('Failed to load vendors:', error);
document.getElementById('vendorsList').innerHTML = `
<div class="empty-state">
<p>Failed to load vendors: ${error.message}</p>
</div>
`;
}
}
// Display vendors list
function displayVendorsList(vendors) {
const container = document.getElementById('vendorsList');
if (vendors.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🏪</div>
<p>No vendors found</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Vendor Code</th>
<th>Name</th>
<th>Subdomain</th>
<th>Email</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${vendors.map(v => `
<tr>
<td>${v.id}</td>
<td><strong>${v.vendor_code}</strong></td>
<td>${v.name}</td>
<td>${v.subdomain}</td>
<td>${v.contact_email || '-'}</td>
<td>
${v.is_verified ? '<span class="badge badge-success">Verified</span>' : '<span class="badge badge-warning">Pending</span>'}
${v.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(v.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Load users
async function loadUsers() {
try {
const users = await apiCall('/admin/users?limit=100');
displayUsersList(users);
} catch (error) {
console.error('Failed to load users:', error);
document.getElementById('usersList').innerHTML = `
<div class="empty-state">
<p>Failed to load users: ${error.message}</p>
</div>
`;
}
}
// Display users list
function displayUsersList(users) {
const container = document.getElementById('usersList');
if (users.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">👥</div>
<p>No users found</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr>
<td>${u.id}</td>
<td><strong>${u.username}</strong></td>
<td>${u.email}</td>
<td>${u.role}</td>
<td>
${u.is_active ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-danger">Inactive</span>'}
</td>
<td>${new Date(u.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Load imports
async function loadImports() {
try {
const imports = await apiCall('/admin/marketplace-import-jobs?limit=100');
displayImportsList(imports);
} catch (error) {
console.error('Failed to load imports:', error);
document.getElementById('importsList').innerHTML = `
<div class="empty-state">
<p>Failed to load import jobs: ${error.message}</p>
</div>
`;
}
}
// Display imports list
function displayImportsList(imports) {
const container = document.getElementById('importsList');
if (imports.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No import jobs found</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>Job ID</th>
<th>Marketplace</th>
<th>Vendor</th>
<th>Status</th>
<th>Processed</th>
<th>Errors</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${imports.map(j => `
<tr>
<td>#${j.job_id}</td>
<td>${j.marketplace}</td>
<td>${j.vendor_name || '-'}</td>
<td>
${j.status === 'completed' ? '<span class="badge badge-success">Completed</span>' :
j.status === 'failed' ? '<span class="badge badge-danger">Failed</span>' :
'<span class="badge badge-warning">Processing</span>'}
</td>
<td>${j.total_processed || 0}</td>
<td>${j.error_count || 0}</td>
<td>${new Date(j.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// Initialize
window.addEventListener('DOMContentLoaded', () => {
if (checkAuth()) {
loadDashboard();
}
});
</script>
</body>
</html>

200
static/admin/login.html Normal file
View File

@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>🔐 Admin Portal</h1>
<p>Multi-Tenant Ecommerce Platform</p>
</div>
<div id="alertBox" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
<div class="error-message" id="usernameError"></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<div class="error-message" id="passwordError"></div>
</div>
<button type="submit" class="btn-login" id="loginButton">
Sign In
</button>
</form>
<div class="login-footer">
<a href="/">← Back to Platform</a>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script>
// API Client Configuration
const API_BASE_URL = '/api/v1';
// DOM Elements
const loginForm = document.getElementById('loginForm');
const loginButton = document.getElementById('loginButton');
const alertBox = document.getElementById('alertBox');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('usernameError');
const passwordError = document.getElementById('passwordError');
// Show alert message
function showAlert(message, type = 'error') {
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
if (type === 'success') {
setTimeout(() => {
alertBox.classList.remove('show');
}, 3000);
}
}
// Show field error
function showFieldError(field, message) {
const input = field === 'username' ? usernameInput : passwordInput;
const errorDiv = field === 'username' ? usernameError : passwordError;
input.classList.add('error');
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
// Clear field errors
function clearFieldErrors() {
usernameInput.classList.remove('error');
passwordInput.classList.remove('error');
usernameError.classList.remove('show');
passwordError.classList.remove('show');
alertBox.classList.remove('show');
}
// Set loading state
function setLoadingState(loading) {
loginButton.disabled = loading;
if (loading) {
loginButton.innerHTML = '<span class="loading-spinner"></span>Signing in...';
} else {
loginButton.innerHTML = 'Sign In';
}
}
// Handle login
async function handleLogin(event) {
event.preventDefault();
clearFieldErrors();
const username = usernameInput.value.trim();
const password = passwordInput.value;
// Basic validation
if (!username) {
showFieldError('username', 'Username is required');
return;
}
if (!password) {
showFieldError('password', 'Password is required');
return;
}
setLoadingState(true);
try {
const response = await fetch(`${API_BASE_URL}/admin/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Login failed');
}
// Check if user is admin
if (data.user.role !== 'admin') {
throw new Error('Access denied. Admin privileges required.');
}
// Store token
localStorage.setItem('admin_token', data.access_token);
localStorage.setItem('admin_user', JSON.stringify(data.user));
// Show success message
showAlert('Login successful! Redirecting...', 'success');
// Redirect to admin dashboard
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
} catch (error) {
console.error('Login error:', error);
showAlert(error.message || 'Login failed. Please try again.');
} finally {
setLoadingState(false);
}
}
// Event listeners
loginForm.addEventListener('submit', handleLogin);
// Clear errors on input
usernameInput.addEventListener('input', clearFieldErrors);
passwordInput.addEventListener('input', clearFieldErrors);
// Check if already logged in
window.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (token && user) {
try {
const userData = JSON.parse(user);
if (userData.role === 'admin') {
window.location.href = '/static/admin/dashboard.html';
}
} catch (e) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
}
}
});
</script>
</body>
</html>

347
static/admin/vendors.html Normal file
View File

@@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Vendor - Admin Portal</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/admin/admin.css">
</head>
<body>
<header class="header">
<h1>Create New Vendor</h1>
<a href="/static/admin/dashboard.html" class="btn-back">← Back to Dashboard</a>
</header>
<div class="container">
<div class="form-card">
<h2 class="form-title">Vendor Information</h2>
<div id="alertBox" class="alert"></div>
<form id="createVendorForm">
<!-- Vendor Code -->
<div class="form-group">
<label for="vendorCode">
Vendor Code <span class="required">*</span>
</label>
<input
type="text"
id="vendorCode"
name="vendor_code"
required
placeholder="e.g., TECHSTORE"
pattern="[A-Z0-9_-]+"
maxlength="50"
>
<div class="form-help">Uppercase letters, numbers, underscores, and hyphens only</div>
<div class="error-message" id="vendorCodeError"></div>
</div>
<!-- Vendor Name -->
<div class="form-group">
<label for="name">
Vendor Name <span class="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="e.g., Tech Store Luxembourg"
maxlength="255"
>
<div class="form-help">Display name for the vendor</div>
<div class="error-message" id="nameError"></div>
</div>
<!-- Subdomain -->
<div class="form-group">
<label for="subdomain">
Subdomain <span class="required">*</span>
</label>
<input
type="text"
id="subdomain"
name="subdomain"
required
placeholder="e.g., techstore"
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"
maxlength="100"
>
<div class="form-help">Lowercase letters, numbers, and hyphens only (e.g., techstore.platform.com)</div>
<div class="error-message" id="subdomainError"></div>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
placeholder="Brief description of the vendor's business"
></textarea>
<div class="form-help">Optional description of the vendor</div>
</div>
<!-- Owner Email -->
<div class="form-group">
<label for="ownerEmail">
Owner Email <span class="required">*</span>
</label>
<input
type="email"
id="ownerEmail"
name="owner_email"
required
placeholder="owner@example.com"
>
<div class="form-help">Email for the vendor owner (login credentials will be sent here)</div>
<div class="error-message" id="ownerEmailError"></div>
</div>
<!-- Contact Phone -->
<div class="form-group">
<label for="contactPhone">Contact Phone</label>
<input
type="tel"
id="contactPhone"
name="contact_phone"
placeholder="+352 123 456 789"
>
<div class="form-help">Optional contact phone number</div>
</div>
<!-- Website -->
<div class="form-group">
<label for="website">Website</label>
<input
type="url"
id="website"
name="website"
placeholder="https://example.com"
>
<div class="form-help">Optional website URL</div>
</div>
<!-- Business Address -->
<div class="form-group">
<label for="businessAddress">Business Address</label>
<textarea
id="businessAddress"
name="business_address"
placeholder="Street, City, Country"
></textarea>
</div>
<!-- Tax Number -->
<div class="form-group">
<label for="taxNumber">Tax Number</label>
<input
type="text"
id="taxNumber"
name="tax_number"
placeholder="LU12345678"
>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="window.history.back()">
Cancel
</button>
<button type="submit" class="btn btn-primary" id="submitButton">
Create Vendor
</button>
</div>
</form>
<!-- Success credentials display -->
<div id="credentialsDisplay" style="display: none;">
<div class="credentials-card">
<h3>✅ Vendor Created Successfully!</h3>
<div class="credential-item">
<label>Vendor Code:</label>
<span class="value" id="displayVendorCode"></span>
</div>
<div class="credential-item">
<label>Subdomain:</label>
<span class="value" id="displaySubdomain"></span>
</div>
<div class="credential-item">
<label>Owner Username:</label>
<span class="value" id="displayUsername"></span>
</div>
<div class="credential-item">
<label>Owner Email:</label>
<span class="value" id="displayEmail"></span>
</div>
<div class="credential-item">
<label>Temporary Password:</label>
<span class="value" id="displayPassword"></span>
</div>
<div class="credential-item">
<label>Login URL:</label>
<span class="value" id="displayLoginUrl"></span>
</div>
<p class="warning-text">
⚠️ Important: Save these credentials! The password will not be shown again.
</p>
<div class="form-actions" style="margin-top: 20px;">
<button class="btn btn-primary" onclick="window.location.href='/static/admin/dashboard.html'">
Go to Dashboard
</button>
<button class="btn btn-secondary" onclick="location.reload()">
Create Another Vendor
</button>
</div>
</div>
</div>
</div>
</div>
<script>
const API_BASE_URL = '/api/v1';
// Check authentication
function checkAuth() {
const token = localStorage.getItem('admin_token');
const user = localStorage.getItem('admin_user');
if (!token || !user) {
window.location.href = '/static/admin/login.html';
return false;
}
try {
const userData = JSON.parse(user);
if (userData.role !== 'admin') {
alert('Access denied. Admin privileges required.');
window.location.href = '/static/admin/login.html';
return false;
}
return true;
} catch (e) {
window.location.href = '/static/admin/login.html';
return false;
}
}
// Show alert
function showAlert(message, type = 'error') {
const alertBox = document.getElementById('alertBox');
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Clear all errors
function clearErrors() {
document.querySelectorAll('.error-message').forEach(el => {
el.classList.remove('show');
});
document.querySelectorAll('input, textarea').forEach(el => {
el.classList.remove('error');
});
document.getElementById('alertBox').classList.remove('show');
}
// Show field error
function showFieldError(fieldName, message) {
const input = document.querySelector(`[name="${fieldName}"]`);
const errorDiv = document.getElementById(`${fieldName.replace('_', '')}Error`);
if (input) input.classList.add('error');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
}
// Auto-format inputs
document.getElementById('vendorCode').addEventListener('input', function(e) {
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '');
});
document.getElementById('subdomain').addEventListener('input', function(e) {
this.value = this.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
});
// Handle form submission
document.getElementById('createVendorForm').addEventListener('submit', async function(e) {
e.preventDefault();
clearErrors();
const submitButton = document.getElementById('submitButton');
submitButton.disabled = true;
submitButton.innerHTML = '<span class="loading-spinner"></span>Creating vendor...';
// Collect form data
const formData = {
vendor_code: document.getElementById('vendorCode').value.trim(),
name: document.getElementById('name').value.trim(),
subdomain: document.getElementById('subdomain').value.trim(),
description: document.getElementById('description').value.trim() || null,
owner_email: document.getElementById('ownerEmail').value.trim(),
contact_phone: document.getElementById('contactPhone').value.trim() || null,
website: document.getElementById('website').value.trim() || null,
business_address: document.getElementById('businessAddress').value.trim() || null,
tax_number: document.getElementById('taxNumber').value.trim() || null,
};
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_BASE_URL}/admin/vendors`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Failed to create vendor');
}
// Display success and credentials
document.getElementById('createVendorForm').style.display = 'none';
document.getElementById('credentialsDisplay').style.display = 'block';
document.getElementById('displayVendorCode').textContent = data.vendor_code;
document.getElementById('displaySubdomain').textContent = data.subdomain;
document.getElementById('displayUsername').textContent = data.owner_username;
document.getElementById('displayEmail').textContent = data.owner_email;
document.getElementById('displayPassword').textContent = data.temporary_password;
document.getElementById('displayLoginUrl').textContent = data.login_url || `${data.subdomain}.platform.com/vendor/login`;
showAlert('Vendor created successfully!', 'success');
} catch (error) {
console.error('Error creating vendor:', error);
showAlert(error.message || 'Failed to create vendor');
submitButton.disabled = false;
submitButton.innerHTML = 'Create Vendor';
}
});
// Initialize
window.addEventListener('DOMContentLoaded', () => {
checkAuth();
});
</script>
</body>
</html>

556
static/css/admin/admin.css Normal file
View File

@@ -0,0 +1,556 @@
/* static/css/admin/admin.css */
/* Admin-specific styles */
/* Admin Header */
.admin-header {
background: white;
border-bottom: 1px solid var(--border-color);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
}
.admin-header h1 {
font-size: var(--font-2xl);
color: var(--text-primary);
font-weight: 600;
}
.header-left {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.header-right {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.user-info {
font-size: var(--font-base);
color: var(--text-secondary);
}
.user-info strong {
color: var(--text-primary);
font-weight: 600;
}
.btn-logout {
padding: 8px 16px;
background: var(--danger-color);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-base);
font-weight: 500;
transition: all var(--transition-base);
}
.btn-logout:hover {
background: #c0392b;
transform: translateY(-1px);
}
/* Admin Container */
.admin-container {
display: flex;
min-height: calc(100vh - 64px);
}
/* Admin Sidebar */
.admin-sidebar {
width: 250px;
background: white;
border-right: 1px solid var(--border-color);
padding: var(--spacing-lg) 0;
overflow-y: auto;
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-item {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
padding: 12px 24px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-base);
font-weight: 500;
transition: all var(--transition-base);
border-right: 3px solid transparent;
}
.nav-link:hover {
background: var(--gray-50);
color: var(--primary-color);
}
.nav-link.active {
background: var(--primary-color);
color: white;
border-right-color: var(--primary-dark);
}
.nav-icon {
margin-right: var(--spacing-sm);
font-size: var(--font-lg);
}
/* Admin Content */
.admin-content {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
background: var(--bg-secondary);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.stat-icon {
font-size: 32px;
opacity: 0.8;
}
.stat-title {
font-size: var(--font-base);
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
line-height: 1;
}
.stat-subtitle {
font-size: var(--font-sm);
color: var(--text-muted);
}
.stat-change {
display: inline-flex;
align-items: center;
font-size: var(--font-sm);
font-weight: 600;
margin-left: var(--spacing-sm);
}
.stat-change.positive {
color: var(--success-color);
}
.stat-change.negative {
color: var(--danger-color);
}
/* Content Sections */
.content-section {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
margin-bottom: var(--spacing-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--gray-100);
}
.section-title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--text-primary);
}
.section-actions {
display: flex;
gap: var(--spacing-sm);
}
/* Data Tables */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background: var(--gray-50);
}
.data-table th {
text-align: left;
padding: 12px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-color);
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
font-size: var(--font-base);
}
.data-table tbody tr {
transition: background var(--transition-fast);
}
.data-table tbody tr:hover {
background: var(--gray-50);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.table-actions {
display: flex;
gap: var(--spacing-sm);
}
.table-actions .btn {
padding: 6px 12px;
font-size: var(--font-sm);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-2xl) var(--spacing-lg);
color: var(--text-muted);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state h3 {
font-size: var(--font-xl);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.empty-state p {
font-size: var(--font-base);
color: var(--text-muted);
}
/* Loading State */
.loading {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-muted);
}
.loading-text {
margin-top: var(--spacing-md);
font-size: var(--font-base);
}
/* Search and Filters */
.filter-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
}
.filter-group {
flex: 1;
min-width: 200px;
}
.search-box {
position: relative;
flex: 2;
min-width: 300px;
}
.search-input {
width: 100%;
padding-left: 40px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
/* Modal/Dialog */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-overlay);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: var(--font-xl);
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: var(--font-2xl);
cursor: pointer;
color: var(--text-muted);
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
transition: all var(--transition-base);
}
.modal-close:hover {
background: var(--gray-100);
color: var(--text-primary);
}
.modal-body {
padding: var(--spacing-lg);
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
.pagination-btn {
padding: 8px 12px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
}
.pagination-btn:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--primary-color);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.pagination-info {
font-size: var(--font-sm);
color: var(--text-muted);
}
/* Responsive Design */
@media (max-width: 1024px) {
.admin-sidebar {
width: 200px;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (max-width: 768px) {
.admin-container {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.admin-content {
padding: var(--spacing-md);
}
.stats-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.filter-bar {
flex-direction: column;
}
.filter-group,
.search-box {
width: 100%;
}
.data-table {
font-size: var(--font-sm);
}
.data-table th,
.data-table td {
padding: 8px;
}
}
@media (max-width: 480px) {
.admin-header {
padding: 12px 16px;
}
.admin-header h1 {
font-size: var(--font-lg);
}
.stat-value {
font-size: var(--font-3xl);
}
.content-section {
padding: var(--spacing-md);
}
/* Make table scrollable on small screens */
.table-wrapper {
overflow-x: auto;
}
.data-table {
min-width: 600px;
}
}
/* Print Styles */
@media print {
.admin-sidebar,
.admin-header .header-right,
.section-actions,
.table-actions {
display: none;
}
.admin-content {
padding: 0;
}
.content-section {
box-shadow: none;
border: 1px solid var(--border-color);
}

622
static/css/shared/auth.css Normal file
View File

@@ -0,0 +1,622 @@
/* static/css/shared/auth.css */
/* Authentication pages (login, register) styles */
/* Auth Page Layout */
.auth-page {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
padding: var(--spacing-lg);
}
.auth-page::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(255,255,255,0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255,255,255,0.1) 0%, transparent 50%);
pointer-events: none;
}
/* Login Container */
.login-container,
.auth-container {
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 100%;
max-width: 420px;
padding: var(--spacing-xl);
position: relative;
z-index: 1;
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Login Header */
.login-header,
.auth-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-logo {
width: 80px;
height: 80px;
margin: 0 auto var(--spacing-md);
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: white;
box-shadow: var(--shadow-md);
}
.login-header h1,
.auth-header h1 {
font-size: var(--font-3xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
font-weight: 700;
}
.login-header p,
.auth-header p {
color: var(--text-secondary);
font-size: var(--font-base);
}
/* Vendor Info Display */
.vendor-info {
background: var(--gray-50);
padding: 12px 16px;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-lg);
text-align: center;
border: 1px solid var(--border-color);
}
.vendor-info strong {
color: var(--primary-color);
font-size: var(--font-lg);
font-weight: 600;
}
/* No Vendor Message */
.no-vendor-message {
text-align: center;
padding: var(--spacing-2xl) var(--spacing-lg);
color: var(--text-secondary);
}
.no-vendor-message h2 {
font-size: var(--font-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.no-vendor-message p {
margin-bottom: var(--spacing-lg);
color: var(--text-muted);
}
/* Auth Form */
.auth-form,
.login-form {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
font-size: var(--font-base);
}
.form-group input {
width: 100%;
padding: 14px 16px;
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
font-size: var(--font-md);
transition: all var(--transition-base);
background: white;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input.error {
border-color: var(--danger-color);
}
.form-group input::placeholder {
color: var(--text-muted);
}
/* Password Toggle */
.password-group {
position: relative;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: var(--spacing-sm);
font-size: var(--font-lg);
}
.password-toggle:hover {
color: var(--text-primary);
}
/* Remember Me & Forgot Password */
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
font-size: var(--font-sm);
}
.remember-me {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.remember-me input[type="checkbox"] {
width: auto;
margin: 0;
}
.forgot-password {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.forgot-password:hover {
text-decoration: underline;
}
/* Submit Button */
.btn-login,
.btn-auth {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-lg);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-login:hover:not(:disabled),
.btn-auth:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-login:active:not(:disabled),
.btn-auth:active:not(:disabled) {
transform: translateY(0);
}
.btn-login:disabled,
.btn-auth:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Social Login Buttons */
.social-login {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
.social-login-text {
text-align: center;
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--spacing-md);
}
.social-buttons {
display: flex;
gap: var(--spacing-md);
}
.btn-social {
flex: 1;
padding: 12px;
border: 2px solid var(--border-color);
background: white;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--font-base);
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
.btn-social:hover {
border-color: var(--primary-color);
background: var(--gray-50);
}
/* Login Footer */
.login-footer,
.auth-footer {
text-align: center;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
.login-footer a,
.auth-footer a {
color: var(--primary-color);
text-decoration: none;
font-size: var(--font-base);
font-weight: 500;
}
.login-footer a:hover,
.auth-footer a:hover {
text-decoration: underline;
}
.auth-footer-text {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: var(--spacing-sm);
}
/* Back Button */
.btn-back {
display: inline-block;
padding: 10px 20px;
background: var(--secondary-color);
color: white;
text-decoration: none;
border-radius: var(--radius-md);
font-weight: 500;
transition: all var(--transition-base);
}
.btn-back:hover {
background: #5a6268;
transform: translateY(-1px);
}
/* Error and Success Messages */
.alert {
padding: 12px 16px;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-lg);
font-size: var(--font-base);
display: none;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Field Errors */
.error-message {
color: var(--danger-color);
font-size: var(--font-sm);
margin-top: var(--spacing-xs);
display: none;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.error-message.show {
display: block;
}
/* Loading State */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
border-top-color: transparent;
animation: spinner 0.6s linear infinite;
margin-right: var(--spacing-sm);
vertical-align: middle;
}
@keyframes spinner {
to { transform: rotate(360deg); }
}
/* Divider */
.divider {
display: flex;
align-items: center;
text-align: center;
margin: var(--spacing-lg) 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border-color);
}
.divider span {
padding: 0 var(--spacing-md);
color: var(--text-muted);
font-size: var(--font-sm);
}
/* Credentials Display (for vendor creation success) */
.credentials-card {
background: #fff3cd;
border: 2px solid var(--warning-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.credentials-card h3 {
margin-bottom: var(--spacing-md);
color: #856404;
font-size: var(--font-xl);
}
.credential-item {
background: white;
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border-color);
}
.credential-item label {
font-weight: 600;
color: var(--text-primary);
font-size: var(--font-base);
}
.credential-item .value {
font-family: 'Courier New', monospace;
background: var(--gray-50);
padding: 6px 12px;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: var(--font-base);
word-break: break-all;
}
.warning-text {
color: #856404;
font-size: var(--font-sm);
margin-top: var(--spacing-md);
font-weight: 600;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.warning-text::before {
content: '⚠️';
font-size: var(--font-lg);
}
/* Copy Button */
.btn-copy {
background: none;
border: 1px solid var(--border-color);
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-size: var(--font-xs);
transition: all var(--transition-base);
}
.btn-copy:hover {
background: var(--gray-50);
border-color: var(--primary-color);
color: var(--primary-color);
}
/* Responsive Design */
@media (max-width: 480px) {
.login-container,
.auth-container {
padding: var(--spacing-lg);
max-width: 100%;
}
.auth-logo {
width: 60px;
height: 60px;
font-size: 32px;
}
.login-header h1,
.auth-header h1 {
font-size: var(--font-2xl);
}
.btn-login,
.btn-auth {
padding: 12px 20px;
font-size: var(--font-base);
}
.social-buttons {
flex-direction: column;
}
.credential-item {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
}
.credential-item .value {
width: 100%;
text-align: left;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.auth-page {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
}
.login-container,
.auth-container {
background: #2d3748;
color: #e2e8f0;
}
.login-header h1,
.auth-header h1,
.form-group label {
color: #e2e8f0;
}
.login-header p,
.auth-header p {
color: #a0aec0;
}
.form-group input {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
.form-group input::placeholder {
color: #718096;
}
.vendor-info {
background: #1a202c;
border-color: #4a5568;
}
.credential-item {
background: #1a202c;
border-color: #4a5568;
}
.credential-item .value {
background: #2d3748;
}
}
/* Print Styles */
@media print {
.auth-page::before {
display: none;
}
.login-container,
.auth-container {
box-shadow: none;
border: 1px solid var(--border-color);
}
.btn-login,
.btn-auth,
.social-login,
.login-footer,
.auth-footer {
display: none;
}
}

510
static/css/shared/base.css Normal file
View File

@@ -0,0 +1,510 @@
/* static/css/shared/base.css */
/* Base styles shared across all pages */
:root {
/* Color Palette */
--primary-color: #667eea;
--primary-dark: #764ba2;
--secondary-color: #6c757d;
--success-color: #28a745;
--danger-color: #e74c3c;
--warning-color: #ffc107;
--info-color: #17a2b8;
/* Grays */
--gray-50: #f9fafb;
--gray-100: #f5f7fa;
--gray-200: #e1e8ed;
--gray-300: #d1d9e0;
--gray-400: #b0bac5;
--gray-500: #8796a5;
--gray-600: #687785;
--gray-700: #4a5568;
--gray-800: #2d3748;
--gray-900: #1a202c;
/* Text Colors */
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
/* Background Colors */
--bg-primary: #ffffff;
--bg-secondary: #f5f7fa;
--bg-overlay: rgba(0, 0, 0, 0.5);
/* Border Colors */
--border-color: #e1e8ed;
--border-focus: #667eea;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Font Sizes */
--font-xs: 12px;
--font-sm: 13px;
--font-base: 14px;
--font-md: 15px;
--font-lg: 16px;
--font-xl: 18px;
--font-2xl: 20px;
--font-3xl: 24px;
--font-4xl: 32px;
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: var(--font-base);
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-weight: 600;
line-height: 1.2;
color: var(--text-primary);
}
h1 { font-size: var(--font-4xl); }
h2 { font-size: var(--font-3xl); }
h3 { font-size: var(--font-2xl); }
h4 { font-size: var(--font-xl); }
h5 { font-size: var(--font-lg); }
h6 { font-size: var(--font-base); }
p {
margin: 0 0 1rem;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-base);
}
a:hover {
color: var(--primary-dark);
}
/* Buttons */
.btn {
display: inline-block;
padding: 10px 20px;
font-size: var(--font-base);
font-weight: 600;
text-align: center;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
text-decoration: none;
line-height: 1.5;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-warning {
background: var(--warning-color);
color: var(--gray-900);
}
.btn-outline {
background: transparent;
border: 2px solid var(--primary-color);
color: var(--primary-color);
}
.btn-outline:hover:not(:disabled) {
background: var(--primary-color);
color: white;
}
.btn-sm {
padding: 6px 12px;
font-size: var(--font-sm);
}
.btn-lg {
padding: 14px 28px;
font-size: var(--font-lg);
}
/* Form Elements */
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
font-weight: 600;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
font-size: var(--font-base);
}
.form-control,
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 12px 16px;
font-size: var(--font-base);
line-height: 1.5;
color: var(--text-primary);
background: white;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color var(--transition-base);
}
.form-control:focus,
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--border-focus);
}
.form-control.error,
.form-input.error {
border-color: var(--danger-color);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.form-help {
display: block;
margin-top: var(--spacing-xs);
font-size: var(--font-sm);
color: var(--text-muted);
}
.error-message {
display: none;
margin-top: var(--spacing-xs);
font-size: var(--font-sm);
color: var(--danger-color);
}
.error-message.show {
display: block;
}
/* Cards */
.card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.card-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.card-body {
padding: var(--spacing-lg);
}
.card-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
background: var(--gray-50);
}
/* Badges */
.badge {
display: inline-block;
padding: 4px 10px;
font-size: var(--font-xs);
font-weight: 600;
line-height: 1;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-primary {
background: rgba(102, 126, 234, 0.1);
color: var(--primary-color);
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.badge-secondary {
background: var(--gray-200);
color: var(--gray-700);
}
/* Alerts */
.alert {
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
font-size: var(--font-base);
border: 1px solid transparent;
}
.alert-success {
background: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
.alert-danger,
.alert-error {
background: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-warning {
background: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border-color: #bee5eb;
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 12px;
background: var(--gray-100);
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-color);
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
font-size: var(--font-base);
}
.table tr:hover {
background: var(--gray-50);
}
.table-striped tbody tr:nth-child(odd) {
background: var(--gray-50);
}
/* Utilities */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-muted { color: var(--text-muted); }
.text-primary { color: var(--primary-color); }
.text-success { color: var(--success-color); }
.text-danger { color: var(--danger-color); }
.text-warning { color: var(--warning-color); }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.font-normal { font-weight: 400; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--spacing-sm); }
.mt-2 { margin-top: var(--spacing-md); }
.mt-3 { margin-top: var(--spacing-lg); }
.mt-4 { margin-top: var(--spacing-xl); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--spacing-sm); }
.mb-2 { margin-bottom: var(--spacing-md); }
.mb-3 { margin-bottom: var(--spacing-lg); }
.mb-4 { margin-bottom: var(--spacing-xl); }
.p-0 { padding: 0; }
.p-1 { padding: var(--spacing-sm); }
.p-2 { padding: var(--spacing-md); }
.p-3 { padding: var(--spacing-lg); }
.p-4 { padding: var(--spacing-xl); }
.d-none { display: none; }
.d-block { display: block; }
.d-inline { display: inline; }
.d-inline-block { display: inline-block; }
.d-flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-row { flex-direction: row; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.align-start { align-items: flex-start; }
.align-end { align-items: flex-end; }
.align-center { align-items: center; }
.gap-1 { gap: var(--spacing-sm); }
.gap-2 { gap: var(--spacing-md); }
.gap-3 { gap: var(--spacing-lg); }
/* Loading Spinner */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
border-top-color: transparent;
animation: spinner 0.6s linear infinite;
vertical-align: middle;
}
@keyframes spinner {
to { transform: rotate(360deg); }
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-overlay);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-spinner-lg {
width: 48px;
height: 48px;
border-width: 4px;
}
/* Responsive */
@media (max-width: 768px) {
:root {
--font-base: 14px;
--font-lg: 15px;
--font-xl: 16px;
--font-2xl: 18px;
--font-3xl: 20px;
--font-4xl: 24px;
}
.btn {
padding: 8px 16px;
}
.card-body {
padding: var(--spacing-md);
}
}
@media (max-width: 480px) {
.btn {
width: 100%;
}
}

601
static/css/vendor/vendor.css vendored Normal file
View File

@@ -0,0 +1,601 @@
/* static/css/vendor/vendor.css */
/* Vendor-specific styles */
/* Vendor Header */
.vendor-header {
background: white;
border-bottom: 1px solid var(--border-color);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
}
.vendor-header h1 {
font-size: var(--font-2xl);
color: var(--text-primary);
font-weight: 600;
}
.vendor-logo {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.vendor-logo-img {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
object-fit: cover;
}
.vendor-name {
font-size: var(--font-xl);
font-weight: 700;
color: var(--primary-color);
}
/* Vendor Container */
.vendor-container {
display: flex;
min-height: calc(100vh - 64px);
}
/* Vendor Sidebar */
.vendor-sidebar {
width: 260px;
background: white;
border-right: 1px solid var(--border-color);
padding: var(--spacing-lg) 0;
overflow-y: auto;
}
.vendor-sidebar-header {
padding: 0 var(--spacing-lg) var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--spacing-md);
}
.vendor-status {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
/* Vendor Content */
.vendor-content {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
background: var(--bg-secondary);
}
/* Vendor Dashboard Widgets */
.dashboard-widgets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.widget {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
}
.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.widget-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.widget-icon {
font-size: var(--font-2xl);
color: var(--primary-color);
}
.widget-content {
margin-bottom: var(--spacing-md);
}
.widget-footer {
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color);
}
.widget-stat {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: var(--spacing-sm);
}
.widget-label {
font-size: var(--font-sm);
color: var(--text-muted);
}
/* Welcome Card */
.welcome-card {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow-lg);
margin-bottom: var(--spacing-xl);
text-align: center;
}
.welcome-icon {
font-size: 64px;
margin-bottom: var(--spacing-lg);
opacity: 0.9;
}
.welcome-card h2 {
color: white;
font-size: var(--font-3xl);
margin-bottom: var(--spacing-md);
}
.welcome-card p {
color: rgba(255, 255, 255, 0.9);
font-size: var(--font-lg);
margin-bottom: var(--spacing-sm);
}
/* Vendor Info Card */
.vendor-info-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.info-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-base);
}
.info-value {
color: var(--text-primary);
font-size: var(--font-base);
text-align: right;
}
/* Coming Soon Badge */
.coming-soon {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
color: white;
padding: 12px 24px;
border-radius: var(--radius-lg);
display: inline-block;
font-weight: 600;
margin-top: var(--spacing-lg);
box-shadow: var(--shadow-md);
}
/* Product Grid */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.product-card {
background: white;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
cursor: pointer;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
background: var(--gray-100);
}
.product-info {
padding: var(--spacing-md);
}
.product-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: var(--font-xl);
font-weight: 700;
color: var(--primary-color);
margin-bottom: var(--spacing-sm);
}
.product-description {
font-size: var(--font-sm);
color: var(--text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-actions {
padding: var(--spacing-md);
border-top: 1px solid var(--border-color);
display: flex;
gap: var(--spacing-sm);
}
/* Order List */
.order-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.order-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
}
.order-card:hover {
box-shadow: var(--shadow-lg);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.order-number {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.order-date {
font-size: var(--font-sm);
color: var(--text-muted);
}
.order-body {
margin-bottom: var(--spacing-md);
}
.order-items {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-total {
font-size: var(--font-xl);
font-weight: 700;
color: var(--text-primary);
}
/* Tabs */
.tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--border-color);
}
.tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: var(--font-base);
font-weight: 500;
color: var(--text-secondary);
transition: all var(--transition-base);
margin-bottom: -2px;
}
.tab:hover {
color: var(--primary-color);
}
.tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
/* File Upload */
.upload-area {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
text-align: center;
cursor: pointer;
transition: all var(--transition-base);
}
.upload-area:hover,
.upload-area.dragover {
border-color: var(--primary-color);
background: rgba(102, 126, 234, 0.05);
}
.upload-icon {
font-size: 48px;
color: var(--text-muted);
margin-bottom: var(--spacing-md);
}
.upload-text {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.upload-hint {
font-size: var(--font-sm);
color: var(--text-muted);
}
.file-list {
margin-top: var(--spacing-md);
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-sm);
}
.file-name {
font-size: var(--font-base);
color: var(--text-primary);
}
.file-size {
font-size: var(--font-sm);
color: var(--text-muted);
}
.file-remove {
background: none;
border: none;
color: var(--danger-color);
cursor: pointer;
font-size: var(--font-lg);
padding: var(--spacing-sm);
}
/* Progress Bar */
.progress {
width: 100%;
height: 8px;
background: var(--gray-200);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
transition: width var(--transition-base);
}
.progress-text {
text-align: center;
font-size: var(--font-sm);
color: var(--text-muted);
margin-top: var(--spacing-sm);
}
/* Settings Form */
.settings-section {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
margin-bottom: var(--spacing-lg);
}
.settings-header {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--border-color);
}
.settings-title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.settings-description {
font-size: var(--font-sm);
color: var(--text-muted);
}
.settings-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
/* Responsive Design */
@media (max-width: 1024px) {
.vendor-sidebar {
width: 220px;
}
.dashboard-widgets {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
@media (max-width: 768px) {
.vendor-container {
flex-direction: column;
}
.vendor-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-md) 0;
}
.vendor-content {
padding: var(--spacing-md);
}
.dashboard-widgets,
.product-grid {
grid-template-columns: 1fr;
}
.welcome-card {
padding: var(--spacing-lg);
}
.welcome-icon {
font-size: 48px;
}
.welcome-card h2 {
font-size: var(--font-2xl);
}
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
white-space: nowrap;
}
.settings-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.vendor-header {
padding: 12px 16px;
}
.vendor-header h1 {
font-size: var(--font-lg);
}
.vendor-name {
font-size: var(--font-lg);
}
.widget-stat {
font-size: var(--font-3xl);
}
.settings-section {
padding: var(--spacing-md);
}
.info-item {
flex-direction: column;
gap: var(--spacing-xs);
}
.info-value {
text-align: left;
}
}
/* Print Styles */
@media print {
.vendor-sidebar,
.vendor-header .header-right,
.product-actions,
.order-footer .btn {
display: none;
}
.vendor-content {
padding: 0;
}
.settings-section,
.order-card,
.product-card {
box-shadow: none;
border: 1px solid var(--border-color);
break-inside: avoid;
}
}

View File

@@ -0,0 +1,340 @@
// static/js/shared/api-client.js
/**
* API Client for Multi-Tenant Ecommerce Platform
*
* Provides utilities for:
* - Making authenticated API calls
* - Token management
* - Error handling
* - Request/response interceptors
*/
const API_BASE_URL = '/api/v1';
/**
* API Client Class
*/
class APIClient {
constructor(baseURL = API_BASE_URL) {
this.baseURL = baseURL;
}
/**
* Get stored authentication token
*/
getToken() {
return localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
}
/**
* Get default headers with authentication
*/
getHeaders(additionalHeaders = {}) {
const headers = {
'Content-Type': 'application/json',
...additionalHeaders
};
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Make API request
*/
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
...options,
headers: this.getHeaders(options.headers)
};
try {
const response = await fetch(url, config);
// Handle 401 Unauthorized
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('Unauthorized - please login again');
}
// Parse response
const data = await response.json();
// Handle non-OK responses
if (!response.ok) {
throw new Error(data.detail || data.message || 'Request failed');
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* GET request
*/
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, {
method: 'GET'
});
}
/**
* POST request
*/
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
/**
* PUT request
*/
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
/**
* DELETE request
*/
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE'
});
}
/**
* Handle unauthorized access
*/
handleUnauthorized() {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
// Redirect to appropriate login page
if (window.location.pathname.includes('/admin/')) {
window.location.href = '/static/admin/login.html';
} else if (window.location.pathname.includes('/vendor/')) {
window.location.href = '/static/vendor/login.html';
}
}
}
// Create global API client instance
const apiClient = new APIClient();
/**
* Authentication helpers
*/
const Auth = {
/**
* Check if user is authenticated
*/
isAuthenticated() {
const token = localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
return !!token;
},
/**
* Get current user
*/
getCurrentUser() {
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('vendor_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
},
/**
* Check if user is admin
*/
isAdmin() {
const user = this.getCurrentUser();
return user && user.role === 'admin';
},
/**
* Login
*/
async login(username, password) {
const response = await apiClient.post('/auth/login', {
username,
password
});
// Store token and user
if (response.user.role === 'admin') {
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
} else {
localStorage.setItem('vendor_token', response.access_token);
localStorage.setItem('vendor_user', JSON.stringify(response.user));
}
return response;
},
/**
* Logout
*/
logout() {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user');
}
};
/**
* Utility functions
*/
const Utils = {
/**
* Format date
*/
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
},
/**
* Format datetime
*/
formatDateTime(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
},
/**
* Format currency
*/
formatCurrency(amount, currency = 'EUR') {
if (amount === null || amount === undefined) return '-';
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: currency
}).format(amount);
},
/**
* Debounce function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
/**
* Show toast notification
*/
showToast(message, type = 'info', duration = 3000) {
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// Style
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'};
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
animation: slideIn 0.3s ease;
max-width: 400px;
`;
// Add to page
document.body.appendChild(toast);
// Remove after duration
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
},
/**
* Confirm dialog
*/
async confirm(message, title = 'Confirm') {
return window.confirm(`${title}\n\n${message}`);
}
};
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { APIClient, apiClient, Auth, Utils };
}

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="auth-page">
<div class="login-container"
x-data="customerLogin()"
x-init="checkRegistrationSuccess()"
data-vendor-id="{{ vendor.id }}"
data-vendor-name="{{ vendor.name }}"
>
<!-- Header -->
<div class="login-header">
{% if vendor.logo_url %}
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="auth-logo">
{% else %}
<div class="auth-logo">🛒</div>
{% endif %}
<h1>Welcome Back</h1>
<p>Sign in to {{ vendor.name }}</p>
</div>
<!-- Alert Box -->
<div x-show="alert.show"
x-transition
:class="'alert alert-' + alert.type"
x-text="alert.message"
></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<!-- Email -->
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
x-model="credentials.email"
required
placeholder="your@email.com"
:class="{ 'error': errors.email }"
@input="clearAllErrors()"
>
<div x-show="errors.email"
x-text="errors.email"
class="error-message show"
></div>
</div>
<!-- Password -->
<div class="form-group">
<label for="password">Password</label>
<div class="password-group">
<input
:type="showPassword ? 'text' : 'password'"
id="password"
x-model="credentials.password"
required
placeholder="Enter your password"
:class="{ 'error': errors.password }"
@input="clearAllErrors()"
>
<button
type="button"
class="password-toggle"
@click="showPassword = !showPassword"
>
<span x-text="showPassword ? '👁️' : '👁️‍🗨️'"></span>
</button>
</div>
<div x-show="errors.password"
x-text="errors.password"
class="error-message show"
></div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="form-options">
<div class="remember-me">
<input
type="checkbox"
id="rememberMe"
x-model="rememberMe"
>
<label for="rememberMe">Remember me</label>
</div>
<a href="/shop/account/forgot-password" class="forgot-password">
Forgot password?
</a>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn-login"
:disabled="loading"
>
<span x-show="loading" class="loading-spinner"></span>
<span x-text="loading ? 'Signing in...' : 'Sign In'"></span>
</button>
</form>
<!-- Register Link -->
<div class="login-footer">
<div class="auth-footer-text">Don't have an account?</div>
<a href="/shop/account/register">Create an account</a>
</div>
<!-- Back to Shop -->
<div class="login-footer" style="border-top: none; padding-top: 0;">
<a href="/shop">← Continue shopping</a>
</div>
</div>
<script>
function customerLogin() {
return {
// Data
credentials: {
email: '',
password: ''
},
rememberMe: false,
showPassword: false,
loading: false,
errors: {},
alert: {
show: false,
type: 'error',
message: ''
},
// Get vendor data
get vendorId() {
return this.$el.dataset.vendorId;
},
get vendorName() {
return this.$el.dataset.vendorName;
},
// Check if redirected after registration
checkRegistrationSuccess() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('registered') === 'true') {
this.showAlert(
'Account created successfully! Please sign in.',
'success'
);
}
},
// Clear errors
clearAllErrors() {
this.errors = {};
this.alert.show = false;
},
// Show alert
showAlert(message, type = 'error') {
this.alert = {
show: true,
type: type,
message: message
};
window.scrollTo({ top: 0, behavior: 'smooth' });
},
// Handle login
async handleLogin() {
this.clearAllErrors();
// Basic validation
if (!this.credentials.email) {
this.errors.email = 'Email is required';
return;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
return;
}
this.loading = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/customers/login`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: this.credentials.email, // API expects username
password: this.credentials.password
})
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Login failed');
}
// Store token and user data
localStorage.setItem('customer_token', data.access_token);
localStorage.setItem('customer_user', JSON.stringify(data.user));
// Store vendor context
localStorage.setItem('customer_vendor_id', this.vendorId);
this.showAlert('Login successful! Redirecting...', 'success');
// Redirect to account page or cart
setTimeout(() => {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/shop/account';
window.location.href = returnUrl;
}, 1000);
} catch (error) {
console.error('Login error:', error);
this.showAlert(error.message || 'Invalid email or password');
} finally {
this.loading = false;
}
}
}
}
</script>
</body>
</html></title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Account - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="auth-page">
<div class="login-container"
x-data="customerRegistration()"
data-vendor-id="{{ vendor.id }}"
data-vendor-name="{{ vendor.name }}"
>
<!-- Header -->
<div class="login-header">
{% if vendor.logo_url %}
<img src="{{ vendor.logo_url }}" alt="{{ vendor.name }}" class="auth-logo">
{% else %}
<div class="auth-logo">🛒</div>
{% endif %}
<h1>Create Account</h1>
<p>Join {{ vendor.name }}</p>
</div>
<!-- Alert Box -->
<div x-show="alert.show"
x-transition
:class="'alert alert-' + alert.type"
x-text="alert.message"
></div>
<!-- Registration Form -->
<form @submit.prevent="handleRegister">
<!-- First Name -->
<div class="form-group">
<label for="firstName">First Name <span class="required">*</span></label>
<input
type="text"
id="firstName"
x-model="formData.first_name"
required
placeholder="Enter your first name"
:class="{ 'error': errors.first_name }"
@input="clearError('first_name')"
>
<div x-show="errors.first_name"
x-text="errors.first_name"
class="error-message show"
></div>
</div>
<!-- Last Name -->
<div class="form-group">
<label for="lastName">Last Name <span class="required">*</span></label>
<input
type="text"
id="lastName"
x-model="formData.last_name"
required
placeholder="Enter your last name"
:class="{ 'error': errors.last_name }"
@input="clearError('last_name')"
>
<div x-show="errors.last_name"
x-text="errors.last_name"
class="error-message show"
></div>
</div>
<!-- Email -->
<div class="form-group">
<label for="email">Email Address <span class="required">*</span></label>
<input
type="email"
id="email"
x-model="formData.email"
required
placeholder="your@email.com"
:class="{ 'error': errors.email }"
@input="clearError('email')"
>
<div x-show="errors.email"
x-text="errors.email"
class="error-message show"
></div>
</div>
<!-- Phone (Optional) -->
<div class="form-group">
<label for="phone">Phone Number</label>
<input
type="tel"
id="phone"
x-model="formData.phone"
placeholder="+352 123 456 789"
>
</div>
<!-- Password -->
<div class="form-group">
<label for="password">Password <span class="required">*</span></label>
<div class="password-group">
<input
:type="showPassword ? 'text' : 'password'"
id="password"
x-model="formData.password"
required
placeholder="At least 8 characters"
:class="{ 'error': errors.password }"
@input="clearError('password')"
>
<button
type="button"
class="password-toggle"
@click="showPassword = !showPassword"
>
<span x-text="showPassword ? '👁️' : '👁️‍🗨️'"></span>
</button>
</div>
<div class="form-help">
Must contain at least 8 characters, one letter, and one number
</div>
<div x-show="errors.password"
x-text="errors.password"
class="error-message show"
></div>
</div>
<!-- Confirm Password -->
<div class="form-group">
<label for="confirmPassword">Confirm Password <span class="required">*</span></label>
<input
:type="showPassword ? 'text' : 'password'"
id="confirmPassword"
x-model="confirmPassword"
required
placeholder="Re-enter your password"
:class="{ 'error': errors.confirmPassword }"
@input="clearError('confirmPassword')"
>
<div x-show="errors.confirmPassword"
x-text="errors.confirmPassword"
class="error-message show"
></div>
</div>
<!-- Marketing Consent -->
<div class="form-group">
<div class="remember-me">
<input
type="checkbox"
id="marketingConsent"
x-model="formData.marketing_consent"
>
<label for="marketingConsent" style="font-weight: normal;">
I'd like to receive news and special offers
</label>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn-login"
:disabled="loading"
>
<span x-show="loading" class="loading-spinner"></span>
<span x-text="loading ? 'Creating Account...' : 'Create Account'"></span>
</button>
</form>
<!-- Login Link -->
<div class="login-footer">
<div class="auth-footer-text">Already have an account?</div>
<a href="/shop/account/login">Sign in instead</a>
</div>
</div>
<script>
function customerRegistration() {
return {
// Data
formData: {
first_name: '',
last_name: '',
email: '',
phone: '',
password: '',
marketing_consent: false
},
confirmPassword: '',
showPassword: false,
loading: false,
errors: {},
alert: {
show: false,
type: 'error',
message: ''
},
// Get vendor data from element
get vendorId() {
return this.$el.dataset.vendorId;
},
get vendorName() {
return this.$el.dataset.vendorName;
},
// Clear specific error
clearError(field) {
delete this.errors[field];
},
// Clear all errors
clearAllErrors() {
this.errors = {};
this.alert.show = false;
},
// Show alert
showAlert(message, type = 'error') {
this.alert = {
show: true,
type: type,
message: message
};
// Auto-hide success messages
if (type === 'success') {
setTimeout(() => {
this.alert.show = false;
}, 3000);
}
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
},
// Validate form
validateForm() {
this.clearAllErrors();
let isValid = true;
// First name
if (!this.formData.first_name.trim()) {
this.errors.first_name = 'First name is required';
isValid = false;
}
// Last name
if (!this.formData.last_name.trim()) {
this.errors.last_name = 'Last name is required';
isValid = false;
}
// Email
if (!this.formData.email.trim()) {
this.errors.email = 'Email is required';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
this.errors.email = 'Please enter a valid email address';
isValid = false;
}
// Password
if (!this.formData.password) {
this.errors.password = 'Password is required';
isValid = false;
} else if (this.formData.password.length < 8) {
this.errors.password = 'Password must be at least 8 characters';
isValid = false;
} else if (!/[a-zA-Z]/.test(this.formData.password)) {
this.errors.password = 'Password must contain at least one letter';
isValid = false;
} else if (!/[0-9]/.test(this.formData.password)) {
this.errors.password = 'Password must contain at least one number';
isValid = false;
}
// Confirm password
if (this.formData.password !== this.confirmPassword) {
this.errors.confirmPassword = 'Passwords do not match';
isValid = false;
}
return isValid;
},
// Handle registration
async handleRegister() {
if (!this.validateForm()) {
return;
}
this.loading = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/customers/register`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.formData)
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Registration failed');
}
// Success!
this.showAlert(
'Account created successfully! Redirecting to login...',
'success'
);
// Redirect to login after 2 seconds
setTimeout(() => {
window.location.href = '/shop/account/login?registered=true';
}, 2000);
} catch (error) {
console.error('Registration error:', error);
this.showAlert(error.message || 'Registration failed. Please try again.');
} finally {
this.loading = false;
}
}
}
}
</script>
</body>
</html>

489
static/shop/cart.html Normal file
View File

@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shopping Cart - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="shoppingCart()"
x-init="loadCart()"
data-vendor-id="{{ vendor.id }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<h1>🛒 Shopping Cart</h1>
</div>
<div class="header-right">
<a href="/shop" class="btn-secondary">← Continue Shopping</a>
</div>
</header>
<div class="container">
<!-- Loading State -->
<div x-show="loading && items.length === 0" class="loading">
<div class="loading-spinner-lg"></div>
<p>Loading your cart...</p>
</div>
<!-- Empty Cart -->
<div x-show="!loading && items.length === 0" class="empty-state">
<div class="empty-state-icon">🛒</div>
<h3>Your cart is empty</h3>
<p>Add some products to get started!</p>
<a href="/shop/products" class="btn-primary">Browse Products</a>
</div>
<!-- Cart Items -->
<div x-show="items.length > 0" class="cart-content">
<!-- Cart Items List -->
<div class="cart-items">
<template x-for="item in items" :key="item.product_id">
<div class="cart-item-card">
<div class="item-image">
<img :src="item.image_url || '/static/images/placeholder.png'"
:alt="item.name">
</div>
<div class="item-details">
<h3 class="item-name" x-text="item.name"></h3>
<p class="item-sku" x-text="'SKU: ' + item.sku"></p>
<p class="item-price">
<span x-text="parseFloat(item.price).toFixed(2)"></span>
</p>
</div>
<div class="item-quantity">
<label>Quantity:</label>
<div class="quantity-controls">
<button
@click="updateQuantity(item.product_id, item.quantity - 1)"
:disabled="item.quantity <= 1 || updating"
class="btn-quantity"
>
</button>
<input
type="number"
:value="item.quantity"
@change="updateQuantity(item.product_id, $event.target.value)"
min="1"
max="99"
:disabled="updating"
class="quantity-input"
>
<button
@click="updateQuantity(item.product_id, item.quantity + 1)"
:disabled="updating"
class="btn-quantity"
>
+
</button>
</div>
</div>
<div class="item-total">
<label>Subtotal:</label>
<p class="item-total-price">
<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
</p>
</div>
<div class="item-actions">
<button
@click="removeItem(item.product_id)"
:disabled="updating"
class="btn-remove"
title="Remove from cart"
>
🗑️
</button>
</div>
</div>
</template>
</div>
<!-- Cart Summary -->
<div class="cart-summary">
<div class="summary-card">
<h3>Order Summary</h3>
<div class="summary-row">
<span>Subtotal (<span x-text="totalItems"></span> items):</span>
<span><span x-text="subtotal.toFixed(2)"></span></span>
</div>
<div class="summary-row">
<span>Shipping:</span>
<span x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
</div>
<div class="summary-row summary-total">
<span>Total:</span>
<span class="total-amount"><span x-text="total.toFixed(2)"></span></span>
</div>
<button
@click="proceedToCheckout()"
:disabled="updating || items.length === 0"
class="btn-primary btn-checkout"
>
Proceed to Checkout
</button>
<a href="/shop/products" class="btn-outline">
Continue Shopping
</a>
</div>
</div>
</div>
</div>
</div>
<script>
function shoppingCart() {
return {
items: [],
loading: false,
updating: false,
vendorId: null,
sessionId: null,
// Computed properties
get totalItems() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
get subtotal() {
return this.items.reduce((sum, item) =>
sum + (parseFloat(item.price) * item.quantity), 0
);
},
get shipping() {
// Free shipping over €50
return this.subtotal >= 50 ? 0 : 5.99;
},
get total() {
return this.subtotal + this.shipping;
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.sessionId = this.getOrCreateSessionId();
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
}
return sessionId;
},
// Load cart from API
async loadCart() {
this.loading = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
);
if (response.ok) {
const data = await response.json();
this.items = data.items || [];
}
} catch (error) {
console.error('Failed to load cart:', error);
} finally {
this.loading = false;
}
},
// Update item quantity
async updateQuantity(productId, newQuantity) {
newQuantity = parseInt(newQuantity);
if (newQuantity < 1 || newQuantity > 99) return;
this.updating = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items/${productId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: newQuantity })
}
);
if (response.ok) {
await this.loadCart();
} else {
throw new Error('Failed to update quantity');
}
} catch (error) {
console.error('Update quantity error:', error);
alert('Failed to update quantity. Please try again.');
} finally {
this.updating = false;
}
},
// Remove item from cart
async removeItem(productId) {
if (!confirm('Remove this item from your cart?')) {
return;
}
this.updating = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items/${productId}`,
{
method: 'DELETE'
}
);
if (response.ok) {
await this.loadCart();
} else {
throw new Error('Failed to remove item');
}
} catch (error) {
console.error('Remove item error:', error);
alert('Failed to remove item. Please try again.');
} finally {
this.updating = false;
}
},
// Proceed to checkout
proceedToCheckout() {
// Check if customer is logged in
const token = localStorage.getItem('customer_token');
if (!token) {
// Redirect to login with return URL
window.location.href = '/shop/account/login?return=/shop/checkout';
} else {
window.location.href = '/shop/checkout';
}
}
}
}
</script>
<style>
/* Cart-specific styles */
.cart-content {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.cart-items {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.cart-item-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: grid;
grid-template-columns: 100px 1fr auto auto auto;
gap: var(--spacing-lg);
align-items: center;
box-shadow: var(--shadow-md);
}
.item-image img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: var(--radius-md);
}
.item-name {
font-size: var(--font-lg);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.item-sku {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: var(--spacing-xs);
}
.item-price {
font-size: var(--font-lg);
font-weight: 600;
color: var(--primary-color);
}
.quantity-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.btn-quantity {
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-lg);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.btn-quantity:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--primary-color);
}
.btn-quantity:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-input {
width: 60px;
text-align: center;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.item-total-price {
font-size: var(--font-xl);
font-weight: 700;
color: var(--text-primary);
}
.btn-remove {
width: 40px;
height: 40px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-lg);
transition: all var(--transition-base);
}
.btn-remove:hover:not(:disabled) {
background: var(--danger-color);
border-color: var(--danger-color);
color: white;
}
.cart-summary {
position: sticky;
top: var(--spacing-lg);
height: fit-content;
}
.summary-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
}
.summary-card h3 {
font-size: var(--font-xl);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--border-color);
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-md);
font-size: var(--font-base);
}
.summary-total {
font-size: var(--font-lg);
font-weight: 700;
padding-top: var(--spacing-md);
border-top: 2px solid var(--border-color);
margin-top: var(--spacing-md);
}
.total-amount {
color: var(--primary-color);
font-size: var(--font-2xl);
}
.btn-checkout {
width: 100%;
margin-top: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.btn-outline {
width: 100%;
display: block;
text-align: center;
padding: 10px 20px;
}
/* Responsive */
@media (max-width: 1024px) {
.cart-content {
grid-template-columns: 1fr;
}
.cart-summary {
position: static;
}
}
@media (max-width: 768px) {
.cart-item-card {
grid-template-columns: 80px 1fr;
gap: var(--spacing-md);
}
.item-image img {
width: 80px;
height: 80px;
}
.item-quantity,
.item-total {
grid-column: 2;
}
.item-actions {
grid-column: 2;
justify-self: end;
}
}
</style>
</body>
</html>

771
static/shop/product.html Normal file
View File

@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ product.name if product else 'Product' }} - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="productDetail()"
x-init="loadProduct()"
data-vendor-id="{{ vendor.id }}"
data-product-id="{{ product_id }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<a href="/shop/products" class="btn-back">← Back to Products</a>
<h1>{{ vendor.name }}</h1>
</div>
<div class="header-right">
<a href="/shop/cart" class="btn-primary">
🛒 Cart (<span x-text="cartCount"></span>)
</a>
</div>
</header>
<!-- Loading State -->
<div x-show="loading" class="container">
<div class="loading">
<div class="loading-spinner-lg"></div>
<p>Loading product...</p>
</div>
</div>
<!-- Product Detail -->
<div x-show="!loading && product" class="container">
<div class="product-detail-container">
<!-- Product Images -->
<div class="product-images">
<div class="main-image">
<img
:src="selectedImage || '/static/images/placeholder.png'"
:alt="product?.marketplace_product?.title"
class="product-main-image"
>
</div>
<!-- Thumbnail Gallery -->
<div class="image-gallery" x-show="product?.marketplace_product?.images?.length > 1">
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
<img
:src="image"
:alt="`Product image ${index + 1}`"
class="thumbnail"
:class="{ 'active': selectedImage === image }"
@click="selectedImage = image"
>
</template>
</div>
</div>
<!-- Product Info -->
<div class="product-info-detail">
<h1 x-text="product?.marketplace_product?.title" class="product-title-detail"></h1>
<!-- Brand & Category -->
<div class="product-meta">
<span x-show="product?.marketplace_product?.brand" class="meta-item">
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
</span>
<span x-show="product?.marketplace_product?.google_product_category" class="meta-item">
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
</span>
<span class="meta-item">
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
</span>
</div>
<!-- Price -->
<div class="product-pricing">
<div x-show="product?.sale_price && product?.sale_price < product?.price">
<span class="price-original"><span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
<span class="price-sale"><span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
<span class="price-badge">SALE</span>
</div>
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
<span class="price-current"><span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
</div>
</div>
<!-- Availability -->
<div class="product-availability">
<span
x-show="product?.available_inventory > 0"
class="availability-badge in-stock"
>
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
</span>
<span
x-show="!product?.available_inventory || product?.available_inventory <= 0"
class="availability-badge out-of-stock"
>
✗ Out of Stock
</span>
</div>
<!-- Description -->
<div class="product-description">
<h3>Description</h3>
<p x-text="product?.marketplace_product?.description || 'No description available'"></p>
</div>
<!-- Additional Details -->
<div class="product-details" x-show="hasAdditionalDetails">
<h3>Product Details</h3>
<ul>
<li x-show="product?.marketplace_product?.gtin">
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
</li>
<li x-show="product?.condition">
<strong>Condition:</strong> <span x-text="product?.condition"></span>
</li>
<li x-show="product?.marketplace_product?.color">
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
</li>
<li x-show="product?.marketplace_product?.size">
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
</li>
<li x-show="product?.marketplace_product?.material">
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
</li>
</ul>
</div>
<!-- Add to Cart Section -->
<div class="add-to-cart-section">
<!-- Quantity Selector -->
<div class="quantity-selector">
<label>Quantity:</label>
<div class="quantity-controls">
<button
@click="decreaseQuantity()"
:disabled="quantity <= (product?.min_quantity || 1)"
class="btn-quantity"
>
</button>
<input
type="number"
x-model.number="quantity"
:min="product?.min_quantity || 1"
:max="product?.max_quantity || product?.available_inventory"
class="quantity-input"
@change="validateQuantity()"
>
<button
@click="increaseQuantity()"
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
class="btn-quantity"
>
+
</button>
</div>
</div>
<!-- Add to Cart Button -->
<button
@click="addToCart()"
:disabled="!canAddToCart || addingToCart"
class="btn-add-to-cart"
>
<span x-show="!addingToCart">
🛒 Add to Cart
</span>
<span x-show="addingToCart">
<span class="loading-spinner"></span> Adding...
</span>
</button>
<!-- Total Price -->
<div class="total-price">
<strong>Total:</strong><span x-text="totalPrice.toFixed(2)"></span>
</div>
</div>
</div>
</div>
<!-- Related Products / You May Also Like -->
<div class="related-products" x-show="relatedProducts.length > 0">
<h2>You May Also Like</h2>
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="product-card">
<img
:src="related.image_url || '/static/images/placeholder.png'"
:alt="related.name"
class="product-image"
@click="viewProduct(related.id)"
>
<div class="product-info">
<h3
class="product-title"
@click="viewProduct(related.id)"
x-text="related.name"
></h3>
<p class="product-price">
<span x-text="parseFloat(related.price).toFixed(2)"></span>
</p>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Toast Notification -->
<div
x-show="toast.show"
x-transition
:class="'toast toast-' + toast.type"
x-text="toast.message"
></div>
</div>
<script>
function productDetail() {
return {
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
cartCount: 0,
vendorId: null,
productId: null,
sessionId: null,
// Toast notification
toast: {
show: false,
type: 'success',
message: ''
},
// Computed properties
get canAddToCart() {
return this.product?.is_active &&
this.product?.available_inventory > 0 &&
this.quantity > 0 &&
this.quantity <= this.product?.available_inventory;
},
get totalPrice() {
const price = this.product?.sale_price || this.product?.price || 0;
return price * this.quantity;
},
get hasAdditionalDetails() {
return this.product?.marketplace_product?.gtin ||
this.product?.condition ||
this.product?.marketplace_product?.color ||
this.product?.marketplace_product?.size ||
this.product?.marketplace_product?.material;
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.productId = this.$el.dataset.productId;
this.sessionId = this.getOrCreateSessionId();
this.loadCartCount();
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
}
return sessionId;
},
// Load product details
async loadProduct() {
this.loading = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/products/${this.productId}`
);
if (!response.ok) {
throw new Error('Product not found');
}
this.product = await response.json();
// Set default image
if (this.product?.marketplace_product?.image_link) {
this.selectedImage = this.product.marketplace_product.image_link;
}
// Set initial quantity
this.quantity = this.product?.min_quantity || 1;
// Load related products (optional)
await this.loadRelatedProducts();
} catch (error) {
console.error('Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '/shop/products';
}, 2000);
} finally {
this.loading = false;
}
},
// Load related products (same category or brand)
async loadRelatedProducts() {
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/products?limit=4`
);
if (response.ok) {
const data = await response.json();
// Filter out current product
this.relatedProducts = data.products
.filter(p => p.id !== parseInt(this.productId))
.slice(0, 4);
}
} catch (error) {
console.error('Failed to load related products:', error);
}
},
// Load cart count
async loadCartCount() {
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
);
if (response.ok) {
const data = await response.json();
this.cartCount = (data.items || []).reduce((sum, item) =>
sum + item.quantity, 0
);
}
} catch (error) {
console.error('Failed to load cart count:', error);
}
},
// Quantity controls
increaseQuantity() {
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < max) {
this.quantity++;
}
},
decreaseQuantity() {
const min = this.product?.min_quantity || 1;
if (this.quantity > min) {
this.quantity--;
}
},
validateQuantity() {
const min = this.product?.min_quantity || 1;
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < min) {
this.quantity = min;
} else if (this.quantity > max) {
this.quantity = max;
}
},
// Add to cart
async addToCart() {
if (!this.canAddToCart) return;
this.addingToCart = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: parseInt(this.productId),
quantity: this.quantity
})
}
);
if (response.ok) {
this.cartCount += this.quantity;
this.showToast(
`${this.quantity} item(s) added to cart!`,
'success'
);
// Reset quantity to minimum
this.quantity = this.product?.min_quantity || 1;
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
},
// View other product
viewProduct(productId) {
window.location.href = `/shop/products/${productId}`;
},
// Show toast notification
showToast(message, type = 'success') {
this.toast = {
show: true,
type: type,
message: message
};
setTimeout(() => {
this.toast.show = false;
}, 3000);
}
}
}
</script>
<style>
/* Product Detail Styles */
.product-detail-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-2xl);
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
}
.product-images {
position: sticky;
top: var(--spacing-lg);
height: fit-content;
}
.main-image {
width: 100%;
aspect-ratio: 1;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--gray-50);
margin-bottom: var(--spacing-md);
}
.product-main-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--spacing-sm);
}
.thumbnail {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-md);
cursor: pointer;
border: 2px solid transparent;
transition: all var(--transition-base);
}
.thumbnail:hover {
border-color: var(--primary-color);
}
.thumbnail.active {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
.product-info-detail {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.product-title-detail {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.product-meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.meta-item {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.product-pricing {
padding: var(--spacing-lg);
background: var(--gray-50);
border-radius: var(--radius-lg);
}
.price-original {
font-size: var(--font-xl);
color: var(--text-muted);
text-decoration: line-through;
margin-right: var(--spacing-md);
}
.price-sale {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--danger-color);
}
.price-current {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
}
.price-badge {
display: inline-block;
background: var(--danger-color);
color: white;
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--font-sm);
font-weight: 600;
margin-left: var(--spacing-sm);
}
.product-availability {
padding: var(--spacing-md);
background: white;
border-radius: var(--radius-md);
}
.availability-badge {
display: inline-block;
padding: 8px 16px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: var(--font-base);
}
.availability-badge.in-stock {
background: #d4edda;
color: #155724;
}
.availability-badge.out-of-stock {
background: #f8d7da;
color: #721c24;
}
.product-description {
padding: var(--spacing-lg);
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.product-description h3 {
margin-bottom: var(--spacing-md);
font-size: var(--font-xl);
}
.product-description p {
line-height: 1.6;
color: var(--text-secondary);
}
.product-details {
padding: var(--spacing-lg);
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.product-details h3 {
margin-bottom: var(--spacing-md);
font-size: var(--font-xl);
}
.product-details ul {
list-style: none;
padding: 0;
margin: 0;
}
.product-details li {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color);
}
.product-details li:last-child {
border-bottom: none;
}
.add-to-cart-section {
padding: var(--spacing-xl);
background: white;
border-radius: var(--radius-lg);
border: 2px solid var(--primary-color);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.quantity-selector {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.quantity-selector label {
font-weight: 600;
font-size: var(--font-lg);
}
.quantity-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.btn-quantity {
width: 40px;
height: 40px;
border: 2px solid var(--border-color);
background: white;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-xl);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
}
.btn-quantity:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--primary-color);
}
.btn-quantity:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-input {
width: 80px;
text-align: center;
padding: 10px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-lg);
font-weight: 600;
}
.btn-add-to-cart {
width: 100%;
padding: 16px 32px;
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-xl);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
}
.btn-add-to-cart:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-add-to-cart:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.total-price {
padding: var(--spacing-md);
background: var(--gray-50);
border-radius: var(--radius-md);
text-align: center;
font-size: var(--font-2xl);
}
.related-products {
margin-top: var(--spacing-2xl);
padding-top: var(--spacing-2xl);
border-top: 2px solid var(--border-color);
}
.related-products h2 {
margin-bottom: var(--spacing-xl);
font-size: var(--font-3xl);
}
/* Responsive */
@media (max-width: 1024px) {
.product-detail-container {
grid-template-columns: 1fr;
}
.product-images {
position: static;
}
}
@media (max-width: 768px) {
.product-title-detail {
font-size: var(--font-2xl);
}
.price-current,
.price-sale {
font-size: var(--font-3xl);
}
.add-to-cart-section {
padding: var(--spacing-lg);
}
}
</style>
</body>
</html>

459
static/shop/products.html Normal file
View File

@@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Products - {{ vendor.name }}</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/vendor/vendor.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="productCatalog()"
x-init="loadProducts()"
data-vendor-id="{{ vendor.id }}"
>
<!-- Header -->
<header class="header">
<div class="header-left">
<h1>{{ vendor.name }} - Products</h1>
</div>
<div class="header-right">
<a href="/shop/cart" class="btn-primary">
🛒 Cart (<span x-text="cartCount"></span>)
</a>
</div>
</header>
<div class="container">
<!-- Filters & Search -->
<div class="filter-bar">
<div class="search-box">
<input
type="text"
x-model="filters.search"
@input.debounce.500ms="loadProducts()"
placeholder="Search products..."
class="search-input"
>
<span class="search-icon">🔍</span>
</div>
<div class="filter-group">
<select
x-model="filters.sort"
@change="loadProducts()"
class="form-select"
>
<option value="name_asc">Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
<option value="price_asc">Price (Low to High)</option>
<option value="price_desc">Price (High to Low)</option>
<option value="newest">Newest First</option>
</select>
</div>
<button
@click="clearFilters()"
class="btn-secondary"
x-show="hasActiveFilters"
>
Clear Filters
</button>
</div>
<!-- Loading State -->
<div x-show="loading" class="loading">
<div class="loading-spinner-lg"></div>
<p>Loading products...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && products.length === 0" class="empty-state">
<div class="empty-state-icon">📦</div>
<h3>No products found</h3>
<p x-show="hasActiveFilters">Try adjusting your filters</p>
<p x-show="!hasActiveFilters">Check back soon for new products!</p>
</div>
<!-- Product Grid -->
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="product-card">
<!-- Product Image -->
<div class="product-image-wrapper">
<img
:src="product.image_url || '/static/images/placeholder.png'"
:alt="product.name"
class="product-image"
@click="viewProduct(product.id)"
>
<div
x-show="!product.is_active || product.inventory_level <= 0"
class="out-of-stock-badge"
>
Out of Stock
</div>
</div>
<!-- Product Info -->
<div class="product-info">
<h3
class="product-title"
@click="viewProduct(product.id)"
x-text="product.name"
></h3>
<p class="product-sku" x-text="'SKU: ' + product.sku"></p>
<p class="product-price">
<span x-text="parseFloat(product.price).toFixed(2)"></span>
</p>
<p
class="product-description"
x-text="product.description || 'No description available'"
></p>
</div>
<!-- Product Actions -->
<div class="product-actions">
<button
@click="viewProduct(product.id)"
class="btn-outline btn-sm"
>
View Details
</button>
<button
@click="addToCart(product)"
:disabled="!product.is_active || product.inventory_level <= 0 || addingToCart[product.id]"
class="btn-primary btn-sm"
>
<span x-show="!addingToCart[product.id]">Add to Cart</span>
<span x-show="addingToCart[product.id]">
<span class="loading-spinner"></span> Adding...
</span>
</button>
</div>
</div>
</template>
</div>
<!-- Pagination -->
<div x-show="totalPages > 1" class="pagination">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
class="pagination-btn"
>
← Previous
</button>
<span class="pagination-info">
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
</span>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="pagination-btn"
>
Next →
</button>
</div>
</div>
<!-- Toast Notification -->
<div
x-show="toast.show"
x-transition
:class="'toast toast-' + toast.type"
class="toast"
x-text="toast.message"
></div>
</div>
<script>
function productCatalog() {
return {
products: [],
loading: false,
addingToCart: {},
cartCount: 0,
vendorId: null,
sessionId: null,
// Filters
filters: {
search: '',
sort: 'name_asc',
category: ''
},
// Pagination
currentPage: 1,
perPage: 12,
totalProducts: 0,
// Toast notification
toast: {
show: false,
type: 'success',
message: ''
},
// Computed properties
get totalPages() {
return Math.ceil(this.totalProducts / this.perPage);
},
get hasActiveFilters() {
return this.filters.search !== '' ||
this.filters.category !== '' ||
this.filters.sort !== 'name_asc';
},
// Initialize
init() {
this.vendorId = this.$el.dataset.vendorId;
this.sessionId = this.getOrCreateSessionId();
this.loadCartCount();
},
// Get or create session ID
getOrCreateSessionId() {
let sessionId = localStorage.getItem('cart_session_id');
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('cart_session_id', sessionId);
}
return sessionId;
},
// Load products from API
async loadProducts() {
this.loading = true;
try {
const params = new URLSearchParams({
page: this.currentPage,
per_page: this.perPage,
search: this.filters.search,
sort: this.filters.sort
});
if (this.filters.category) {
params.append('category', this.filters.category);
}
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/products?${params}`
);
if (response.ok) {
const data = await response.json();
this.products = data.products || [];
this.totalProducts = data.total || 0;
} else {
throw new Error('Failed to load products');
}
} catch (error) {
console.error('Load products error:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
}
},
// Load cart count
async loadCartCount() {
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}`
);
if (response.ok) {
const data = await response.json();
this.cartCount = (data.items || []).reduce((sum, item) =>
sum + item.quantity, 0
);
}
} catch (error) {
console.error('Failed to load cart count:', error);
}
},
// Add product to cart
async addToCart(product) {
this.addingToCart[product.id] = true;
try {
const response = await fetch(
`/api/v1/public/vendors/${this.vendorId}/cart/${this.sessionId}/items`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: product.id,
quantity: 1
})
}
);
if (response.ok) {
this.cartCount++;
this.showToast(`${product.name} added to cart!`, 'success');
} else {
throw new Error('Failed to add to cart');
}
} catch (error) {
console.error('Add to cart error:', error);
this.showToast('Failed to add to cart. Please try again.', 'error');
} finally {
this.addingToCart[product.id] = false;
}
},
// View product details
viewProduct(productId) {
window.location.href = `/shop/products/${productId}`;
},
// Change page
changePage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
this.loadProducts();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
// Clear filters
clearFilters() {
this.filters = {
search: '',
sort: 'name_asc',
category: ''
};
this.currentPage = 1;
this.loadProducts();
},
// Show toast notification
showToast(message, type = 'success') {
this.toast = {
show: true,
type: type,
message: message
};
setTimeout(() => {
this.toast.show = false;
}, 3000);
}
}
}
</script>
<style>
/* Product-specific styles */
.filter-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
align-items: center;
}
.search-box {
flex: 2;
min-width: 300px;
position: relative;
}
.search-input {
width: 100%;
padding-left: 40px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
font-size: var(--font-lg);
}
.filter-group {
flex: 1;
min-width: 200px;
}
.product-image-wrapper {
position: relative;
cursor: pointer;
}
.out-of-stock-badge {
position: absolute;
top: 10px;
right: 10px;
background: var(--danger-color);
color: white;
padding: 6px 12px;
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: 600;
}
.product-title {
cursor: pointer;
}
.product-title:hover {
color: var(--primary-color);
}
.product-sku {
font-size: var(--font-sm);
color: var(--text-muted);
margin-bottom: var(--spacing-sm);
}
/* Toast notification */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
z-index: 10000;
max-width: 400px;
}
.toast-success {
border-left: 4px solid var(--success-color);
}
.toast-error {
border-left: 4px solid var(--danger-color);
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
}
.search-box,
.filter-group {
width: 100%;
}
}
</style>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More