From 81bfc49f776c5dcd0d36b4a88b1a398ecfaab4ca Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 4 Dec 2025 23:26:03 +0100 Subject: [PATCH] refactor: enforce strict architecture rules and add Pydantic response models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .architecture-rules.yaml | 35 +++- app/api/deps.py | 36 ++-- app/api/v1/admin/notifications.py | 42 ++-- app/api/v1/vendor/analytics.py | 14 +- app/api/v1/vendor/customers.py | 24 +-- app/api/v1/vendor/dashboard.py | 9 +- app/api/v1/vendor/info.py | 36 +--- app/api/v1/vendor/inventory.py | 38 +--- app/api/v1/vendor/marketplace.py | 26 +-- app/api/v1/vendor/media.py | 107 +++++----- app/api/v1/vendor/notifications.py | 110 +++++----- app/api/v1/vendor/orders.py | 47 +---- app/api/v1/vendor/payments.py | 116 ++++++----- app/api/v1/vendor/products.py | 77 ++----- app/api/v1/vendor/profile.py | 24 +-- app/api/v1/vendor/settings.py | 41 +--- app/api/v1/vendor/team.py | 2 +- .../marketplace_import_job_service.py | 44 ++++ app/services/vendor_service.py | 139 +++++++++++++ docs/api/authentication.md | 31 ++- docs/backend/vendor-in-token-architecture.md | 134 ++++++++---- models/schema/media.py | 191 +++++++++++++++++- models/schema/notification.py | 152 +++++++++++++- models/schema/payment.py | 167 ++++++++++++++- scripts/validate_architecture.py | 113 ++++++++--- 25 files changed, 1225 insertions(+), 530 deletions(-) diff --git a/.architecture-rules.yaml b/.architecture-rules.yaml index 3ce7fde2..e43d8ed0 100644 --- a/.architecture-rules.yaml +++ b/.architecture-rules.yaml @@ -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 diff --git a/app/api/deps.py b/app/api/deps.py index 6ddae7fd..1634d9b7 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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 diff --git a/app/api/v1/admin/notifications.py b/app/api/v1/admin/notifications.py index 6322cbde..af426167 100644 --- a/app/api/v1/admin/notifications.py +++ b/app/api/v1/admin/notifications.py @@ -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, + ) diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py index e0d23912..28763d95 100644 --- a/app/api/v1/vendor/analytics.py +++ b/app/api/v1/vendor/analytics.py @@ -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) diff --git a/app/api/v1/vendor/customers.py b/app/api/v1/vendor/customers.py index c6e5f759..bf546b1e 100644 --- a/app/api/v1/vendor/customers.py +++ b/app/api/v1/vendor/customers.py @@ -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, diff --git a/app/api/v1/vendor/dashboard.py b/app/api/v1/vendor/dashboard.py index db5d176c..803b51ea 100644 --- a/app/api/v1/vendor/dashboard.py +++ b/app/api/v1/vendor/dashboard.py @@ -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) diff --git a/app/api/v1/vendor/info.py b/app/api/v1/vendor/info.py index 481d16bc..2b1ad005 100644 --- a/app/api/v1/vendor/info.py +++ b/app/api/v1/vendor/info.py @@ -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})") diff --git a/app/api/v1/vendor/inventory.py b/app/api/v1/vendor/inventory.py index abb828df..66d82068 100644 --- a/app/api/v1/vendor/inventory.py +++ b/app/api/v1/vendor/inventory.py @@ -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"} diff --git a/app/api/v1/vendor/marketplace.py b/app/api/v1/vendor/marketplace.py index adfc85e3..a46af68a 100644 --- a/app/api/v1/vendor/marketplace.py +++ b/app/api/v1/vendor/marketplace.py @@ -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, diff --git a/app/api/v1/vendor/media.py b/app/api/v1/vendor/media.py index b7005c38..bce96ea2 100644 --- a/app/api/v1/vendor/media.py +++ b/app/api/v1/vendor/media.py @@ -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") diff --git a/app/api/v1/vendor/notifications.py b/app/api/v1/vendor/notifications.py index 018e1406..a6983fc1 100644 --- a/app/api/v1/vendor/notifications.py +++ b/app/api/v1/vendor/notifications.py @@ -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") diff --git a/app/api/v1/vendor/orders.py b/app/api/v1/vendor/orders.py index 5760c29c..19dc2213 100644 --- a/app/api/v1/vendor/orders.py +++ b/app/api/v1/vendor/orders.py @@ -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( diff --git a/app/api/v1/vendor/payments.py b/app/api/v1/vendor/payments.py index 22737252..c3075e28 100644 --- a/app/api/v1/vendor/payments.py +++ b/app/api/v1/vendor/payments.py @@ -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") diff --git a/app/api/v1/vendor/products.py b/app/api/v1/vendor/products.py index f00896a4..1441884a 100644 --- a/app/api/v1/vendor/products.py +++ b/app/api/v1/vendor/products.py @@ -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() diff --git a/app/api/v1/vendor/profile.py b/app/api/v1/vendor/profile.py index a00b707f..913885da 100644 --- a/app/api/v1/vendor/profile.py +++ b/app/api/v1/vendor/profile.py @@ -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 + ) diff --git a/app/api/v1/vendor/settings.py b/app/api/v1/vendor/settings.py index 552e37e5..84dff346 100644 --- a/app/api/v1/vendor/settings.py +++ b/app/api/v1/vendor/settings.py @@ -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 + ) diff --git a/app/api/v1/vendor/team.py b/app/api/v1/vendor/team.py index 73d0f127..a985756a 100644 --- a/app/api/v1/vendor/team.py +++ b/app/api/v1/vendor/team.py @@ -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. diff --git a/app/services/marketplace_import_job_service.py b/app/services/marketplace_import_job_service.py index 13056353..f52b9a77 100644 --- a/app/services/marketplace_import_job_service.py +++ b/app/services/marketplace_import_job_service.py @@ -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, diff --git a/app/services/vendor_service.py b/app/services/vendor_service.py index c3eab927..d8a71cdb 100644 --- a/app/services/vendor_service.py +++ b/app/services/vendor_service.py @@ -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() diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 044ca0e0..b6b341c9 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -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()` diff --git a/docs/backend/vendor-in-token-architecture.md b/docs/backend/vendor-in-token-architecture.md index 70e1c6d6..5dc6c442 100644 --- a/docs/backend/vendor-in-token-architecture.md +++ b/docs/backend/vendor-in-token-architecture.md @@ -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 diff --git a/models/schema/media.py b/models/schema/media.py index cb2935b4..16ad1f7d 100644 --- a/models/schema/media.py +++ b/models/schema/media.py @@ -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 diff --git a/models/schema/notification.py b/models/schema/notification.py index 36f54ac4..63ed778b 100644 --- a/models/schema/notification.py +++ b/models/schema/notification.py @@ -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 diff --git a/models/schema/payment.py b/models/schema/payment.py index 1ad2d971..cf7084f6 100644 --- a/models/schema/payment.py +++ b/models/schema/payment.py @@ -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 diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py index eeb27206..a7b8bcd4 100755 --- a/scripts/validate_architecture.py +++ b/scripts/validate_architecture.py @@ -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):