refactor: migrate vendor APIs to token-based context and consolidate architecture
## Vendor-in-Token Architecture (Complete Migration) - Migrate all vendor API endpoints from require_vendor_context() to token_vendor_id - Update permission dependencies to extract vendor from JWT token - Add vendor exceptions: VendorAccessDeniedException, VendorOwnerOnlyException, InsufficientVendorPermissionsException - Shop endpoints retain require_vendor_context() for URL-based detection - Add AUTH-004 architecture rule enforcing vendor context patterns - Fix marketplace router missing /marketplace prefix ## Exception Pattern Fixes (API-003/API-004) - Services raise domain exceptions, endpoints let them bubble up - Add code_quality and content_page exception modules - Move business logic from endpoints to services (admin, auth, content_page) - Fix exception handling in admin, shop, and vendor endpoints ## Tailwind CSS Consolidation - Consolidate CSS to per-area files (admin, vendor, shop, platform) - Remove shared/cdn-fallback.html and shared/css/tailwind.min.css - Update all templates to use area-specific Tailwind output files - Remove Node.js config (package.json, postcss.config.js, tailwind.config.js) ## Documentation & Cleanup - Update vendor-in-token-architecture.md with completed migration status - Update architecture-rules.md with new rules - Move migration docs to docs/development/migration/ - Remove duplicate/obsolete documentation files - Merge pytest.ini settings into pyproject.toml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,14 +16,20 @@ This prevents:
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import VendorNotFoundException
|
||||
from app.services.customer_service import customer_service
|
||||
from models.schema.auth import UserLogin
|
||||
from models.schema.auth import (
|
||||
LogoutResponse,
|
||||
PasswordResetRequestResponse,
|
||||
PasswordResetResponse,
|
||||
UserLogin,
|
||||
)
|
||||
from models.schema.customer import CustomerRegister, CustomerResponse
|
||||
|
||||
router = APIRouter()
|
||||
@@ -62,10 +68,7 @@ def register_customer(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
|
||||
@@ -122,10 +125,7 @@ def customer_login(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
|
||||
@@ -199,7 +199,7 @@ def customer_login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
@router.post("/auth/logout", response_model=LogoutResponse)
|
||||
def customer_logout(request: Request, response: Response):
|
||||
"""
|
||||
Customer logout for current vendor.
|
||||
@@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response):
|
||||
|
||||
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
@router.post("/auth/forgot-password")
|
||||
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
|
||||
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Request password reset for customer.
|
||||
@@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
|
||||
@@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
|
||||
|
||||
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
|
||||
|
||||
return {
|
||||
"message": "If an account exists with this email, a password reset link has been sent."
|
||||
}
|
||||
return PasswordResetRequestResponse(
|
||||
message="If an account exists with this email, a password reset link has been sent."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/reset-password")
|
||||
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
|
||||
def reset_password(
|
||||
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -307,10 +304,7 @@ def reset_password(
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
|
||||
@@ -329,6 +323,6 @@ def reset_password(
|
||||
|
||||
logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
|
||||
|
||||
return {
|
||||
"message": "Password reset successfully. You can now log in with your new password."
|
||||
}
|
||||
return PasswordResetResponse(
|
||||
message="Password reset successfully. You can now log in with your new password."
|
||||
)
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
Shop Shopping Cart API (Public)
|
||||
|
||||
Public endpoints for managing shopping cart in shop frontend.
|
||||
Uses vendor from request.state (injected by VendorContextMiddleware).
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
No authentication required - uses session ID for cart tracking.
|
||||
|
||||
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.cart_service import cart_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.cart import (
|
||||
AddToCartRequest,
|
||||
CartOperationResponse,
|
||||
@@ -31,30 +35,21 @@ logger = logging.getLogger(__name__)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/cart/{session_id}", response_model=CartResponse)
|
||||
@router.get("/cart/{session_id}", response_model=CartResponse) # public
|
||||
def get_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartResponse:
|
||||
"""
|
||||
Get shopping cart contents for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID for cart tracking.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
|
||||
extra={
|
||||
@@ -79,17 +74,17 @@ def get_cart(
|
||||
return CartResponse.from_service_dict(cart)
|
||||
|
||||
|
||||
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse)
|
||||
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
|
||||
def add_to_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
cart_data: AddToCartRequest = Body(...),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Add product to cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
@@ -99,15 +94,6 @@ def add_to_cart(
|
||||
- product_id: ID of product to add
|
||||
- quantity: Quantity to add (default: 1)
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
|
||||
extra={
|
||||
@@ -140,18 +126,18 @@ def add_to_cart(
|
||||
|
||||
@router.put(
|
||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||
)
|
||||
) # public
|
||||
def update_cart_item(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
cart_data: UpdateCartItemRequest = Body(...),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Update cart item quantity for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
@@ -161,15 +147,6 @@ def update_cart_item(
|
||||
Request Body:
|
||||
- quantity: New quantity (must be >= 1)
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
|
||||
extra={
|
||||
@@ -194,32 +171,23 @@ def update_cart_item(
|
||||
|
||||
@router.delete(
|
||||
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
|
||||
)
|
||||
) # public
|
||||
def remove_from_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CartOperationResponse:
|
||||
"""
|
||||
Remove item from cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
- product_id: ID of product to remove
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] remove_from_cart: product {product_id}",
|
||||
extra={
|
||||
@@ -237,30 +205,21 @@ def remove_from_cart(
|
||||
return CartOperationResponse(**result)
|
||||
|
||||
|
||||
@router.delete("/cart/{session_id}", response_model=ClearCartResponse)
|
||||
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
|
||||
def clear_cart(
|
||||
request: Request,
|
||||
session_id: str = Path(..., description="Shopping session ID"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClearCartResponse:
|
||||
"""
|
||||
Clear all items from cart for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required - uses session ID.
|
||||
|
||||
Path Parameters:
|
||||
- session_id: Unique session identifier for the cart
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] clear_cart for session {session_id}",
|
||||
extra={
|
||||
|
||||
@@ -8,7 +8,7 @@ No authentication required.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
vendor_id = vendor.id if vendor else None
|
||||
|
||||
page = content_page_service.get_page_for_vendor(
|
||||
page = content_page_service.get_page_for_vendor_or_raise(
|
||||
db,
|
||||
slug=slug,
|
||||
vendor_id=vendor_id,
|
||||
include_unpublished=False, # Only show published pages
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
|
||||
|
||||
return {
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
Shop Product Catalog API (Public)
|
||||
|
||||
Public endpoints for browsing product catalog in shop frontend.
|
||||
Uses vendor from request.state (injected by VendorContextMiddleware).
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
No authentication required.
|
||||
|
||||
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.product import (
|
||||
ProductDetailResponse,
|
||||
ProductListResponse,
|
||||
@@ -24,19 +28,19 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/products", response_model=ProductListResponse)
|
||||
@router.get("/products", response_model=ProductListResponse) # public
|
||||
def get_product_catalog(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: str | None = Query(None, description="Search products by name"),
|
||||
is_featured: bool | None = Query(None, description="Filter by featured products"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get product catalog for current vendor.
|
||||
|
||||
Vendor is automatically determined from request context (domain/subdomain/path).
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
Only returns active products visible to customers.
|
||||
No authentication required.
|
||||
|
||||
@@ -46,15 +50,6 @@ def get_product_catalog(
|
||||
- search: Search query for product name/description
|
||||
- is_featured: Filter by featured products only
|
||||
"""
|
||||
# Get vendor from middleware (injected by VendorContextMiddleware)
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
|
||||
extra={
|
||||
@@ -85,30 +80,21 @@ def get_product_catalog(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
|
||||
@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
|
||||
def get_product_details(
|
||||
request: Request,
|
||||
product_id: int = Path(..., description="Product ID", gt=0),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed product information for customers.
|
||||
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required.
|
||||
|
||||
Path Parameters:
|
||||
- product_id: ID of the product to retrieve
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] get_product_details for product {product_id}",
|
||||
extra={
|
||||
@@ -131,19 +117,19 @@ def get_product_details(
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
|
||||
|
||||
@router.get("/products/search", response_model=ProductListResponse)
|
||||
@router.get("/products/search", response_model=ProductListResponse) # public
|
||||
def search_products(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Search products in current vendor's catalog.
|
||||
|
||||
Searches in product names, descriptions, and SKUs.
|
||||
Vendor is automatically determined from request context.
|
||||
Vendor is automatically determined from request context (URL/subdomain/domain).
|
||||
No authentication required.
|
||||
|
||||
Query Parameters:
|
||||
@@ -151,15 +137,6 @@ def search_products(
|
||||
- skip: Number of results to skip (pagination)
|
||||
- limit: Maximum number of results to return
|
||||
"""
|
||||
# Get vendor from middleware
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[SHOP_API] search_products: '{q}'",
|
||||
extra={
|
||||
|
||||
Reference in New Issue
Block a user