refactor: enforce strict architecture rules and add Pydantic response models

- Update architecture rules to be stricter (API-003 now blocks ALL exception
  raising in endpoints, not just HTTPException)
- Update get_current_vendor_api dependency to guarantee token_vendor_id presence
- Remove redundant _get_vendor_from_token helpers from all vendor API files
- Move vendor access validation to service layer methods
- Add Pydantic response models for media, notification, and payment endpoints
- Add get_active_vendor_by_code service method for public vendor lookup
- Add get_import_job_for_vendor service method with vendor validation
- Update validation script to detect exception raising patterns in endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 23:26:03 +01:00
parent cbfbbb4654
commit 81bfc49f77
25 changed files with 1225 additions and 530 deletions

View File

@@ -75,17 +75,42 @@ api_endpoint_rules:
# NOTE: db.commit() is intentionally NOT listed - it's allowed for transaction control
- id: "API-003"
name: "Endpoint must NOT raise HTTPException directly"
name: "Endpoint must NOT raise ANY exceptions directly"
severity: "error"
description: |
API endpoints should NOT raise HTTPException directly. Instead, let domain
exceptions bubble up to the global exception handler which converts them
to appropriate HTTP responses. This ensures consistent error formatting
and centralized error handling.
API endpoints should NOT raise exceptions directly. Endpoints are a thin
orchestration layer that:
1. Accepts request parameters (validated by Pydantic)
2. Calls dependencies for authentication/authorization (deps.py raises exceptions)
3. Calls services for business logic (services raise domain exceptions)
4. Returns response (formatted by Pydantic)
Exception raising belongs in:
- Dependencies (app/api/deps.py) - authentication/authorization validation
- Services (app/services/) - business logic validation
The global exception handler catches all WizamartException subclasses and
converts them to appropriate HTTP responses.
WRONG (endpoint raises exception):
@router.get("/orders")
def get_orders(current_user: User = Depends(get_current_vendor_api)):
if not hasattr(current_user, "token_vendor_id"): # ❌ Redundant check
raise InvalidTokenException("...") # ❌ Don't raise here
return order_service.get_orders(db, current_user.token_vendor_id)
RIGHT (dependency guarantees, endpoint trusts):
@router.get("/orders")
def get_orders(current_user: User = Depends(get_current_vendor_api)):
# Dependency guarantees token_vendor_id is present
return order_service.get_orders(db, current_user.token_vendor_id)
pattern:
file_pattern: "app/api/v1/**/*.py"
anti_patterns:
- "raise HTTPException"
- "raise InvalidTokenException"
- "raise InsufficientPermissionsException"
- "if not hasattr\\(current_user.*token_vendor"
exceptions:
- "app/exceptions/handler.py" # Handler is allowed to use HTTPException

View File

