refactor(cleanup): delete legacy storefront routes, convert cart to re-exports

Phase 6 of storefront restructure plan - delete legacy files and convert
remaining cart files to re-exports.

Deleted legacy storefront route files (now served from modules):
- app/api/v1/storefront/auth.py (→ customers module)
- app/api/v1/storefront/profile.py (→ customers module)
- app/api/v1/storefront/addresses.py (→ customers module)
- app/api/v1/storefront/carts.py (→ cart module)
- app/api/v1/storefront/products.py (→ catalog module)
- app/api/v1/storefront/orders.py (→ orders/checkout modules)
- app/api/v1/storefront/messages.py (→ messaging module)

Converted legacy cart files to re-exports:
- models/schema/cart.py → app.modules.cart.schemas
- models/database/cart.py → app.modules.cart.models
- app/services/cart_service.py → app.modules.cart.services

This reduces API-007 violations from 81 to 69 (remaining violations
are in admin/vendor routes - separate migration effort).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 23:09:11 +01:00
parent 4b8e1b1d88
commit 309104292d
11 changed files with 37 additions and 2692 deletions

View File

@@ -1,269 +0,0 @@
# app/api/v1/shop/addresses.py
"""
Shop Addresses API (Customer authenticated)
Endpoints for managing customer addresses in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication.
"""
import logging
from fastapi import APIRouter, Depends, Path, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.services.customer_address_service import customer_address_service
from models.database.customer import Customer
from models.schema.customer import (
CustomerAddressCreate,
CustomerAddressListResponse,
CustomerAddressResponse,
CustomerAddressUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/addresses", response_model=CustomerAddressListResponse) # authenticated
def list_addresses(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List all addresses for authenticated customer.
Vendor is automatically determined from request context.
Returns all addresses sorted by default first, then by creation date.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] list_addresses for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
},
)
addresses = customer_address_service.list_addresses(
db=db, vendor_id=vendor.id, customer_id=customer.id
)
return CustomerAddressListResponse(
addresses=[CustomerAddressResponse.model_validate(a) for a in addresses],
total=len(addresses),
)
@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse)
def get_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get specific address by ID.
Vendor is automatically determined from request context.
Customer can only access their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.get_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
return CustomerAddressResponse.model_validate(address)
@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201)
def create_address(
request: Request,
address_data: CustomerAddressCreate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Create new address for authenticated customer.
Vendor is automatically determined from request context.
Maximum 10 addresses per customer.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] create_address for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
address = customer_address_service.create_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_data=address_data,
)
db.commit()
logger.info(
f"Created address {address.id} for customer {customer.id} "
f"(type={address_data.address_type})",
extra={
"address_id": address.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
return CustomerAddressResponse.model_validate(address)
@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse)
def update_address(
request: Request,
address_data: CustomerAddressUpdate,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update existing address.
Vendor is automatically determined from request context.
Customer can only update their own addresses.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] update_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.update_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_id=address_id,
address_data=address_data,
)
db.commit()
logger.info(
f"Updated address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
return CustomerAddressResponse.model_validate(address)
@router.delete("/addresses/{address_id}", status_code=204)
def delete_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Delete address.
Vendor is automatically determined from request context.
Customer can only delete their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] delete_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
customer_address_service.delete_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Deleted address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse)
def set_address_default(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Set address as default for its type.
Vendor is automatically determined from request context.
Clears default flag on other addresses of the same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] set_address_default {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.set_default(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Set address {address_id} as default for customer {customer.id}",
extra={
"address_id": address_id,
"customer_id": customer.id,
"address_type": address.address_type,
},
)
return CustomerAddressResponse.model_validate(address)

View File

@@ -1,376 +0,0 @@
# app/api/v1/shop/auth.py
"""
Shop Authentication API (Public)
Public endpoints for customer authentication in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/shop (restricted to shop routes only)
- Returns token in response for localStorage (API calls)
This prevents:
- Customer cookies from being sent to admin or vendor routes
- Cross-context authentication confusion
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, 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 app.services.email_service import EmailService
from models.database.customer import Customer
from models.database.password_reset_token import PasswordResetToken
from models.schema.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
from models.schema.customer import CustomerRegister, CustomerResponse
router = APIRouter()
logger = logging.getLogger(__name__)
# Response model for customer login
class CustomerLoginResponse(BaseModel):
"""Customer login response with token and customer data."""
access_token: str
token_type: str
expires_in: int
user: CustomerResponse # Use CustomerResponse instead of UserResponse
@router.post("/auth/register", response_model=CustomerResponse)
def register_customer(
request: Request, customer_data: CustomerRegister, db: Session = Depends(get_db)
):
"""
Register a new customer for current vendor.
Vendor is automatically determined from request context.
Customer accounts are vendor-scoped - each vendor has independent customers.
Same email can be used for different vendors.
Request Body:
- email: Customer email address
- password: Customer password
- first_name: Customer first name
- last_name: Customer last name
- phone: Customer phone number (optional)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email": customer_data.email,
},
)
# Create customer account
customer = customer_service.register_customer(
db=db, vendor_id=vendor.id, customer_data=customer_data
)
db.commit()
logger.info(
f"New customer registered: {customer.email} for vendor {vendor.subdomain}",
extra={
"customer_id": customer.id,
"vendor_id": vendor.id,
"email": customer.email,
},
)
return CustomerResponse.model_validate(customer)
@router.post("/auth/login", response_model=CustomerLoginResponse)
def customer_login(
request: Request,
user_credentials: UserLogin,
response: Response,
db: Session = Depends(get_db),
):
"""
Customer login for current vendor.
Vendor is automatically determined from request context.
Authenticates customer and returns JWT token.
Customer must belong to the specified vendor.
Sets token in two places:
1. HTTP-only cookie with path=/shop (for browser page navigation)
2. Response body (for localStorage and API calls)
The cookie is restricted to /shop/* routes only to prevent
it from being sent to admin or vendor routes.
Request Body:
- email_or_username: Customer email or username
- password: Customer password
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email_or_username": user_credentials.email_or_username,
},
)
# 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} for vendor {vendor.subdomain}",
extra={
"customer_id": login_result["customer"].id,
"vendor_id": vendor.id,
"email": login_result["customer"].email,
},
)
# Calculate cookie path based on vendor access method
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path":
# For path-based access like /vendors/wizamart/shop
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Set HTTP-only cookie for browser navigation
# Cookie path matches the vendor's shop routes
response.set_cookie(
key="customer_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path=cookie_path, # Matches vendor's shop routes
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
extra={
"expires_in": login_result["token_data"]["expires_in"],
"secure": should_use_secure_cookies(),
"cookie_path": cookie_path,
},
)
# Return full login response
return CustomerLoginResponse(
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=CustomerResponse.model_validate(login_result["customer"]),
)
@router.post("/auth/logout", response_model=LogoutResponse)
def customer_logout(request: Request, response: Response):
"""
Customer logout for current vendor.
Vendor is automatically determined from request context.
Clears the customer_token cookie.
Client should also remove token from localStorage.
"""
# Get vendor from middleware (for logging)
vendor = getattr(request.state, "vendor", None)
logger.info(
f"Customer logout for vendor {vendor.subdomain if vendor else 'unknown'}",
extra={
"vendor_id": vendor.id if vendor else None,
"vendor_code": vendor.subdomain if vendor else None,
},
)
# Calculate cookie path based on vendor access method (must match login)
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
cookie_path = "/shop" # Default for domain/subdomain access
if access_method == "path" and vendor:
# For path-based access like /vendors/wizamart/shop
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="customer_token",
path=cookie_path,
)
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return LogoutResponse(message="Logged out successfully")
@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.
Vendor is automatically determined from request context.
Sends password reset email to customer if account exists.
Request Body:
- email: Customer email address
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email": email,
},
)
# Look up customer by email (vendor-scoped)
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
# If customer exists, generate token and send email
if customer:
try:
# Generate reset token (returns plaintext token)
plaintext_token = PasswordResetToken.create_for_customer(db, customer.id)
# Build reset link
# Use request host to construct the URL
scheme = "https" if should_use_secure_cookies() else "http"
host = request.headers.get("host", "localhost")
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
# Send password reset email
email_service = EmailService(db)
email_service.send_template(
template_code="password_reset",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"reset_link": reset_link,
"expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS),
},
vendor_id=vendor.id,
related_type="customer",
related_id=customer.id,
)
db.commit()
logger.info(
f"Password reset email sent to {email} (vendor: {vendor.subdomain})"
)
except Exception as e:
db.rollback()
logger.error(f"Failed to send password reset email: {e}")
# Don't reveal the error to the user for security
else:
# Log but don't reveal that email doesn't exist
logger.info(
f"Password reset requested for non-existent email {email} (vendor: {vendor.subdomain})"
)
# Always return the same message (don't reveal if email exists)
return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent."
)
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
"""
Reset customer password using reset token.
Vendor is automatically determined from request context.
Request Body:
- reset_token: Password reset token from email
- new_password: New password (minimum 8 characters)
"""
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
},
)
# Validate and reset password using service
customer = customer_service.validate_and_reset_password(
db=db,
vendor_id=vendor.id,
reset_token=reset_token,
new_password=new_password,
)
db.commit()
logger.info(
f"Password reset completed for customer {customer.id} (vendor: {vendor.subdomain})"
)
return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password."
)

View File

@@ -1,238 +0,0 @@
# app/api/v1/shop/carts.py
"""
Shop Shopping Cart API (Public)
Public endpoints for managing shopping cart in shop frontend.
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, 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,
CartResponse,
ClearCartResponse,
UpdateCartItemRequest,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# CART ENDPOINTS
# ============================================================================
@router.get("/cart/{session_id}", response_model=CartResponse) # public
def get_cart(
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 (URL/subdomain/domain).
No authentication required - uses session ID for cart tracking.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
logger.info(
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
},
)
cart = cart_service.get_cart(db=db, vendor_id=vendor.id, session_id=session_id)
logger.info(
f"[SHOP_API] get_cart result: {len(cart.get('items', []))} items in cart",
extra={
"session_id": session_id,
"vendor_id": vendor.id,
"item_count": len(cart.get("items", [])),
"total": cart.get("total", 0),
},
)
return CartResponse.from_service_dict(cart)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
def add_to_cart(
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 (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
Request Body:
- product_id: ID of product to add
- quantity: Quantity to add (default: 1)
"""
logger.info(
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": cart_data.product_id,
"quantity": cart_data.quantity,
},
)
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,
)
db.commit()
logger.info(
f"[SHOP_API] add_to_cart result: {result}",
extra={
"session_id": session_id,
"result": result,
},
)
return CartOperationResponse(**result)
@router.put(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) # public
def update_cart_item(
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 (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 update
Request Body:
- quantity: New quantity (must be >= 1)
"""
logger.debug(
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": product_id,
"quantity": cart_data.quantity,
},
)
result = cart_service.update_cart_item(
db=db,
vendor_id=vendor.id,
session_id=session_id,
product_id=product_id,
quantity=cart_data.quantity,
)
db.commit()
return CartOperationResponse(**result)
@router.delete(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) # public
def remove_from_cart(
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 (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
"""
logger.debug(
f"[SHOP_API] remove_from_cart: product {product_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
"product_id": product_id,
},
)
result = cart_service.remove_from_cart(
db=db, vendor_id=vendor.id, session_id=session_id, product_id=product_id
)
db.commit()
return CartOperationResponse(**result)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
def clear_cart(
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 (URL/subdomain/domain).
No authentication required - uses session ID.
Path Parameters:
- session_id: Unique session identifier for the cart
"""
logger.debug(
f"[SHOP_API] clear_cart for session {session_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"session_id": session_id,
},
)
result = cart_service.clear_cart(db=db, vendor_id=vendor.id, session_id=session_id)
db.commit()
return ClearCartResponse(**result)

View File

@@ -1,541 +0,0 @@
# app/api/v1/shop/messages.py
"""
Shop Messages API (Customer authenticated)
Endpoints for customer messaging in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication.
Customers can only:
- View their own vendor_customer conversations
- Reply to existing conversations
- Mark conversations as read
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import (
AttachmentNotFoundException,
ConversationClosedException,
ConversationNotFoundException,
VendorNotFoundException,
)
from app.services.message_attachment_service import message_attachment_service
from app.services.messaging_service import messaging_service
from models.database.customer import Customer
from models.database.message import ConversationType, ParticipantType
from models.schema.message import (
ConversationDetailResponse,
ConversationListResponse,
ConversationSummary,
MessageResponse,
UnreadCountResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# Response Models
# ============================================================================
class SendMessageResponse(BaseModel):
"""Response for send message."""
success: bool
message: MessageResponse
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/messages", response_model=ConversationListResponse) # authenticated
def list_conversations(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: Optional[str] = Query(None, regex="^(open|closed)$"),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List conversations for authenticated customer.
Customers only see their vendor_customer conversations.
Query Parameters:
- skip: Pagination offset
- limit: Max items to return
- status: Filter by open/closed
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] list_conversations for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"skip": skip,
"limit": limit,
"status": status,
},
)
# Build filter - customers only see vendor_customer conversations
is_closed = None
if status == "open":
is_closed = False
elif status == "closed":
is_closed = True
# Get conversations where customer is a participant
conversations, total = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
conversation_type=ConversationType.VENDOR_CUSTOMER,
is_closed=is_closed,
skip=skip,
limit=limit,
)
# Convert to summaries
summaries = []
for conv, unread in conversations:
summaries.append(
ConversationSummary(
id=conv.id,
subject=conv.subject,
conversation_type=conv.conversation_type.value,
is_closed=conv.is_closed,
last_message_at=conv.last_message_at,
message_count=conv.message_count,
unread_count=unread,
other_participant_name=_get_other_participant_name(conv, customer.id),
)
)
return ConversationListResponse(
conversations=summaries,
total=total,
skip=skip,
limit=limit,
)
@router.get("/messages/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get total unread message count for header badge.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
return UnreadCountResponse(unread_count=count)
@router.get("/messages/{conversation_id}", response_model=ConversationDetailResponse)
def get_conversation(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get conversation detail with messages.
Validates that customer is a participant.
Automatically marks conversation as read.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_conversation {conversation_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
},
)
# Get conversation with access check
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Mark as read
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
# Build response
messages = []
for msg in conversation.messages:
if msg.is_deleted:
continue
messages.append(
MessageResponse(
id=msg.id,
content=msg.content,
sender_type=msg.sender_type.value,
sender_id=msg.sender_id,
sender_name=_get_sender_name(msg),
is_system_message=msg.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in msg.attachments
],
created_at=msg.created_at,
)
)
return ConversationDetailResponse(
id=conversation.id,
subject=conversation.subject,
conversation_type=conversation.conversation_type.value,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
messages=messages,
other_participant_name=_get_other_participant_name(conversation, customer.id),
)
@router.post("/messages/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
content: str = Form(..., min_length=1, max_length=10000),
attachments: List[UploadFile] = File(default=[]),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Send a message in a conversation.
Validates that customer is a participant.
Supports file attachments.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] send_message in {conversation_id} from customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
"attachment_count": len(attachments),
},
)
# Verify conversation exists and customer has access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Check if conversation is closed
if conversation.is_closed:
raise ConversationClosedException(conversation_id)
# Process attachments
attachment_data = []
for upload_file in attachments:
if upload_file.filename:
file_data = await message_attachment_service.validate_and_store(
db=db,
upload_file=upload_file,
conversation_id=conversation_id,
)
attachment_data.append(file_data)
# Send message
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.CUSTOMER,
sender_id=customer.id,
content=content,
attachments=attachment_data,
)
logger.info(
f"[SHOP_API] Message sent in conversation {conversation_id}",
extra={
"message_id": message.id,
"customer_id": customer.id,
"vendor_id": vendor.id,
},
)
return SendMessageResponse(
success=True,
message=MessageResponse(
id=message.id,
content=message.content,
sender_type=message.sender_type.value,
sender_id=message.sender_id,
sender_name=_get_sender_name(message),
is_system_message=message.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in message.attachments
],
created_at=message.created_at,
),
)
@router.put("/messages/{conversation_id}/read", response_model=dict)
def mark_as_read(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""Mark conversation as read."""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
return {"success": True}
@router.get("/messages/{conversation_id}/attachments/{attachment_id}")
async def download_attachment(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download a message attachment.
Validates that customer has access to the conversation.
"""
from fastapi.responses import FileResponse
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Find attachment
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment:
raise AttachmentNotFoundException(attachment_id)
return FileResponse(
path=attachment.file_path,
filename=attachment.original_filename,
media_type=attachment.mime_type,
)
@router.get("/messages/{conversation_id}/attachments/{attachment_id}/thumbnail")
async def get_attachment_thumbnail(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get thumbnail for an image attachment.
Validates that customer has access to the conversation.
"""
from fastapi.responses import FileResponse
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Find attachment
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment or not attachment.thumbnail_path:
raise AttachmentNotFoundException(f"{attachment_id}/thumbnail")
return FileResponse(
path=attachment.thumbnail_path,
media_type="image/jpeg",
)
# ============================================================================
# Helper Functions
# ============================================================================
def _get_other_participant_name(conversation, customer_id: int) -> str:
"""Get the name of the other participant (the vendor user)."""
for participant in conversation.participants:
if participant.participant_type == ParticipantType.VENDOR:
# Get vendor user name
from models.database.user import User
user = (
User.query.filter_by(id=participant.participant_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
return "Shop Support"
def _get_sender_name(message) -> str:
"""Get sender name for a message."""
if message.sender_type == ParticipantType.CUSTOMER:
from models.database.customer import Customer
customer = (
Customer.query.filter_by(id=message.sender_id).first()
if hasattr(Customer, "query")
else None
)
if customer:
return f"{customer.first_name} {customer.last_name}"
return "Customer"
elif message.sender_type == ParticipantType.VENDOR:
from models.database.user import User
user = (
User.query.filter_by(id=message.sender_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
elif message.sender_type == ParticipantType.ADMIN:
return "Platform Support"
return "Unknown"

View File

@@ -1,330 +0,0 @@
# app/api/v1/shop/orders.py
"""
Shop Orders API (Customer authenticated)
Endpoints for managing customer orders in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication - get_current_customer_api validates
that customer token vendor_id matches the URL vendor.
Customer Context: get_current_customer_api returns Customer directly
(not User), with vendor validation already performed.
"""
import logging
from datetime import UTC, datetime
from pathlib import Path as FilePath
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.exceptions.invoice import InvoicePDFNotFoundException
from app.services.cart_service import cart_service
from app.services.email_service import EmailService
from app.services.invoice_service import invoice_service
from app.services.order_service import order_service
from app.utils.money import cents_to_euros
from models.database.customer import Customer
from models.schema.order import (
OrderCreate,
OrderDetailResponse,
OrderListResponse,
OrderResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/orders", response_model=OrderResponse) # authenticated
def place_order(
request: Request,
order_data: OrderCreate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Place a new order for current vendor.
Vendor is automatically determined from request context.
Customer must be authenticated to place an order.
Creates an order from the customer's cart.
Request Body:
- Order data including shipping address, payment method, etc.
"""
# Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] place_order for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
},
)
# Create order
order = order_service.create_order(
db=db, vendor_id=vendor.id, order_data=order_data
)
db.commit()
logger.info(
f"Order {order.order_number} placed for vendor {vendor.subdomain}, "
f"total: €{order.total_amount:.2f}",
extra={
"order_id": order.id,
"order_number": order.order_number,
"customer_id": customer.id,
"total_amount": float(order.total_amount),
},
)
# Update customer stats
customer.total_orders = (customer.total_orders or 0) + 1
customer.total_spent = (customer.total_spent or 0) + order.total_amount
customer.last_order_date = datetime.now(UTC)
db.flush()
logger.debug(
f"Updated customer stats: total_orders={customer.total_orders}, "
f"total_spent={customer.total_spent}"
)
# Clear cart (get session_id from request cookies or headers)
session_id = request.cookies.get("cart_session_id") or request.headers.get(
"X-Cart-Session-Id"
)
if session_id:
try:
cart_service.clear_cart(db, vendor.id, session_id)
logger.debug(f"Cleared cart for session {session_id}")
except Exception as e:
logger.warning(f"Failed to clear cart: {e}")
# Send order confirmation email
try:
email_service = EmailService(db)
email_service.send_template(
template_code="order_confirmation",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"order_number": order.order_number,
"order_total": f"{order.total_amount:.2f}",
"order_items_count": len(order.items),
"order_date": order.order_date.strftime("%d.%m.%Y")
if order.order_date
else "",
"shipping_address": f"{order.ship_address_line_1}, {order.ship_postal_code} {order.ship_city}",
},
vendor_id=vendor.id,
related_type="order",
related_id=order.id,
)
logger.info(f"Sent order confirmation email to {customer.email}")
except Exception as e:
logger.warning(f"Failed to send order confirmation email: {e}")
return OrderResponse.model_validate(order)
@router.get("/orders", response_model=OrderListResponse)
def get_my_orders(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get order history for authenticated customer.
Vendor is automatically determined from request context.
Returns all orders placed by the authenticated customer.
Query Parameters:
- skip: Number of orders to skip (pagination)
- limit: Maximum number of orders to return
"""
# Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_my_orders for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"skip": skip,
"limit": limit,
},
)
# 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("/orders/{order_id}", response_model=OrderDetailResponse)
def get_order_details(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get detailed order information for authenticated customer.
Vendor is automatically determined from request context.
Customer can only view their own orders.
Path Parameters:
- order_id: ID of the order to retrieve
"""
# Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_order_details: order {order_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"order_id": order_id,
},
)
# 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)
@router.get("/orders/{order_id}/invoice")
def download_order_invoice(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download invoice PDF for a customer's order.
Vendor is automatically determined from request context.
Customer can only download invoices for their own orders.
Invoice is auto-generated if it doesn't exist.
Path Parameters:
- order_id: ID of the order to get invoice for
"""
from app.exceptions import OrderNotFoundException
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] download_order_invoice: order {order_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"order_id": order_id,
},
)
# 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:
raise OrderNotFoundException(str(order_id))
# Only allow invoice download for orders that are at least processing
allowed_statuses = ["processing", "partially_shipped", "shipped", "delivered", "completed"]
if order.status not in allowed_statuses:
from app.exceptions import ValidationException
raise ValidationException("Invoice not available for pending orders")
# Check if invoice exists for this order (via service layer)
invoice = invoice_service.get_invoice_by_order_id(
db=db, vendor_id=vendor.id, order_id=order_id
)
# Create invoice if it doesn't exist
if not invoice:
logger.info(f"Creating invoice for order {order_id} (customer download)")
invoice = invoice_service.create_invoice_from_order(
db=db,
vendor_id=vendor.id,
order_id=order_id,
)
db.commit()
# Get or generate PDF
pdf_path = invoice_service.get_pdf_path(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
if not pdf_path:
# Generate PDF
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
# Verify file exists
if not FilePath(pdf_path).exists():
raise InvoicePDFNotFoundException(invoice.id)
filename = f"invoice-{invoice.invoice_number}.pdf"
logger.info(
f"Customer {customer.id} downloading invoice {invoice.invoice_number} for order {order.order_number}"
)
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)

View File

@@ -1,171 +0,0 @@
# app/api/v1/shop/products.py
"""
Shop Product Catalog API (Public)
Public endpoints for browsing product catalog in shop frontend.
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, Path, Query, Request
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,
ProductResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/products", response_model=ProductListResponse) # public
def get_product_catalog(
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 (URL/subdomain/domain).
Only returns active products visible to customers.
No authentication required.
Query Parameters:
- skip: Number of products to skip (pagination)
- limit: Maximum number of products to return
- search: Search query for product name/description
- is_featured: Filter by featured products only
"""
logger.debug(
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"skip": skip,
"limit": limit,
"search": search,
"is_featured": is_featured,
},
)
# 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("/products/{product_id}", response_model=ProductDetailResponse) # public
def get_product_details(
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 (URL/subdomain/domain).
No authentication required.
Path Parameters:
- product_id: ID of the product to retrieve
"""
logger.debug(
f"[SHOP_API] get_product_details for product {product_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"product_id": product_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("/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, SKUs, brands, and GTINs.
Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required.
Query Parameters:
- q: Search query string (minimum 1 character)
- skip: Number of results to skip (pagination)
- limit: Maximum number of results to return
"""
# Get preferred language from request (via middleware or default)
language = getattr(request.state, "language", "en")
logger.debug(
f"[SHOP_API] search_products: '{q}'",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"query": q,
"skip": skip,
"limit": limit,
"language": language,
},
)
# Search products using the service
products, total = product_service.search_products(
db=db,
vendor_id=vendor.id,
query=q,
skip=skip,
limit=limit,
language=language,
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit,
)

View File

@@ -1,161 +0,0 @@
# app/api/v1/shop/profile.py
"""
Shop Profile API (Customer authenticated)
Endpoints for managing customer profile in shop frontend.
Requires customer authentication.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import ValidationException
from app.services.auth_service import AuthService
from app.services.customer_service import customer_service
from models.database.customer import Customer
from models.schema.customer import (
CustomerPasswordChange,
CustomerResponse,
CustomerUpdate,
)
# Auth service for password operations
auth_service = AuthService()
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/profile", response_model=CustomerResponse) # authenticated
def get_profile(
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get current customer profile.
Returns the authenticated customer's profile information.
"""
logger.debug(
f"[SHOP_API] get_profile for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile", response_model=CustomerResponse)
def update_profile(
update_data: CustomerUpdate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update current customer profile.
Allows updating profile fields like name, phone, marketing consent, etc.
Email changes require the new email to be unique within the vendor.
Request Body:
- email: New email address (optional)
- first_name: First name (optional)
- last_name: Last name (optional)
- phone: Phone number (optional)
- marketing_consent: Marketing consent (optional)
- preferred_language: Preferred language (optional)
"""
logger.debug(
f"[SHOP_API] update_profile for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
"update_fields": [k for k, v in update_data.model_dump().items() if v is not None],
},
)
# If email is being changed, check uniqueness within vendor
if update_data.email and update_data.email != customer.email:
existing = customer_service.get_customer_by_email(
db, customer.vendor_id, update_data.email
)
if existing and existing.id != customer.id:
raise ValidationException("Email already in use")
# Update only provided fields
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if value is not None:
setattr(customer, field, value)
db.commit()
db.refresh(customer)
logger.info(
f"Customer {customer.id} updated profile",
extra={
"customer_id": customer.id,
"updated_fields": list(update_dict.keys()),
},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile/password", response_model=dict)
def change_password(
password_data: CustomerPasswordChange,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Change customer password.
Requires current password verification and matching new password confirmation.
Request Body:
- current_password: Current password
- new_password: New password (min 8 chars, must contain letter and digit)
- confirm_password: Confirmation of new password
"""
logger.debug(
f"[SHOP_API] change_password for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
# Verify current password
if not auth_service.auth_manager.verify_password(
password_data.current_password, customer.hashed_password
):
raise ValidationException("Current password is incorrect")
# Verify passwords match
if password_data.new_password != password_data.confirm_password:
raise ValidationException("New passwords do not match")
# Check new password is different
if password_data.new_password == password_data.current_password:
raise ValidationException("New password must be different from current password")
# Update password
customer.hashed_password = auth_service.hash_password(password_data.new_password)
db.commit()
logger.info(
f"Customer {customer.id} changed password",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
return {"message": "Password changed successfully"}

View File

@@ -1,453 +1,13 @@
# app/services/cart_service.py
"""
Shopping cart service.
LEGACY LOCATION - This file re-exports from the canonical module location.
This module provides:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
Canonical location: app/modules/cart/services/
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
This file exists for backward compatibility. New code should import from:
from app.modules.cart.services import cart_service
"""
import logging
from app.modules.cart.services.cart_service import cart_service, CartService
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.exceptions import (
CartItemNotFoundException,
InsufficientInventoryForCartException,
InvalidCartQuantityException,
ProductNotFoundException,
)
from app.utils.money import cents_to_euros
from models.database.cart import CartItem
from models.database.product import Product
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.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
Returns:
Cart data with items and totals
"""
logger.info(
"[CART_SERVICE] get_cart called",
extra={
"vendor_id": vendor_id,
"session_id": session_id,
},
)
# Fetch cart items from database
cart_items = (
db.query(CartItem)
.filter(
and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id)
)
.all()
)
logger.info(
f"[CART_SERVICE] Found {len(cart_items)} items in database",
extra={"item_count": len(cart_items)},
)
# Build response - calculate totals in cents, return euros
items = []
subtotal_cents = 0
for cart_item in cart_items:
product = cart_item.product
line_total_cents = cart_item.line_total_cents
items.append(
{
"product_id": product.id,
"product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else str(product.id),
"quantity": cart_item.quantity,
"price": cart_item.price_at_add, # Returns euros via property
"line_total": cents_to_euros(line_total_cents),
"image_url": (
product.marketplace_product.image_link
if product.marketplace_product
else None
),
}
)
subtotal_cents += line_total_cents
# Convert to euros for API response
subtotal = cents_to_euros(subtotal_cents)
cart_data = {
"vendor_id": vendor_id,
"session_id": session_id,
"items": items,
"subtotal": subtotal,
"total": subtotal, # Could add tax/shipping later
}
logger.info(
f"[CART_SERVICE] get_cart returning: {len(cart_data['items'])} items, total: {cart_data['total']}",
extra={"cart": cart_data},
)
return cart_data
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
"""
logger.info(
"[CART_SERVICE] add_to_cart called",
extra={
"vendor_id": vendor_id,
"session_id": session_id,
"product_id": product_id,
"quantity": quantity,
},
)
# 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:
logger.error(
"[CART_SERVICE] Product not found",
extra={"product_id": product_id, "vendor_id": vendor_id},
)
raise ProductNotFoundException(product_id=product_id, vendor_id=vendor_id)
logger.info(
f"[CART_SERVICE] Product found: {product.marketplace_product.title}",
extra={
"product_id": product_id,
"product_name": product.marketplace_product.title,
"available_inventory": product.available_inventory,
},
)
# Get current price in cents (use sale_price if available, otherwise regular price)
current_price_cents = (
product.sale_price_cents
or product.price_cents
or 0
)
# Check if item already exists in cart
existing_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if existing_item:
# Update quantity
new_quantity = existing_item.quantity + quantity
# Check inventory for new total quantity
if product.available_inventory < new_quantity:
logger.warning(
"[CART_SERVICE] Insufficient inventory for update",
extra={
"product_id": product_id,
"current_in_cart": existing_item.quantity,
"adding": quantity,
"requested_total": new_quantity,
"available": product.available_inventory,
},
)
raise InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=new_quantity,
available=product.available_inventory,
)
existing_item.quantity = new_quantity
db.flush()
db.refresh(existing_item)
logger.info(
"[CART_SERVICE] Updated existing cart item",
extra={"cart_item_id": existing_item.id, "new_quantity": new_quantity},
)
return {
"message": "Product quantity updated in cart",
"product_id": product_id,
"quantity": new_quantity,
}
# Check inventory for new item
if product.available_inventory < quantity:
logger.warning(
"[CART_SERVICE] Insufficient inventory",
extra={
"product_id": product_id,
"requested": quantity,
"available": product.available_inventory,
},
)
raise InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=quantity,
available=product.available_inventory,
)
# Create new cart item (price stored in cents)
cart_item = CartItem(
vendor_id=vendor_id,
session_id=session_id,
product_id=product_id,
quantity=quantity,
price_at_add_cents=current_price_cents,
)
db.add(cart_item)
db.flush()
db.refresh(cart_item)
logger.info(
"[CART_SERVICE] Created new cart item",
extra={
"cart_item_id": cart_item.id,
"quantity": quantity,
"price_cents": current_price_cents,
},
)
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.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
quantity: New quantity (must be >= 1)
Returns:
Success message
Raises:
ValidationException: If quantity < 1
ProductNotFoundException: If product not found
InsufficientInventoryException: If not enough inventory
"""
if quantity < 1:
raise InvalidCartQuantityException(quantity=quantity, min_quantity=1)
# Find cart item
cart_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if not cart_item:
raise CartItemNotFoundException(
product_id=product_id, session_id=session_id
)
# Verify product still exists and is active
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 InsufficientInventoryForCartException(
product_id=product_id,
product_name=product.marketplace_product.title,
requested=quantity,
available=product.available_inventory,
)
# Update quantity
cart_item.quantity = quantity
db.flush()
db.refresh(cart_item)
logger.info(
"[CART_SERVICE] Updated cart item quantity",
extra={
"cart_item_id": cart_item.id,
"product_id": product_id,
"new_quantity": 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.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
Returns:
Success message
Raises:
ProductNotFoundException: If product not in cart
"""
# Find and delete cart item
cart_item = (
db.query(CartItem)
.filter(
and_(
CartItem.vendor_id == vendor_id,
CartItem.session_id == session_id,
CartItem.product_id == product_id,
)
)
.first()
)
if not cart_item:
raise CartItemNotFoundException(
product_id=product_id, session_id=session_id
)
db.delete(cart_item)
logger.info(
"[CART_SERVICE] Removed item from cart",
extra={
"cart_item_id": cart_item.id,
"product_id": product_id,
"session_id": 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.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
Returns:
Success message with count of items removed
"""
# Delete all cart items for this session
deleted_count = (
db.query(CartItem)
.filter(
and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id)
)
.delete()
)
logger.info(
"[CART_SERVICE] Cleared cart",
extra={
"session_id": session_id,
"vendor_id": vendor_id,
"items_removed": deleted_count,
},
)
return {"message": "Cart cleared", "items_removed": deleted_count}
# Create service instance
cart_service = CartService()
__all__ = ["cart_service", "CartService"]

View File

@@ -315,7 +315,7 @@ After migrated to `app/modules/cart/services/cart_service.py`.
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE
4. **Phase 4** - Move routes to modules ✅ COMPLETE
5. **Phase 5** - Fix direct model imports ✅ COMPLETE
6. **Phase 6** - Delete legacy files
6. **Phase 6** - Delete legacy files ✅ COMPLETE
7. **Phase 7** - Update documentation
---

View File

@@ -1,78 +1,13 @@
# models/database/cart.py
"""Cart item database model.
"""
LEGACY LOCATION - This file re-exports from the canonical module location.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
Canonical location: app/modules/cart/models/
This file exists for backward compatibility. New code should import from:
from app.modules.cart.models import CartItem
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.modules.cart.models.cart import CartItem
from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
class CartItem(Base, TimestampMixin):
"""
Shopping cart items.
Stores cart items per session, vendor, and product.
Sessions are identified by a session_id string (from browser cookies).
Price is stored as integer cents for precision.
"""
__tablename__ = "cart_items"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
session_id = Column(String(255), nullable=False, index=True)
# Cart details
quantity = Column(Integer, nullable=False, default=1)
price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added
# Relationships
vendor = relationship("Vendor")
product = relationship("Product")
# Constraints
__table_args__ = (
UniqueConstraint("vendor_id", "session_id", "product_id", name="uq_cart_item"),
Index("idx_cart_session", "vendor_id", "session_id"),
Index("idx_cart_created", "created_at"), # For cleanup of old carts
)
def __repr__(self):
return f"<CartItem(id={self.id}, session='{self.session_id}', product_id={self.product_id}, qty={self.quantity})>"
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def price_at_add(self) -> float:
"""Get price at add in euros."""
return cents_to_euros(self.price_at_add_cents)
@price_at_add.setter
def price_at_add(self, value: float):
"""Set price at add from euros."""
self.price_at_add_cents = euros_to_cents(value)
@property
def line_total_cents(self) -> int:
"""Calculate line total in cents."""
return self.price_at_add_cents * self.quantity
@property
def line_total(self) -> float:
"""Calculate line total in euros."""
return cents_to_euros(self.line_total_cents)
__all__ = ["CartItem"]

View File

@@ -1,91 +1,27 @@
# models/schema/cart.py
"""
Pydantic schemas for shopping cart operations.
LEGACY LOCATION - This file re-exports from the canonical module location.
Canonical location: app/modules/cart/schemas/
This file exists for backward compatibility. New code should import from:
from app.modules.cart.schemas import CartResponse, AddToCartRequest
"""
from pydantic import BaseModel, ConfigDict, Field
from app.modules.cart.schemas.cart import (
AddToCartRequest,
UpdateCartItemRequest,
CartItemResponse,
CartResponse,
CartOperationResponse,
ClearCartResponse,
)
# ============================================================================
# Request Schemas
# ============================================================================
class AddToCartRequest(BaseModel):
"""Request model for adding items to cart."""
product_id: int = Field(..., description="Product ID to add", gt=0)
quantity: int = Field(1, ge=1, description="Quantity to add")
class UpdateCartItemRequest(BaseModel):
"""Request model for updating cart item quantity."""
quantity: int = Field(..., ge=1, description="New quantity (must be >= 1)")
# ============================================================================
# Response Schemas
# ============================================================================
class CartItemResponse(BaseModel):
"""Response model for a single cart item."""
model_config = ConfigDict(from_attributes=True)
product_id: int = Field(..., description="Product ID")
product_name: str = Field(..., description="Product name")
quantity: int = Field(..., description="Quantity in cart")
price: float = Field(..., description="Price per unit when added to cart")
line_total: float = Field(
..., description="Total price for this line (price * quantity)"
)
image_url: str | None = Field(None, description="Product image URL")
class CartResponse(BaseModel):
"""Response model for shopping cart."""
vendor_id: int = Field(..., description="Vendor ID")
session_id: str = Field(..., description="Shopping session ID")
items: list[CartItemResponse] = Field(
default_factory=list, description="Cart items"
)
subtotal: float = Field(..., description="Subtotal of all items")
total: float = Field(..., description="Total amount (currently same as subtotal)")
item_count: int = Field(..., description="Total number of items in cart")
@classmethod
def from_service_dict(cls, cart_dict: dict) -> "CartResponse":
"""
Create CartResponse from service layer dictionary.
This is a convenience method to convert the dictionary format
returned by cart_service into a proper Pydantic model.
"""
items = [CartItemResponse(**item) for item in cart_dict.get("items", [])]
return cls(
vendor_id=cart_dict["vendor_id"],
session_id=cart_dict["session_id"],
items=items,
subtotal=cart_dict["subtotal"],
total=cart_dict["total"],
item_count=len(items),
)
class CartOperationResponse(BaseModel):
"""Response model for cart operations (add, update, remove)."""
message: str = Field(..., description="Operation result message")
product_id: int = Field(..., description="Product ID affected")
quantity: int | None = Field(
None, description="New quantity (for add/update operations)"
)
class ClearCartResponse(BaseModel):
"""Response model for clearing cart."""
message: str = Field(..., description="Operation result message")
items_removed: int = Field(..., description="Number of items removed from cart")
__all__ = [
"AddToCartRequest",
"UpdateCartItemRequest",
"CartItemResponse",
"CartResponse",
"CartOperationResponse",
"ClearCartResponse",
]