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:
2025-12-04 22:24:45 +01:00
parent 76f8a59954
commit 8a367077e1
85 changed files with 21787 additions and 134978 deletions

View File

@@ -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."
)

View File

@@ -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={

View File

@@ -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,

View File

@@ -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={