@@ -275,7 +275,9 @@ def get_current_vendor_api(
Get current vendor user from Authorization header ONLY.
Used for vendor API endpoints that should not accept cookies.
Validates that user still has access to the vendor specified in the token.
Validates that:
1. Token contains vendor context (token_vendor_id)
2. User still has access to the vendor specified in the token
Args:
credentials: Bearer token from Authorization header
@@ -285,7 +287,7 @@ def get_current_vendor_api(
User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role)
Raises:
InvalidTokenException: If no token or invalid token
InvalidTokenException: If no token, invalid token, or missing vendor context
InsufficientPermissionsException: If user is not vendor or lost access to vendor
"""
if not credentials:
@@ -302,23 +304,25 @@ def get_current_vendor_api(
logger.warning(f"Non-vendor user {user.username} attempted vendor API")
raise InsufficientPermissionsException("Vendor privileges required")
# Validate vendor access if token is vendor-scoped
if hasattr(user, "token_vendor_id"):
vendor_id = user.token_vendor_id
# Require vendor context in token
if not hasattr(user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
# Verify user still has access to this vendor
if not user.is_member_of(vendor_id):
logger.warning(
f"User {user.username} lost access to vendor_id={vendor_id}"
)
raise InsufficientPermissionsException(
"Access to vendor has been revoked. Please login again."
)
vendor_id = user.token_vendor_id
logger.debug(
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
# Verify user still has access to this vendor
if not user.is_member_of(vendor_id):
logger.warning(
f"User {user.username} lost access to vendor_id={vendor_id}"
)
raise InsufficientPermissionsException(
"Access to vendor has been revoked. Please login again."
)
logger.debug(
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
)
return user

View File

@@ -21,7 +21,11 @@ from models.schema.admin import (
PlatformAlertCreate,
PlatformAlertListResponse,
PlatformAlertResolve,
PlatformAlertResponse,
)
from models.schema.notification import (
AlertStatisticsResponse,
MessageResponse,
UnreadCountResponse,
)
router = APIRouter(prefix="/notifications")
@@ -49,17 +53,17 @@ def get_notifications(
)
@router.get("/unread-count")
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get count of unread notifications."""
# TODO: Implement
return {"unread_count": 0}
return UnreadCountResponse(unread_count=0)
@router.put("/{notification_id}/read")
@router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
@@ -67,17 +71,17 @@ def mark_as_read(
):
"""Mark notification as read."""
# TODO: Implement
return {"message": "Notification marked as read"}
return MessageResponse(message="Notification marked as read")
@router.put("/mark-all-read")
@router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Mark all notifications as read."""
# TODO: Implement
return {"message": "All notifications marked as read"}
return MessageResponse(message="All notifications marked as read")
# ============================================================================
@@ -101,19 +105,19 @@ def get_platform_alerts(
)
@router.post("/alerts", response_model=PlatformAlertResponse)
@router.post("/alerts", response_model=MessageResponse)
def create_platform_alert(
alert_data: PlatformAlertCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Create new platform alert (manual)."""
# TODO: Implement
# TODO: Implement - return PlatformAlertResponse when service is ready
logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}")
return {}
return MessageResponse(message="Platform alert creation coming soon")
@router.put("/alerts/{alert_id}/resolve")
@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
def resolve_platform_alert(
alert_id: int,
resolve_data: PlatformAlertResolve,
@@ -123,19 +127,19 @@ def resolve_platform_alert(
"""Resolve platform alert."""
# TODO: Implement
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
return {"message": "Alert resolved successfully"}
return MessageResponse(message="Alert resolved successfully")
@router.get("/alerts/stats")
@router.get("/alerts/stats", response_model=AlertStatisticsResponse)
def get_alert_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get alert statistics for dashboard."""
# TODO: Implement
return {
"total_alerts": 0,
"active_alerts": 0,
"critical_alerts": 0,
"resolved_today": 0,
}
return AlertStatisticsResponse(
total_alerts=0,
active_alerts=0,
critical_alerts=0,
resolved_today=0,
)

View File

@@ -2,7 +2,8 @@
"""
Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.stats_service import stats_service
from models.database.user import User
@@ -20,13 +20,6 @@ router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.get("")
def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
@@ -34,5 +27,4 @@ def get_vendor_analytics(
db: Session = Depends(get_db),
):
"""Get vendor analytics data for specified time period."""
vendor_id = _get_vendor_id_from_token(current_user)
return stats_service.get_vendor_analytics(db, vendor_id, period)
return stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)

View File

@@ -1,9 +1,9 @@
# Vendor customer management
# app/api/v1/vendor/customers.py
"""
Vendor customer management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
@@ -21,13 +20,6 @@ router = APIRouter(prefix="/customers")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
def get_vendor_customers(
skip: int = Query(0, ge=0),
@@ -46,7 +38,7 @@ def get_vendor_customers(
- Support filtering by active status
- Return paginated results
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {
"customers": [],
"total": 0,
@@ -71,7 +63,7 @@ def get_customer_details(
- Include order history
- Include total spent, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {"message": "Customer details coming in Slice 4"}
@@ -89,7 +81,7 @@ def get_customer_orders(
- Filter by vendor_id
- Return order details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {"orders": [], "message": "Customer orders coming in Slice 5"}
@@ -108,7 +100,7 @@ def update_customer(
- Verify customer belongs to vendor
- Update customer preferences
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {"message": "Customer update coming in Slice 4"}
@@ -126,7 +118,7 @@ def toggle_customer_status(
- Verify customer belongs to vendor
- Log the change
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {"message": "Customer status toggle coming in Slice 4"}
@@ -145,7 +137,7 @@ def get_customer_statistics(
- Average order value
- Last order date
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return {
"total_orders": 0,
"total_spent": 0.0,

View File

@@ -1,6 +1,9 @@
# app/api/v1/vendor/dashboard.py
"""
Vendor dashboard and statistics endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -10,7 +13,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, VendorNotActiveException
from app.exceptions import VendorNotActiveException
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from models.database.user import User
@@ -37,10 +40,6 @@ def get_vendor_dashboard_stats(
Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
# Get vendor ID from token (set by get_current_vendor_api)
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Get vendor object (raises VendorNotFoundException if not found)

View File

@@ -10,48 +10,16 @@ This module provides:
import logging
from fastapi import APIRouter, Depends, Path
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from models.database.vendor import Vendor
from app.services.vendor_service import vendor_service
from models.schema.vendor import VendorDetailResponse
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_vendor_by_code(db: Session, vendor_code: str) -> Vendor:
"""
Helper to get active vendor by vendor_code.
Args:
db: Database session
vendor_code: Vendor code (case-insensitive)
Returns:
Vendor object
Raises:
VendorNotFoundException: If vendor not found or inactive
"""
vendor = (
db.query(Vendor)
.filter(
func.upper(Vendor.vendor_code) == vendor_code.upper(),
Vendor.is_active == True,
)
.first()
)
if not vendor:
logger.warning(f"Vendor not found or inactive: {vendor_code}")
raise VendorNotFoundException(vendor_code, identifier_type="code")
return vendor
@router.get("/{vendor_code}", response_model=VendorDetailResponse)
def get_vendor_info(
vendor_code: str = Path(..., description="Vendor code"),
@@ -81,7 +49,7 @@ def get_vendor_info(
"""
logger.info(f"Public vendor info request: {vendor_code}")
vendor = _get_vendor_by_code(db, vendor_code)
vendor = vendor_service.get_active_vendor_by_code(db, vendor_code)
logger.info(f"Vendor info retrieved: {vendor.name} ({vendor.vendor_code})")

View File

@@ -2,7 +2,8 @@
"""
Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -11,7 +12,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.inventory_service import inventory_service
from models.database.user import User
from models.schema.inventory import (
@@ -28,13 +28,6 @@ router = APIRouter()
logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory(
inventory: InventoryCreate,
@@ -42,8 +35,7 @@ def set_inventory(
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.set_inventory(db, vendor_id, inventory)
return inventory_service.set_inventory(db, current_user.token_vendor_id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse)
@@ -53,8 +45,7 @@ def adjust_inventory(
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
return inventory_service.adjust_inventory(db, current_user.token_vendor_id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse)
@@ -64,8 +55,7 @@ def reserve_inventory(
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.reserve_inventory(db, vendor_id, reservation)
return inventory_service.reserve_inventory(db, current_user.token_vendor_id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse)
@@ -75,8 +65,7 @@ def release_reservation(
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.release_reservation(db, vendor_id, reservation)
return inventory_service.release_reservation(db, current_user.token_vendor_id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse)
@@ -86,8 +75,7 @@ def fulfill_reservation(
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
return inventory_service.fulfill_reservation(db, current_user.token_vendor_id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
@@ -97,8 +85,7 @@ def get_product_inventory(
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.get_product_inventory(db, vendor_id, product_id)
return inventory_service.get_product_inventory(db, current_user.token_vendor_id, product_id)
@router.get("/inventory", response_model=InventoryListResponse)
@@ -111,9 +98,8 @@ def get_vendor_inventory(
db: Session = Depends(get_db),
):
"""Get all inventory for vendor."""
vendor_id = _get_vendor_id_from_token(current_user)
inventories = inventory_service.get_vendor_inventory(
db, vendor_id, skip, limit, location, low_stock
db, current_user.token_vendor_id, skip, limit, location, low_stock
)
# Get total count
@@ -132,9 +118,8 @@ def update_inventory(
db: Session = Depends(get_db),
):
"""Update inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.update_inventory(
db, vendor_id, inventory_id, inventory_update
db, current_user.token_vendor_id, inventory_id, inventory_update
)
@@ -145,6 +130,5 @@ def delete_inventory(
db: Session = Depends(get_db),
):
"""Delete inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
inventory_service.delete_inventory(db, vendor_id, inventory_id)
inventory_service.delete_inventory(db, current_user.token_vendor_id, inventory_id)
return {"message": "Inventory deleted successfully"}

View File

@@ -2,7 +2,8 @@
"""
Marketplace import endpoints for vendors.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import
@@ -27,13 +27,6 @@ router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
@@ -43,7 +36,7 @@ async def import_products_from_marketplace(
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
@@ -90,13 +83,10 @@ def get_marketplace_import_status(
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
# Verify job belongs to current vendor
if job.vendor_id != vendor.id:
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
# Service validates that job belongs to vendor and raises UnauthorizedVendorAccessException if not
job = marketplace_import_job_service.get_import_job_for_vendor(
db, job_id, current_user.token_vendor_id
)
return marketplace_import_job_service.convert_to_response_model(job)
@@ -110,7 +100,7 @@ def get_marketplace_import_jobs(
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
jobs = marketplace_import_job_service.get_import_jobs(
db=db,

View File

@@ -1,9 +1,9 @@
# File and media management
# app/api/v1/vendor/media.py
"""
Vendor media and file management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -13,22 +13,23 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.schema.media import (
MediaDetailResponse,
MediaListResponse,
MediaMetadataUpdate,
MediaUploadResponse,
MediaUsageResponse,
MultipleUploadResponse,
OptimizationResultResponse,
)
router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
@router.get("", response_model=MediaListResponse)
def get_media_library(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
@@ -47,17 +48,17 @@ def get_media_library(
- Support pagination
- Return file URLs, sizes, metadata
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"media": [],
"total": 0,
"skip": skip,
"limit": limit,
"message": "Media library coming in Slice 3",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaListResponse(
media=[],
total=0,
skip=skip,
limit=limit,
message="Media library coming in Slice 3",
)
@router.post("/upload")
@router.post("/upload", response_model=MediaUploadResponse)
async def upload_media(
file: UploadFile = File(...),
folder: str | None = Query(None, description="products, general, etc."),
@@ -75,15 +76,15 @@ async def upload_media(
- Save metadata to database
- Return file URL
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"file_url": None,
"thumbnail_url": None,
"message": "Media upload coming in Slice 3",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaUploadResponse(
file_url=None,
thumbnail_url=None,
message="Media upload coming in Slice 3",
)
@router.post("/upload/multiple")
@router.post("/upload/multiple", response_model=MultipleUploadResponse)
async def upload_multiple_media(
files: list[UploadFile] = File(...),
folder: str | None = Query(None),
@@ -99,15 +100,15 @@ async def upload_multiple_media(
- Return list of uploaded file URLs
- Handle errors gracefully
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"uploaded_files": [],
"failed_files": [],
"message": "Multiple upload coming in Slice 3",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MultipleUploadResponse(
uploaded_files=[],
failed_files=[],
message="Multiple upload coming in Slice 3",
)
@router.get("/{media_id}")
@router.get("/{media_id}", response_model=MediaDetailResponse)
def get_media_details(
media_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -121,14 +122,14 @@ def get_media_details(
- Return file URL
- Return usage information (which products use this file)
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media details coming in Slice 3"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaDetailResponse(message="Media details coming in Slice 3")
@router.put("/{media_id}")
@router.put("/{media_id}", response_model=MediaDetailResponse)
def update_media_metadata(
media_id: int,
metadata: dict,
metadata: MediaMetadataUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -141,11 +142,11 @@ def update_media_metadata(
- Update tags/categories
- Update description
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media update coming in Slice 3"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaDetailResponse(message="Media update coming in Slice 3")
@router.delete("/{media_id}")
@router.delete("/{media_id}", response_model=MediaDetailResponse)
def delete_media(
media_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -161,11 +162,11 @@ def delete_media(
- Delete database record
- Return success/error
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media deletion coming in Slice 3"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaDetailResponse(message="Media deletion coming in Slice 3")
@router.get("/{media_id}/usage")
@router.get("/{media_id}/usage", response_model=MediaUsageResponse)
def get_media_usage(
media_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -179,15 +180,15 @@ def get_media_usage(
- Check other entities using this media
- Return list of usage
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"products": [],
"other_usage": [],
"message": "Media usage tracking coming in Slice 3",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MediaUsageResponse(
products=[],
other_usage=[],
message="Media usage tracking coming in Slice 3",
)
@router.post("/optimize/{media_id}")
@router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)
def optimize_media(
media_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -202,5 +203,5 @@ def optimize_media(
- Keep original
- Update database with new versions
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media optimization coming in Slice 3"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return OptimizationResultResponse(message="Media optimization coming in Slice 3")

View File

@@ -1,9 +1,9 @@
# Notification management
# app/api/v1/vendor/notifications.py
"""
Vendor notification management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -13,22 +13,24 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.schema.notification import (
MessageResponse,
NotificationListResponse,
NotificationSettingsResponse,
NotificationSettingsUpdate,
NotificationTemplateListResponse,
NotificationTemplateUpdate,
TestNotificationRequest,
UnreadCountResponse,
)
router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("")
@router.get("", response_model=NotificationListResponse)
def get_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
@@ -45,16 +47,16 @@ def get_notifications(
- Support pagination
- Return notification details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"notifications": [],
"total": 0,
"unread_count": 0,
"message": "Notifications coming in Slice 5",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationListResponse(
notifications=[],
total=0,
unread_count=0,
message="Notifications coming in Slice 5",
)
@router.get("/unread-count")
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -66,11 +68,11 @@ def get_unread_count(
- Count unread notifications for vendor
- Used for notification badge
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return UnreadCountResponse(unread_count=0, message="Unread count coming in Slice 5")
@router.put("/{notification_id}/read")
@router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -83,11 +85,11 @@ def mark_as_read(
- Mark single notification as read
- Update read timestamp
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark as read coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Mark as read coming in Slice 5")
@router.put("/mark-all-read")
@router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -99,11 +101,11 @@ def mark_all_as_read(
- Mark all vendor notifications as read
- Update timestamps
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark all as read coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Mark all as read coming in Slice 5")
@router.delete("/{notification_id}")
@router.delete("/{notification_id}", response_model=MessageResponse)
def delete_notification(
notification_id: int,
current_user: User = Depends(get_current_vendor_api),
@@ -116,11 +118,11 @@ def delete_notification(
- Delete single notification
- Verify notification belongs to vendor
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification deletion coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Notification deletion coming in Slice 5")
@router.get("/settings")
@router.get("/settings", response_model=NotificationSettingsResponse)
def get_notification_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -133,18 +135,18 @@ def get_notification_settings(
- Get in-app notification settings
- Get notification types enabled/disabled
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"email_notifications": True,
"in_app_notifications": True,
"notification_types": {},
"message": "Notification settings coming in Slice 5",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationSettingsResponse(
email_notifications=True,
in_app_notifications=True,
notification_types={},
message="Notification settings coming in Slice 5",
)
@router.put("/settings")
@router.put("/settings", response_model=MessageResponse)
def update_notification_settings(
settings: dict,
settings: NotificationSettingsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -156,11 +158,11 @@ def update_notification_settings(
- Update in-app notification settings
- Enable/disable specific notification types
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification settings update coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Notification settings update coming in Slice 5")
@router.get("/templates")
@router.get("/templates", response_model=NotificationTemplateListResponse)
def get_notification_templates(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -173,14 +175,16 @@ def get_notification_templates(
- Include: order confirmation, shipping notification, etc.
- Return template details
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"templates": [], "message": "Notification templates coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationTemplateListResponse(
templates=[], message="Notification templates coming in Slice 5"
)
@router.put("/templates/{template_id}")
@router.put("/templates/{template_id}", response_model=MessageResponse)
def update_notification_template(
template_id: int,
template_data: dict,
template_data: NotificationTemplateUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -193,13 +197,13 @@ def update_notification_template(
- Validate template variables
- Preview template
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Template update coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Template update coming in Slice 5")
@router.post("/test")
@router.post("/test", response_model=MessageResponse)
def send_test_notification(
notification_data: dict,
notification_data: TestNotificationRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -211,5 +215,5 @@ def send_test_notification(
- Use specified template
- Send to current user's email
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Test notification coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Test notification coming in Slice 5")

View File

@@ -1,6 +1,9 @@
# app/api/v1/vendor/orders.py
"""
Vendor order management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -42,20 +45,9 @@ def get_vendor_orders(
Vendor is determined from JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
orders, total = order_service.get_vendor_orders(
db=db,
vendor_id=vendor_id,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
status=status,
@@ -81,18 +73,9 @@ def get_order_details(
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id)
order = order_service.get_order(
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
)
return OrderDetailResponse.model_validate(order)
@@ -117,19 +100,11 @@ def update_order_status(
Requires Authorization header (API endpoint).
"""
from fastapi import HTTPException
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
order = order_service.update_order_status(
db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
order_update=order_update,
)
logger.info(

View File

@@ -1,9 +1,9 @@
# Payment configuration and processing
# app/api/v1/vendor/payments.py
"""
Vendor payment configuration and processing endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -13,22 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.schema.payment import (
PaymentBalanceResponse,
PaymentConfigResponse,
PaymentConfigUpdate,
PaymentConfigUpdateResponse,
PaymentMethodsResponse,
RefundRequest,
RefundResponse,
StripeConnectRequest,
StripeConnectResponse,
StripeDisconnectResponse,
TransactionsResponse,
)
router = APIRouter(prefix="/payments")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("/config")
@router.get("/config", response_model=PaymentConfigResponse)
def get_payment_configuration(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -42,19 +47,19 @@ def get_payment_configuration(
- Get currency settings
- Return masked/secure information only
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"payment_gateway": None,
"accepted_methods": [],
"currency": "EUR",
"stripe_connected": False,
"message": "Payment configuration coming in Slice 5",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentConfigResponse(
payment_gateway=None,
accepted_methods=[],
currency="EUR",
stripe_connected=False,
message="Payment configuration coming in Slice 5",
)
@router.put("/config")
@router.put("/config", response_model=PaymentConfigUpdateResponse)
def update_payment_configuration(
payment_config: dict,
payment_config: PaymentConfigUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -67,13 +72,15 @@ def update_payment_configuration(
- Update accepted payment methods
- Validate configuration before saving
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment configuration update coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentConfigUpdateResponse(
message="Payment configuration update coming in Slice 5"
)
@router.post("/stripe/connect")
@router.post("/stripe/connect", response_model=StripeConnectResponse)
def connect_stripe_account(
stripe_data: dict,
stripe_data: StripeConnectRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -86,11 +93,11 @@ def connect_stripe_account(
- Verify Stripe account is active
- Enable payment processing
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe connection coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return StripeConnectResponse(message="Stripe connection coming in Slice 5")
@router.delete("/stripe/disconnect")
@router.delete("/stripe/disconnect", response_model=StripeDisconnectResponse)
def disconnect_stripe_account(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -103,11 +110,11 @@ def disconnect_stripe_account(
- Disable payment processing
- Warn about pending payments
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe disconnection coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return StripeDisconnectResponse(message="Stripe disconnection coming in Slice 5")
@router.get("/methods")
@router.get("/methods", response_model=PaymentMethodsResponse)
def get_payment_methods(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -119,11 +126,14 @@ def get_payment_methods(
- Return list of enabled payment methods
- Include: credit card, PayPal, bank transfer, etc.
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"methods": [], "message": "Payment methods coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentMethodsResponse(
methods=[],
message="Payment methods coming in Slice 5",
)
@router.get("/transactions")
@router.get("/transactions", response_model=TransactionsResponse)
def get_payment_transactions(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -137,15 +147,15 @@ def get_payment_transactions(
- Include payment details
- Support pagination
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"transactions": [],
"total": 0,
"message": "Payment transactions coming in Slice 5",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return TransactionsResponse(
transactions=[],
total=0,
message="Payment transactions coming in Slice 5",
)
@router.get("/balance")
@router.get("/balance", response_model=PaymentBalanceResponse)
def get_payment_balance(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
@@ -159,20 +169,20 @@ def get_payment_balance(
- Get next payout date
- Get payout history
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {
"available_balance": 0.0,
"pending_balance": 0.0,
"currency": "EUR",
"next_payout_date": None,
"message": "Payment balance coming in Slice 5",
}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return PaymentBalanceResponse(
available_balance=0.0,
pending_balance=0.0,
currency="EUR",
next_payout_date=None,
message="Payment balance coming in Slice 5",
)
@router.post("/refund/{payment_id}")
@router.post("/refund/{payment_id}", response_model=RefundResponse)
def refund_payment(
payment_id: int,
refund_data: dict,
refund_data: RefundRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
@@ -185,5 +195,5 @@ def refund_payment(
- Update order status
- Send refund notification to customer
"""
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment refund coming in Slice 5"}
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return RefundResponse(message="Payment refund coming in Slice 5")

View File

@@ -1,6 +1,9 @@
# app/api/v1/vendor/products.py
"""
Vendor product catalog management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -10,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.product_service import product_service
from models.database.user import User
from models.schema.product import (
@@ -45,15 +47,9 @@ def get_vendor_products(
Vendor is determined from JWT token (vendor_id claim).
"""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor_id,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
@@ -75,14 +71,8 @@ def get_product_details(
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product = product_service.get_product(
db=db, vendor_id=vendor_id, product_id=product_id
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
)
return ProductDetailResponse.model_validate(product)
@@ -99,14 +89,8 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product = product_service.create_product(
db=db, vendor_id=vendor_id, product_data=product_data
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
)
logger.info(
@@ -125,14 +109,11 @@ def update_product(
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product = product_service.update_product(
db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data
db=db,
vendor_id=current_user.token_vendor_id,
product_id=product_id,
product_update=product_data,
)
logger.info(
@@ -150,13 +131,9 @@ def remove_product_from_catalog(
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id)
product_service.delete_product(
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
)
logger.info(
f"Product {product_id} removed from catalog by user {current_user.username} "
@@ -177,18 +154,12 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import.
"""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product_data = ProductCreate(
marketplace_product_id=marketplace_product_id, is_active=True
)
product = product_service.create_product(
db=db, vendor_id=vendor_id, product_data=product_data
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
)
logger.info(
@@ -206,13 +177,9 @@ def toggle_product_active(
db: Session = Depends(get_db),
):
"""Toggle product active status."""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product = product_service.get_product(db, vendor_id, product_id)
product = product_service.get_product(
db, current_user.token_vendor_id, product_id
)
product.is_active = not product.is_active
db.commit()
@@ -231,13 +198,9 @@ def toggle_product_featured(
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
# Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
product = product_service.get_product(db, vendor_id, product_id)
product = product_service.get_product(
db, current_user.token_vendor_id, product_id
)
product.is_featured = not product.is_featured
db.commit()

View File

@@ -2,7 +2,8 @@
"""
Vendor profile management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
from models.schema.vendor import VendorResponse, VendorUpdate
@@ -21,20 +21,13 @@ router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("", response_model=VendorResponse)
def get_vendor_profile(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get current vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return vendor
@@ -45,10 +38,7 @@ def update_vendor_profile(
db: Session = Depends(get_db),
):
"""Update vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
# Verify user has permission to update vendor
if not vendor_service.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(required_permission="vendor:profile:update")
return vendor_service.update_vendor(db, vendor.id, vendor_update)
# Service handles permission checking and raises InsufficientPermissionsException if needed
return vendor_service.update_vendor(
db, current_user.token_vendor_id, vendor_update, current_user
)

View File

@@ -2,7 +2,8 @@
"""
Vendor settings and configuration endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User
@@ -26,10 +26,6 @@ def get_vendor_settings(
db: Session = Depends(get_db),
):
"""Get vendor settings and configuration."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return {
@@ -56,32 +52,7 @@ def update_marketplace_settings(
db: Session = Depends(get_db),
):
"""Update marketplace integration settings."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Verify permissions
if not vendor_service.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config:
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
if "letzshop_csv_url_en" in marketplace_config:
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
if "letzshop_csv_url_de" in marketplace_config:
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
db.commit()
db.refresh(vendor)
return {
"message": "Marketplace settings updated successfully",
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
}
# Service handles permission checking and raises InsufficientPermissionsException if needed
return vendor_service.update_marketplace_settings(
db, current_user.token_vendor_id, marketplace_config, current_user
)

View File

@@ -164,7 +164,7 @@ def invite_team_member(
)
@router.post("/accept-invitation", response_model=InvitationAcceptResponse)
@router.post("/accept-invitation", response_model=InvitationAcceptResponse) # public
def accept_invitation(acceptance: InvitationAccept, db: Session = Depends(get_db)):
"""
Accept a team invitation and activate account.

View File

@@ -94,6 +94,50 @@ class MarketplaceImportJobService:
logger.error(f"Error getting import job {job_id}: {str(e)}")
raise ValidationException("Failed to retrieve import job")
def get_import_job_for_vendor(
self, db: Session, job_id: int, vendor_id: int
) -> MarketplaceImportJob:
"""
Get a marketplace import job by ID with vendor access control.
Validates that the job belongs to the specified vendor.
Args:
db: Database session
job_id: Import job ID
vendor_id: Vendor ID from token (to verify ownership)
Raises:
ImportJobNotFoundException: If job not found
UnauthorizedVendorAccessException: If job doesn't belong to vendor
"""
from app.exceptions import UnauthorizedVendorAccessException
try:
job = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.id == job_id)
.first()
)
if not job:
raise ImportJobNotFoundException(job_id)
# Verify job belongs to vendor (service layer validation)
if job.vendor_id != vendor_id:
raise UnauthorizedVendorAccessException(
vendor_code=str(vendor_id),
user_id=0, # Not user-specific, but vendor mismatch
)
return job
except (ImportJobNotFoundException, UnauthorizedVendorAccessException):
raise
except Exception as e:
logger.error(f"Error getting import job {job_id} for vendor {vendor_id}: {str(e)}")
raise ValidationException("Failed to retrieve import job")
def get_import_jobs(
self,
db: Session,

View File

@@ -252,6 +252,44 @@ class VendorService:
return vendor
def get_active_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
"""
Get active vendor by vendor_code for public access (no auth required).
This method is specifically designed for public endpoints where:
- No authentication is required
- Only active vendors should be returned
- Inactive/disabled vendors are hidden
Args:
db: Database session
vendor_code: Vendor code (case-insensitive)
Returns:
Vendor object with company and owner loaded
Raises:
VendorNotFoundException: If vendor not found or inactive
"""
from sqlalchemy.orm import joinedload
from models.database.company import Company
vendor = (
db.query(Vendor)
.options(joinedload(Vendor.company).joinedload(Company.owner))
.filter(
func.upper(Vendor.vendor_code) == vendor_code.upper(),
Vendor.is_active == True,
)
.first()
)
if not vendor:
logger.warning(f"Vendor not found or inactive: {vendor_code}")
raise VendorNotFoundException(vendor_code, identifier_type="code")
return vendor
def get_vendor_by_identifier(self, db: Session, identifier: str) -> Vendor:
"""
Get vendor by ID or vendor_code (admin use - no access control).
@@ -544,6 +582,107 @@ class VendorService:
"""Check if user is vendor owner (via company ownership)."""
return vendor.company and vendor.company.owner_user_id == user.id
def can_update_vendor(self, vendor: Vendor, user: User) -> bool:
"""
Check if user has permission to update vendor settings.
Permission granted to:
- Admins (always)
- Vendor owners (company owner)
- Team members with appropriate role (owner role in VendorUser)
"""
# Admins can always update
if user.role == "admin":
return True
# Check if user is vendor owner via company
if self._is_vendor_owner(vendor, user):
return True
# Check if user is owner via VendorUser relationship
if user.is_owner_of(vendor.id):
return True
return False
def update_vendor(
self,
db: Session,
vendor_id: int,
vendor_update,
current_user: User,
) -> "Vendor":
"""
Update vendor profile with permission checking.
Raises:
VendorNotFoundException: If vendor not found
InsufficientPermissionsException: If user lacks permission
"""
from app.exceptions import InsufficientPermissionsException
vendor = self.get_vendor_by_id(db, vendor_id)
# Check permissions in service layer
if not self.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(
required_permission="vendor:profile:update"
)
# Apply updates
update_data = vendor_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if hasattr(vendor, field):
setattr(vendor, field, value)
db.add(vendor)
db.flush()
db.refresh(vendor)
return vendor
def update_marketplace_settings(
self,
db: Session,
vendor_id: int,
marketplace_config: dict,
current_user: User,
) -> dict:
"""
Update marketplace integration settings with permission checking.
Raises:
VendorNotFoundException: If vendor not found
InsufficientPermissionsException: If user lacks permission
"""
from app.exceptions import InsufficientPermissionsException
vendor = self.get_vendor_by_id(db, vendor_id)
# Check permissions in service layer
if not self.can_update_vendor(vendor, current_user):
raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config:
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
if "letzshop_csv_url_en" in marketplace_config:
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
if "letzshop_csv_url_de" in marketplace_config:
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
db.add(vendor)
db.flush()
db.refresh(vendor)
return {
"message": "Marketplace settings updated successfully",
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
}
# Create service instance following the same pattern as other services
vendor_service = VendorService()

View File

@@ -453,14 +453,37 @@ current_user: User = Depends(get_current_vendor_from_cookie_or_header)
**Purpose:** Authenticate vendor users for API endpoints
**Accepts:** Authorization header ONLY
**Returns:** `User` object with `role="vendor"`
**Returns:** `User` object with `role="vendor"` and **guaranteed** attributes:
- `current_user.token_vendor_id` - Vendor ID from JWT token
- `current_user.token_vendor_code` - Vendor code from JWT token
- `current_user.token_vendor_role` - User's role in vendor (owner, manager, etc.)
**Raises:**
- `InvalidTokenException` - No token or invalid token
- `InsufficientPermissionsException` - User is not vendor or is admin
- `InvalidTokenException` - No token, invalid token, or **missing vendor context in token**
- `InsufficientPermissionsException` - User is not vendor, is admin, or lost access to vendor
**Guarantees:**
This dependency **guarantees** that `token_vendor_id` is present. Endpoints should NOT check for its existence:
```python
# ❌ WRONG - Redundant check violates API-003
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("...")
# ✅ CORRECT - Dependency guarantees this attribute exists
vendor_id = current_user.token_vendor_id
```
**Usage:**
```python
current_user: User = Depends(get_current_vendor_api)
@router.get("/orders")
def get_orders(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
# Safe to use directly - dependency guarantees token_vendor_id
orders = order_service.get_vendor_orders(db, current_user.token_vendor_id)
return orders
```
#### `get_current_customer_from_cookie_or_header()`

View File

@@ -195,27 +195,20 @@ def get_current_vendor_api(
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(get_current_vendor_api), # ✅ Only need this
current_user: User = Depends(get_current_vendor_api), # ✅ Guarantees token_vendor_id
db: Session = Depends(get_db),
):
"""
Get all products in vendor catalog.
Vendor is determined from JWT token (vendor_id claim).
The get_current_vendor_api dependency GUARANTEES token_vendor_id is present.
"""
# Extract vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
# Use vendor_id from token for business logic
# NO validation needed - dependency guarantees token_vendor_id exists
products, total = product_service.get_vendor_products(
db=db,
vendor_id=vendor_id,
vendor_id=current_user.token_vendor_id, # Safe to use directly
skip=skip,
limit=limit,
)
@@ -223,6 +216,9 @@ def get_vendor_products(
return ProductListResponse(products=products, total=total)
```
> **IMPORTANT**: The `get_current_vendor_api()` dependency now **guarantees** that `token_vendor_id` is present.
> Endpoints should NOT check for its existence - this would be redundant validation that belongs in the dependency layer.
## Migration Guide
### Step 1: Identify Endpoints Using require_vendor_context()
@@ -264,21 +260,14 @@ product = product_service.get_product(db, vendor.id, product_id)
**After:**
```python
from fastapi import HTTPException
# Extract vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id
# Use vendor_id from token
product = product_service.get_product(db, vendor_id, product_id)
# Use vendor_id from token directly - dependency guarantees it exists
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
```
> **NOTE**: Do NOT add validation like `if not hasattr(current_user, "token_vendor_id")`.
> The `get_current_vendor_api` dependency guarantees this attribute is present.
> Adding such checks violates the architecture rule API-003 (endpoints should not raise exceptions).
### Step 4: Update Logging References
**Before:**
@@ -325,24 +314,14 @@ def update_product(
def update_product(
product_id: int,
product_data: ProductUpdate,
current_user: User = Depends(get_current_vendor_api), # ✅ Only dependency
current_user: User = Depends(get_current_vendor_api), # ✅ Guarantees token_vendor_id
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
from fastapi import HTTPException
# Extract vendor ID from token
if not hasattr(current_user, "token_vendor_id"):
raise HTTPException(
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id # ✅ From token
# NO validation needed - dependency guarantees token_vendor_id exists
product = product_service.update_product(
db=db,
vendor_id=vendor_id, # ✅ From token
vendor_id=current_user.token_vendor_id, # ✅ From token - safe to use directly
product_id=product_id,
product_update=product_data
)
@@ -355,6 +334,9 @@ def update_product(
return ProductResponse.model_validate(product)
```
> **Architecture Rule API-003**: Endpoints should NOT raise exceptions. The `get_current_vendor_api` dependency
> handles all validation and raises `InvalidTokenException` if `token_vendor_id` is missing.
## Migration Status
**COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern.
@@ -498,9 +480,81 @@ def test_vendor_login_and_api_access():
# All products should belong to token vendor
```
## Architecture Rules
## Architecture Rules and Design Pattern Enforcement
See `docs/architecture/rules/API-VND-001.md` for the formal architecture rule enforcing this pattern.
### The Layered Exception Pattern
The architecture enforces a strict layered pattern for where exceptions should be raised:
```
┌────────────────────────────────────────────────────────────────────────────┐
│ ENDPOINTS (Thin Layer) - app/api/v1/**/*.py │
│ │
│ ❌ MUST NOT raise exceptions │
│ ❌ MUST NOT check hasattr(current_user, 'token_vendor_id') │
│ ✅ MUST trust dependencies to handle validation │
│ ✅ MUST directly use current_user.token_vendor_id │
└────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ DEPENDENCIES (Validation Layer) - app/api/deps.py │
│ │
│ ✅ MUST raise InvalidTokenException if token_vendor_id missing │
│ ✅ MUST validate user still has vendor access │
│ ✅ GUARANTEES token_vendor_id, token_vendor_code, token_vendor_role │
└────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ SERVICES (Business Logic) - app/services/**/*.py │
│ │
│ ✅ MUST raise domain exceptions for business rule violations │
│ ✅ Examples: VendorNotFoundException, ProductNotFoundException │
└────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ GLOBAL EXCEPTION HANDLER - app/exceptions/handler.py │
│ │
│ ✅ Catches all WizamartException subclasses │
│ ✅ Converts to appropriate HTTP responses │
│ ✅ Provides consistent error formatting │
└────────────────────────────────────────────────────────────────────────────┘
```
### Enforced by Architecture Validation
The validation script (`scripts/validate_architecture.py`) enforces these rules:
**Rule API-003: Endpoints must NOT raise exceptions directly**
- Detects `raise HTTPException`, `raise InvalidTokenException`, etc. in endpoint files
- Detects redundant validation like `if not hasattr(current_user, 'token_vendor_id')`
- Blocks commits via pre-commit hook if violations found
### Pre-commit Hook
Architecture validation runs on every commit:
```yaml
# .pre-commit-config.yaml
- repo: local
hooks:
- id: validate-architecture
name: Validate Architecture Patterns
entry: python scripts/validate_architecture.py
language: python
pass_filenames: false
always_run: true
```
To run manually:
```bash
python scripts/validate_architecture.py # Full validation
python scripts/validate_architecture.py -d app/api/v1/vendor/ # Specific directory
```
See `.architecture-rules.yaml` for the complete rule definitions.
## Related Documentation
@@ -519,4 +573,4 @@ The vendor-in-token architecture:
- ✅ Simplifies endpoint implementation
- ✅ Follows architecture best practices
**Migration Status:** In progress - 9 endpoint files remaining to migrate
**Migration Status:** ✅ COMPLETED - All vendor API endpoints migrated and architecture rules enforced

View File

@@ -1 +1,190 @@
# Media/file management models
# models/schema/media.py
"""
Media/file management Pydantic schemas for API validation and responses.
This module provides schemas for:
- Media library listing
- File upload responses
- Media metadata operations
- Media usage tracking
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ============================================================================
# SHARED RESPONSE SCHEMAS
# ============================================================================
class MessageResponse(BaseModel):
"""Generic message response for simple operations."""
message: str
# ============================================================================
# MEDIA ITEM SCHEMAS
# ============================================================================
class MediaItemResponse(BaseModel):
"""Single media item response."""
id: int
filename: str
original_filename: str | None = None
file_url: str
thumbnail_url: str | None = None
media_type: str # image, video, document
mime_type: str | None = None
file_size: int | None = None # bytes
width: int | None = None # for images/videos
height: int | None = None # for images/videos
alt_text: str | None = None
description: str | None = None
folder: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class MediaListResponse(BaseModel):
"""Paginated list of media items."""
media: list[MediaItemResponse] = []
total: int = 0
skip: int = 0
limit: int = 100
message: str | None = None
# ============================================================================
# UPLOAD RESPONSE SCHEMAS
# ============================================================================
class MediaUploadResponse(BaseModel):
"""Response for single file upload."""
id: int | None = None
file_url: str | None = None
thumbnail_url: str | None = None
filename: str | None = None
file_size: int | None = None
media_type: str | None = None
message: str | None = None
class UploadedFileInfo(BaseModel):
"""Information about a successfully uploaded file."""
id: int
filename: str
file_url: str
thumbnail_url: str | None = None
class FailedFileInfo(BaseModel):
"""Information about a failed file upload."""
filename: str
error: str
class MultipleUploadResponse(BaseModel):
"""Response for multiple file upload."""
uploaded_files: list[UploadedFileInfo] = []
failed_files: list[FailedFileInfo] = []
total_uploaded: int = 0
total_failed: int = 0
message: str | None = None
# ============================================================================
# MEDIA DETAIL SCHEMAS
# ============================================================================
class MediaDetailResponse(BaseModel):
"""Detailed media item response with usage info."""
id: int | None = None
filename: str | None = None
original_filename: str | None = None
file_url: str | None = None
thumbnail_url: str | None = None
media_type: str | None = None
mime_type: str | None = None
file_size: int | None = None
width: int | None = None
height: int | None = None
alt_text: str | None = None
description: str | None = None
folder: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
message: str | None = None
model_config = {"from_attributes": True}
# ============================================================================
# MEDIA UPDATE SCHEMAS
# ============================================================================
class MediaMetadataUpdate(BaseModel):
"""Request model for updating media metadata."""
filename: str | None = Field(None, max_length=255)
alt_text: str | None = Field(None, max_length=500)
description: str | None = None
folder: str | None = Field(None, max_length=100)
metadata: dict[str, Any] | None = None
# ============================================================================
# MEDIA USAGE SCHEMAS
# ============================================================================
class ProductUsageInfo(BaseModel):
"""Information about product using this media."""
product_id: int
product_name: str
usage_type: str # main_image, gallery, variant, etc.
class MediaUsageResponse(BaseModel):
"""Response showing where media is being used."""
media_id: int | None = None
products: list[ProductUsageInfo] = []
other_usage: list[dict[str, Any]] = []
total_usage_count: int = 0
message: str | None = None
# ============================================================================
# MEDIA OPTIMIZATION SCHEMAS
# ============================================================================
class OptimizationResultResponse(BaseModel):
"""Response for media optimization operation."""
media_id: int | None = None
original_size: int | None = None
optimized_size: int | None = None
savings_percent: float | None = None
optimized_url: str | None = None
message: str | None = None

View File

@@ -1 +1,151 @@
# Notification models
# models/schema/notification.py
"""
Notification Pydantic schemas for API validation and responses.
This module provides schemas for:
- Vendor notifications (list, read, delete)
- Notification settings management
- Notification email templates
- Unread counts and statistics
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ============================================================================
# SHARED RESPONSE SCHEMAS
# ============================================================================
class MessageResponse(BaseModel):
"""Generic message response for simple operations."""
message: str
class UnreadCountResponse(BaseModel):
"""Response for unread notification count."""
unread_count: int
message: str | None = None
# ============================================================================
# NOTIFICATION SCHEMAS
# ============================================================================
class NotificationResponse(BaseModel):
"""Single notification response."""
id: int
type: str
title: str
message: str
is_read: bool
read_at: datetime | None = None
priority: str = "normal"
action_url: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class NotificationListResponse(BaseModel):
"""Paginated list of notifications."""
notifications: list[NotificationResponse] = []
total: int = 0
unread_count: int = 0
message: str | None = None
# ============================================================================
# NOTIFICATION SETTINGS SCHEMAS
# ============================================================================
class NotificationSettingsResponse(BaseModel):
"""Notification preferences response."""
email_notifications: bool = True
in_app_notifications: bool = True
notification_types: dict[str, bool] = Field(default_factory=dict)
message: str | None = None
class NotificationSettingsUpdate(BaseModel):
"""Request model for updating notification settings."""
email_notifications: bool | None = None
in_app_notifications: bool | None = None
notification_types: dict[str, bool] | None = None
# ============================================================================
# NOTIFICATION TEMPLATE SCHEMAS
# ============================================================================
class NotificationTemplateResponse(BaseModel):
"""Single notification template response."""
id: int
name: str
type: str
subject: str
body_html: str | None = None
body_text: str | None = None
variables: list[str] = Field(default_factory=list)
is_active: bool = True
created_at: datetime
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class NotificationTemplateListResponse(BaseModel):
"""List of notification templates."""
templates: list[NotificationTemplateResponse] = []
message: str | None = None
class NotificationTemplateUpdate(BaseModel):
"""Request model for updating notification template."""
subject: str | None = Field(None, max_length=200)
body_html: str | None = None
body_text: str | None = None
is_active: bool | None = None
# ============================================================================
# TEST NOTIFICATION SCHEMA
# ============================================================================
class TestNotificationRequest(BaseModel):
"""Request model for sending test notification."""
template_id: int | None = Field(None, description="Template to use")
email: str | None = Field(None, description="Override recipient email")
notification_type: str = Field(default="test", description="Type of notification to send")
# ============================================================================
# ADMIN ALERT STATISTICS SCHEMA
# ============================================================================
class AlertStatisticsResponse(BaseModel):
"""Response for alert statistics."""
total_alerts: int = 0
active_alerts: int = 0
critical_alerts: int = 0
resolved_today: int = 0

View File

@@ -1 +1,166 @@
# Payment models
# models/schema/payment.py
"""
Payment Pydantic schemas for API validation and responses.
This module provides schemas for:
- Payment configuration
- Stripe integration
- Payment methods
- Transactions and balance
- Refunds
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ============================================================================
# PAYMENT CONFIGURATION SCHEMAS
# ============================================================================
class PaymentConfigResponse(BaseModel):
"""Response for payment configuration."""
payment_gateway: str | None = None
accepted_methods: list[str] = []
currency: str = "EUR"
stripe_connected: bool = False
stripe_account_id: str | None = None
paypal_connected: bool = False
message: str | None = None
class PaymentConfigUpdate(BaseModel):
"""Request model for updating payment configuration."""
payment_gateway: str | None = Field(None, max_length=50)
accepted_methods: list[str] | None = None
currency: str | None = Field(None, max_length=3)
class PaymentConfigUpdateResponse(BaseModel):
"""Response for payment configuration update."""
success: bool = False
message: str | None = None
# ============================================================================
# STRIPE INTEGRATION SCHEMAS
# ============================================================================
class StripeConnectRequest(BaseModel):
"""Request model for connecting Stripe account."""
authorization_code: str | None = None
state: str | None = None
class StripeConnectResponse(BaseModel):
"""Response for Stripe connection."""
connected: bool = False
stripe_account_id: str | None = None
message: str | None = None
class StripeDisconnectResponse(BaseModel):
"""Response for Stripe disconnection."""
disconnected: bool = False
message: str | None = None
# ============================================================================
# PAYMENT METHODS SCHEMAS
# ============================================================================
class PaymentMethodInfo(BaseModel):
"""Information about a payment method."""
id: str
name: str
type: str # credit_card, paypal, bank_transfer, etc.
enabled: bool = True
icon: str | None = None
class PaymentMethodsResponse(BaseModel):
"""Response for payment methods listing."""
methods: list[PaymentMethodInfo] = []
message: str | None = None
# ============================================================================
# TRANSACTION SCHEMAS
# ============================================================================
class TransactionInfo(BaseModel):
"""Information about a payment transaction."""
id: int
order_id: int | None = None
amount: float
currency: str = "EUR"
status: str # pending, completed, failed, refunded
payment_method: str | None = None
customer_email: str | None = None
created_at: datetime
completed_at: datetime | None = None
metadata: dict[str, Any] | None = None
class TransactionsResponse(BaseModel):
"""Response for payment transactions listing."""
transactions: list[TransactionInfo] = []
total: int = 0
skip: int = 0
limit: int = 50
message: str | None = None
# ============================================================================
# BALANCE SCHEMAS
# ============================================================================
class PaymentBalanceResponse(BaseModel):
"""Response for payment balance information."""
available_balance: float = 0.0
pending_balance: float = 0.0
currency: str = "EUR"
next_payout_date: datetime | None = None
last_payout_date: datetime | None = None
last_payout_amount: float | None = None
message: str | None = None
# ============================================================================
# REFUND SCHEMAS
# ============================================================================
class RefundRequest(BaseModel):
"""Request model for processing a refund."""
amount: float | None = Field(None, gt=0, description="Partial refund amount, or None for full refund")
reason: str | None = Field(None, max_length=500)
class RefundResponse(BaseModel):
"""Response for refund operation."""
refund_id: int | None = None
payment_id: int | None = None
amount: float | None = None
status: str | None = None # pending, completed, failed
message: str | None = None

View File

@@ -517,39 +517,72 @@ class ArchitectureValidator:
def _check_endpoint_exception_handling(
self, file_path: Path, content: str, lines: list[str]
):
"""API-003: Check that endpoints do NOT raise HTTPException directly.
"""API-003: Check that endpoints do NOT raise exceptions directly.
The architecture uses a global exception handler that catches domain
exceptions (WizamartException subclasses) and converts them to HTTP
responses. Endpoints should let exceptions bubble up, not catch and
convert them manually.
The architecture uses:
- Dependencies (deps.py) for authentication/authorization validation
- Services for business logic validation
- Global exception handler that catches WizamartException subclasses
Endpoints should be a thin orchestration layer that trusts dependencies
and services to handle all validation. They should NOT raise exceptions.
"""
rule = self._get_rule("API-003")
if not rule:
return
# Skip exception handler file - it's allowed to use HTTPException
if "exceptions/handler.py" in str(file_path):
# Skip exception handler file and deps.py - they're allowed to raise exceptions
file_path_str = str(file_path)
if "exceptions/handler.py" in file_path_str or file_path_str.endswith("deps.py"):
return
for i, line in enumerate(lines, 1):
# Check for raise HTTPException
if "raise HTTPException" in line:
# Skip if it's a comment
stripped = line.strip()
if stripped.startswith("#"):
continue
# Patterns that indicate endpoints are raising exceptions (BAD)
exception_patterns = [
("raise HTTPException", "Endpoint raises HTTPException directly"),
("raise InvalidTokenException", "Endpoint raises InvalidTokenException - move to dependency"),
("raise InsufficientPermissionsException", "Endpoint raises permission exception - move to dependency"),
("raise UnauthorizedVendorAccessException", "Endpoint raises auth exception - move to dependency or service"),
]
self._add_violation(
rule_id="API-003",
rule_name=rule["name"],
severity=Severity.ERROR,
file_path=file_path,
line_number=i,
message="Endpoint raises HTTPException directly",
context=line.strip()[:80],
suggestion="Use domain exceptions (e.g., VendorNotFoundException) and let global handler convert",
)
# Pattern that indicates redundant validation (BAD)
redundant_patterns = [
(r"if not hasattr\(current_user.*token_vendor", "Redundant token_vendor check - get_current_vendor_api guarantees this"),
(r"if not hasattr\(current_user.*token_vendor_id", "Redundant token_vendor_id check - dependency guarantees this"),
]
for i, line in enumerate(lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith("#"):
continue
# Check for direct exception raising
for pattern, message in exception_patterns:
if pattern in line:
self._add_violation(
rule_id="API-003",
rule_name=rule["name"],
severity=Severity.ERROR,
file_path=file_path,
line_number=i,
message=message,
context=stripped[:80],
suggestion="Let dependencies or services handle validation and raise exceptions",
)
# Check for redundant validation patterns
for pattern, message in redundant_patterns:
if re.search(pattern, line):
self._add_violation(
rule_id="API-003",
rule_name=rule["name"],
severity=Severity.ERROR,
file_path=file_path,
line_number=i,
message=message,
context=stripped[:80],
suggestion="Remove redundant check - auth dependency guarantees this attribute is present",
)
def _check_endpoint_authentication(
self, file_path: Path, content: str, lines: list[str]
@@ -570,7 +603,21 @@ class ArchitectureValidator:
return
# This is a warning-level check
# Look for endpoints without Depends(get_current_*)
# Look for endpoints without proper authentication
# Valid auth patterns:
# - Depends(get_current_*) - direct user authentication
# - Depends(require_vendor_*) - vendor permission dependencies
# - Depends(require_any_vendor_*) - any permission check
# - Depends(require_all_vendor*) - all permissions check
# - Depends(get_user_permissions) - permission fetching
auth_patterns = [
"Depends(get_current_",
"Depends(require_vendor_",
"Depends(require_any_vendor_",
"Depends(require_all_vendor",
"Depends(get_user_permissions",
]
for i, line in enumerate(lines, 1):
if "@router." in line and (
"post" in line or "put" in line or "delete" in line
@@ -582,7 +629,8 @@ class ArchitectureValidator:
context_lines = lines[i - 1 : i + 15] # Include line before decorator
for ctx_line in context_lines:
if "Depends(get_current_" in ctx_line:
# Check for any valid auth pattern
if any(pattern in ctx_line for pattern in auth_patterns):
has_auth = True
break
# Check for public endpoint markers
@@ -593,6 +641,17 @@ class ArchitectureValidator:
if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
lines[i : i + 15]
):
# Determine appropriate suggestion based on file path
file_path_str = str(file_path)
if "/vendor/" in file_path_str:
suggestion = "Add Depends(get_current_vendor_api) or permission dependency, or mark as '# public'"
elif "/admin/" in file_path_str:
suggestion = "Add Depends(get_current_admin_api), or mark as '# public'"
elif "/shop/" in file_path_str:
suggestion = "Add Depends(get_current_customer_api), or mark as '# public'"
else:
suggestion = "Add authentication dependency or mark as '# public' if intentionally unauthenticated"
self._add_violation(
rule_id="API-004",
rule_name=rule["name"],
@@ -601,7 +660,7 @@ class ArchitectureValidator:
line_number=i,
message="Endpoint may be missing authentication",
context=line.strip(),
suggestion="Add Depends(get_current_user) or mark as '# public' if intentionally unauthenticated",
suggestion=suggestion,
)
def _validate_service_layer(self, target_path: Path):