major refactoring adding vendor and customer features

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

View File

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

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

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

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

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

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

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

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

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

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

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