major refactoring adding vendor and customer features
This commit is contained in:
4
Makefile
4
Makefile
@@ -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
|
||||
|
||||
52
README.md
52
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# app/api/v1/__init__.py
|
||||
"""
|
||||
API Version 1 - All endpoints
|
||||
"""
|
||||
|
||||
from . import admin, vendor, public
|
||||
|
||||
__all__ = ["admin", "vendor", "public"]
|
||||
@@ -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)
|
||||
17
app/api/v1/admin/__init__.py
Normal file
17
app/api/v1/admin/__init__.py
Normal 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
58
app/api/v1/admin/auth.py
Normal 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"}
|
||||
90
app/api/v1/admin/dashboard.py
Normal file
90
app/api/v1/admin/dashboard.py
Normal 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),
|
||||
}
|
||||
49
app/api/v1/admin/marketplace.py
Normal file
49
app/api/v1/admin/marketplace.py
Normal 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
51
app/api/v1/admin/users.py
Normal 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
143
app/api/v1/admin/vendors.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
18
app/api/v1/public/__init__.py
Normal file
18
app/api/v1/public/__init__.py
Normal 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
2
app/api/v1/public/vendors/__init__.py
vendored
Normal 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
175
app/api/v1/public/vendors/auth.py
vendored
Normal 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
164
app/api/v1/public/vendors/cart.py
vendored
Normal 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
163
app/api/v1/public/vendors/orders.py
vendored
Normal 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
138
app/api/v1/public/vendors/products.py
vendored
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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"}
|
||||
@@ -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
21
app/api/v1/vendor/__init__.py
vendored
Normal 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
83
app/api/v1/vendor/auth.py
vendored
Normal 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
62
app/api/v1/vendor/dashboard.py
vendored
Normal 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
141
app/api/v1/vendor/inventory.py
vendored
Normal 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
115
app/api/v1/vendor/marketplace.py
vendored
Normal 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
111
app/api/v1/vendor/orders.py
vendored
Normal 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
227
app/api/v1/vendor/products.py
vendored
Normal 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
330
app/api/v1/vendor/vendor.py
vendored
Normal 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)
|
||||
@@ -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
102
app/exceptions/customer.py
Normal 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
|
||||
}
|
||||
)
|
||||
@@ -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
73
app/exceptions/order.py
Normal 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
|
||||
}
|
||||
)
|
||||
@@ -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
236
app/exceptions/team.py
Normal 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
8
app/routes/__init__.py
Normal 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
158
app/routes/frontend.py
Normal 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")
|
||||
@@ -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()
|
||||
|
||||
184
app/services/cart_service.py
Normal file
184
app/services/cart_service.py
Normal 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()
|
||||
407
app/services/customer_service.py
Normal file
407
app/services/customer_service.py
Normal 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()
|
||||
578
app/services/inventory_service.py
Normal file
578
app/services/inventory_service.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
376
app/services/order_service.py
Normal file
376
app/services/order_service.py
Normal 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()
|
||||
247
app/services/product_service.py
Normal file
247
app/services/product_service.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
214
app/services/team_service.py
Normal file
214
app/services/team_service.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
311
create_project_structure.bat
Normal file
311
create_project_structure.bat
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
10
init.sql
10
init.sql
@@ -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
38
main.py
@@ -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)
|
||||
|
||||
166
middleware/vendor_context.py
Normal file
166
middleware/vendor_context.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
64
models/database/customer.py
Normal file
64
models/database/customer.py
Normal 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}')>"
|
||||
41
models/database/inventory.py
Normal file
41
models/database/inventory.py
Normal 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)
|
||||
@@ -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})>"
|
||||
)
|
||||
|
||||
@@ -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
86
models/database/order.py
Normal 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})>"
|
||||
@@ -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)
|
||||
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
@@ -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})>"
|
||||
@@ -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
193
models/schemas/customer.py
Normal 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
|
||||
77
models/schemas/inventory.py
Normal file
77
models/schemas/inventory.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
170
models/schemas/order.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
604
static/admin/dashboard.html
Normal 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
200
static/admin/login.html
Normal 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
347
static/admin/vendors.html
Normal 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
556
static/css/admin/admin.css
Normal 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
622
static/css/shared/auth.css
Normal 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
510
static/css/shared/base.css
Normal 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
601
static/css/vendor/vendor.css
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
340
static/js/shared/api-client.js
Normal file
340
static/js/shared/api-client.js
Normal 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 };
|
||||
}
|
||||
249
static/shop/account/login.html
Normal file
249
static/shop/account/login.html
Normal 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>
|
||||
341
static/shop/account/register.html
Normal file
341
static/shop/account/register.html
Normal 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
489
static/shop/cart.html
Normal 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
771
static/shop/product.html
Normal 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
459
static/shop/products.html
Normal 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
Reference in New Issue
Block a user