refactor: enforce strict architecture rules and add Pydantic response models

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

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

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

View File

@@ -75,17 +75,42 @@ api_endpoint_rules:
# NOTE: db.commit() is intentionally NOT listed - it's allowed for transaction control # NOTE: db.commit() is intentionally NOT listed - it's allowed for transaction control
- id: "API-003" - id: "API-003"
name: "Endpoint must NOT raise HTTPException directly" name: "Endpoint must NOT raise ANY exceptions directly"
severity: "error" severity: "error"
description: | description: |
API endpoints should NOT raise HTTPException directly. Instead, let domain API endpoints should NOT raise exceptions directly. Endpoints are a thin
exceptions bubble up to the global exception handler which converts them orchestration layer that:
to appropriate HTTP responses. This ensures consistent error formatting 1. Accepts request parameters (validated by Pydantic)
and centralized error handling. 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: pattern:
file_pattern: "app/api/v1/**/*.py" file_pattern: "app/api/v1/**/*.py"
anti_patterns: anti_patterns:
- "raise HTTPException" - "raise HTTPException"
- "raise InvalidTokenException"
- "raise InsufficientPermissionsException"
- "if not hasattr\\(current_user.*token_vendor"
exceptions: exceptions:
- "app/exceptions/handler.py" # Handler is allowed to use HTTPException - "app/exceptions/handler.py" # Handler is allowed to use HTTPException

View File

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

View File

@@ -21,7 +21,11 @@ from models.schema.admin import (
PlatformAlertCreate, PlatformAlertCreate,
PlatformAlertListResponse, PlatformAlertListResponse,
PlatformAlertResolve, PlatformAlertResolve,
PlatformAlertResponse, )
from models.schema.notification import (
AlertStatisticsResponse,
MessageResponse,
UnreadCountResponse,
) )
router = APIRouter(prefix="/notifications") 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( def get_unread_count(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Get count of unread notifications.""" """Get count of unread notifications."""
# TODO: Implement # 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( def mark_as_read(
notification_id: int, notification_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -67,17 +71,17 @@ def mark_as_read(
): ):
"""Mark notification as read.""" """Mark notification as read."""
# TODO: Implement # 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( def mark_all_as_read(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Mark all notifications as read.""" """Mark all notifications as read."""
# TODO: Implement # 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( def create_platform_alert(
alert_data: PlatformAlertCreate, alert_data: PlatformAlertCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Create new platform alert (manual).""" """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}") 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( def resolve_platform_alert(
alert_id: int, alert_id: int,
resolve_data: PlatformAlertResolve, resolve_data: PlatformAlertResolve,
@@ -123,19 +127,19 @@ def resolve_platform_alert(
"""Resolve platform alert.""" """Resolve platform alert."""
# TODO: Implement # TODO: Implement
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}") 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( def get_alert_statistics(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Get alert statistics for dashboard.""" """Get alert statistics for dashboard."""
# TODO: Implement # TODO: Implement
return { return AlertStatisticsResponse(
"total_alerts": 0, total_alerts=0,
"active_alerts": 0, active_alerts=0,
"critical_alerts": 0, critical_alerts=0,
"resolved_today": 0, resolved_today=0,
} )

View File

@@ -2,7 +2,8 @@
""" """
Vendor analytics and reporting endpoints. 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 import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.stats_service import stats_service from app.services.stats_service import stats_service
from models.database.user import User from models.database.user import User
@@ -20,13 +20,6 @@ router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__) 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("") @router.get("")
def get_vendor_analytics( def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
@@ -34,5 +27,4 @@ def get_vendor_analytics(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get vendor analytics data for specified time period.""" """Get vendor analytics data for specified time period."""
vendor_id = _get_vendor_id_from_token(current_user) return stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)
return stats_service.get_vendor_analytics(db, vendor_id, period)

View File

@@ -1,9 +1,9 @@
# Vendor customer management
# app/api/v1/vendor/customers.py # app/api/v1/vendor/customers.py
""" """
Vendor customer management endpoints. 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 import logging
@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
@@ -21,13 +20,6 @@ router = APIRouter(prefix="/customers")
logger = logging.getLogger(__name__) 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("")
def get_vendor_customers( def get_vendor_customers(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
@@ -46,7 +38,7 @@ def get_vendor_customers(
- Support filtering by active status - Support filtering by active status
- Return paginated results - 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 { return {
"customers": [], "customers": [],
"total": 0, "total": 0,
@@ -71,7 +63,7 @@ def get_customer_details(
- Include order history - Include order history
- Include total spent, etc. - 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"} return {"message": "Customer details coming in Slice 4"}
@@ -89,7 +81,7 @@ def get_customer_orders(
- Filter by vendor_id - Filter by vendor_id
- Return order details - 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"} return {"orders": [], "message": "Customer orders coming in Slice 5"}
@@ -108,7 +100,7 @@ def update_customer(
- Verify customer belongs to vendor - Verify customer belongs to vendor
- Update customer preferences - 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"} return {"message": "Customer update coming in Slice 4"}
@@ -126,7 +118,7 @@ def toggle_customer_status(
- Verify customer belongs to vendor - Verify customer belongs to vendor
- Log the change - 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"} return {"message": "Customer status toggle coming in Slice 4"}
@@ -145,7 +137,7 @@ def get_customer_statistics(
- Average order value - Average order value
- Last order date - 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 { return {
"total_orders": 0, "total_orders": 0,
"total_spent": 0.0, "total_spent": 0.0,

View File

@@ -1,6 +1,9 @@
# app/api/v1/vendor/dashboard.py # app/api/v1/vendor/dashboard.py
""" """
Vendor dashboard and statistics endpoints. 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 import logging
@@ -10,7 +13,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db 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.stats_service import stats_service
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from models.database.user import User 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). Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint). 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 vendor_id = current_user.token_vendor_id
# Get vendor object (raises VendorNotFoundException if not found) # Get vendor object (raises VendorNotFoundException if not found)

View File

@@ -10,48 +10,16 @@ This module provides:
import logging import logging
from fastapi import APIRouter, Depends, Path from fastapi import APIRouter, Depends, Path
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import VendorNotFoundException from app.services.vendor_service import vendor_service
from models.database.vendor import Vendor
from models.schema.vendor import VendorDetailResponse from models.schema.vendor import VendorDetailResponse
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) 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) @router.get("/{vendor_code}", response_model=VendorDetailResponse)
def get_vendor_info( def get_vendor_info(
vendor_code: str = Path(..., description="Vendor code"), vendor_code: str = Path(..., description="Vendor code"),
@@ -81,7 +49,7 @@ def get_vendor_info(
""" """
logger.info(f"Public vendor info request: {vendor_code}") 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})") logger.info(f"Vendor info retrieved: {vendor.name} ({vendor.vendor_code})")

View File

@@ -2,7 +2,8 @@
""" """
Vendor inventory management endpoints. 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 import logging
@@ -11,7 +12,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.inventory_service import inventory_service from app.services.inventory_service import inventory_service
from models.database.user import User from models.database.user import User
from models.schema.inventory import ( from models.schema.inventory import (
@@ -28,13 +28,6 @@ router = APIRouter()
logger = logging.getLogger(__name__) 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) @router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory( def set_inventory(
inventory: InventoryCreate, inventory: InventoryCreate,
@@ -42,8 +35,7 @@ def set_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Set exact inventory quantity (replaces existing).""" """Set exact inventory quantity (replaces existing)."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.set_inventory(db, current_user.token_vendor_id, inventory)
return inventory_service.set_inventory(db, vendor_id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse) @router.post("/inventory/adjust", response_model=InventoryResponse)
@@ -53,8 +45,7 @@ def adjust_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Adjust inventory (positive to add, negative to remove).""" """Adjust inventory (positive to add, negative to remove)."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.adjust_inventory(db, current_user.token_vendor_id, adjustment)
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse) @router.post("/inventory/reserve", response_model=InventoryResponse)
@@ -64,8 +55,7 @@ def reserve_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Reserve inventory for an order.""" """Reserve inventory for an order."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.reserve_inventory(db, current_user.token_vendor_id, reservation)
return inventory_service.reserve_inventory(db, vendor_id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse) @router.post("/inventory/release", response_model=InventoryResponse)
@@ -75,8 +65,7 @@ def release_reservation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Release reserved inventory (cancel order).""" """Release reserved inventory (cancel order)."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.release_reservation(db, current_user.token_vendor_id, reservation)
return inventory_service.release_reservation(db, vendor_id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse) @router.post("/inventory/fulfill", response_model=InventoryResponse)
@@ -86,8 +75,7 @@ def fulfill_reservation(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Fulfill reservation (complete order, remove from stock).""" """Fulfill reservation (complete order, remove from stock)."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.fulfill_reservation(db, current_user.token_vendor_id, reservation)
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary) @router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
@@ -97,8 +85,7 @@ def get_product_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get inventory summary for a product.""" """Get inventory summary for a product."""
vendor_id = _get_vendor_id_from_token(current_user) return inventory_service.get_product_inventory(db, current_user.token_vendor_id, product_id)
return inventory_service.get_product_inventory(db, vendor_id, product_id)
@router.get("/inventory", response_model=InventoryListResponse) @router.get("/inventory", response_model=InventoryListResponse)
@@ -111,9 +98,8 @@ def get_vendor_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get all inventory for vendor.""" """Get all inventory for vendor."""
vendor_id = _get_vendor_id_from_token(current_user)
inventories = inventory_service.get_vendor_inventory( 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 # Get total count
@@ -132,9 +118,8 @@ def update_inventory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update inventory entry.""" """Update inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.update_inventory( 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), db: Session = Depends(get_db),
): ):
"""Delete inventory entry.""" """Delete inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user) inventory_service.delete_inventory(db, current_user.token_vendor_id, inventory_id)
inventory_service.delete_inventory(db, vendor_id, inventory_id)
return {"message": "Inventory deleted successfully"} return {"message": "Inventory deleted successfully"}

View File

@@ -2,7 +2,8 @@
""" """
Marketplace import endpoints for vendors. 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 import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db 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.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import from app.tasks.background_tasks import process_marketplace_import
@@ -27,13 +27,6 @@ router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__) 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) @router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600) @rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace( async def import_products_from_marketplace(
@@ -43,7 +36,7 @@ async def import_products_from_marketplace(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Import products from marketplace CSV with background processing (Protected).""" """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( logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} " 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), db: Session = Depends(get_db),
): ):
"""Get status of marketplace import job (Protected).""" """Get status of marketplace import job (Protected)."""
vendor = _get_vendor_from_token(current_user, db) # Service validates that job belongs to vendor and raises UnauthorizedVendorAccessException if not
job = marketplace_import_job_service.get_import_job_for_vendor(
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user) db, job_id, current_user.token_vendor_id
)
# Verify job belongs to current vendor
if job.vendor_id != vendor.id:
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
return marketplace_import_job_service.convert_to_response_model(job) return marketplace_import_job_service.convert_to_response_model(job)
@@ -110,7 +100,7 @@ def get_marketplace_import_jobs(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get marketplace import jobs for current vendor (Protected).""" """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( jobs = marketplace_import_job_service.get_import_jobs(
db=db, db=db,

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
# app/api/v1/vendor/orders.py # app/api/v1/vendor/orders.py
""" """
Vendor order management endpoints. 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 import logging
@@ -42,20 +45,9 @@ def get_vendor_orders(
Vendor is determined from JWT token (vendor_id claim). Vendor is determined from JWT token (vendor_id claim).
Requires Authorization header (API endpoint). 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( orders, total = order_service.get_vendor_orders(
db=db, db=db,
vendor_id=vendor_id, vendor_id=current_user.token_vendor_id,
skip=skip, skip=skip,
limit=limit, limit=limit,
status=status, status=status,
@@ -81,18 +73,9 @@ def get_order_details(
Requires Authorization header (API endpoint). Requires Authorization header (API endpoint).
""" """
from fastapi import HTTPException order = order_service.get_order(
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
# 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)
return OrderDetailResponse.model_validate(order) return OrderDetailResponse.model_validate(order)
@@ -117,19 +100,11 @@ def update_order_status(
Requires Authorization header (API endpoint). 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( 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( logger.info(

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
""" """
Vendor profile management endpoints. 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 import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
from models.schema.vendor import VendorResponse, VendorUpdate from models.schema.vendor import VendorResponse, VendorUpdate
@@ -21,20 +21,13 @@ router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__) 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) @router.get("", response_model=VendorResponse)
def get_vendor_profile( def get_vendor_profile(
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get current vendor profile information.""" """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 return vendor
@@ -45,10 +38,7 @@ def update_vendor_profile(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update vendor profile information.""" """Update vendor profile information."""
vendor = _get_vendor_from_token(current_user, db) # Service handles permission checking and raises InsufficientPermissionsException if needed
return vendor_service.update_vendor(
# Verify user has permission to update vendor db, current_user.token_vendor_id, vendor_update, current_user
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)

View File

@@ -2,7 +2,8 @@
""" """
Vendor settings and configuration endpoints. 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 import logging
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
@@ -26,10 +26,6 @@ def get_vendor_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get vendor settings and configuration.""" """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) vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return { return {
@@ -56,32 +52,7 @@ def update_marketplace_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update marketplace integration settings.""" """Update marketplace integration settings."""
# Get vendor ID from JWT token # Service handles permission checking and raises InsufficientPermissionsException if needed
if not hasattr(current_user, "token_vendor_id"): return vendor_service.update_marketplace_settings(
raise InvalidTokenException("Token missing vendor information. Please login again.") db, current_user.token_vendor_id, marketplace_config, current_user
)
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,
}

View File

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

View File

@@ -94,6 +94,50 @@ class MarketplaceImportJobService:
logger.error(f"Error getting import job {job_id}: {str(e)}") logger.error(f"Error getting import job {job_id}: {str(e)}")
raise ValidationException("Failed to retrieve import job") 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( def get_import_jobs(
self, self,
db: Session, db: Session,

View File

@@ -252,6 +252,44 @@ class VendorService:
return vendor 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: def get_vendor_by_identifier(self, db: Session, identifier: str) -> Vendor:
""" """
Get vendor by ID or vendor_code (admin use - no access control). 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).""" """Check if user is vendor owner (via company ownership)."""
return vendor.company and vendor.company.owner_user_id == user.id 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 # Create service instance following the same pattern as other services
vendor_service = VendorService() vendor_service = VendorService()

View File

@@ -453,14 +453,37 @@ current_user: User = Depends(get_current_vendor_from_cookie_or_header)
**Purpose:** Authenticate vendor users for API endpoints **Purpose:** Authenticate vendor users for API endpoints
**Accepts:** Authorization header ONLY **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:** **Raises:**
- `InvalidTokenException` - No token or invalid token - `InvalidTokenException` - No token, invalid token, or **missing vendor context in token**
- `InsufficientPermissionsException` - User is not vendor or is admin - `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:** **Usage:**
```python ```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()` #### `get_current_customer_from_cookie_or_header()`

View File

@@ -195,27 +195,20 @@ def get_current_vendor_api(
def get_vendor_products( def get_vendor_products(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), 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), db: Session = Depends(get_db),
): ):
""" """
Get all products in vendor catalog. Get all products in vendor catalog.
Vendor is determined from JWT token (vendor_id claim). 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 # Use vendor_id from token for business logic
# NO validation needed - dependency guarantees token_vendor_id exists
products, total = product_service.get_vendor_products( products, total = product_service.get_vendor_products(
db=db, db=db,
vendor_id=vendor_id, vendor_id=current_user.token_vendor_id, # Safe to use directly
skip=skip, skip=skip,
limit=limit, limit=limit,
) )
@@ -223,6 +216,9 @@ def get_vendor_products(
return ProductListResponse(products=products, total=total) 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 ## Migration Guide
### Step 1: Identify Endpoints Using require_vendor_context() ### Step 1: Identify Endpoints Using require_vendor_context()
@@ -264,21 +260,14 @@ product = product_service.get_product(db, vendor.id, product_id)
**After:** **After:**
```python ```python
from fastapi import HTTPException # Use vendor_id from token directly - dependency guarantees it exists
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
# 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)
``` ```
> **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 ### Step 4: Update Logging References
**Before:** **Before:**
@@ -325,24 +314,14 @@ def update_product(
def update_product( def update_product(
product_id: int, product_id: int,
product_data: ProductUpdate, 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), db: Session = Depends(get_db),
): ):
"""Update product in vendor catalog.""" """Update product in vendor catalog."""
from fastapi import HTTPException # NO validation needed - dependency guarantees token_vendor_id exists
# 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
product = product_service.update_product( product = product_service.update_product(
db=db, 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_id=product_id,
product_update=product_data product_update=product_data
) )
@@ -355,6 +334,9 @@ def update_product(
return ProductResponse.model_validate(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 ## Migration Status
**COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern. **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 # 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 ## Related Documentation
@@ -519,4 +573,4 @@ The vendor-in-token architecture:
- ✅ Simplifies endpoint implementation - ✅ Simplifies endpoint implementation
- ✅ Follows architecture best practices - ✅ Follows architecture best practices
**Migration Status:** In progress - 9 endpoint files remaining to migrate **Migration Status:** ✅ COMPLETED - All vendor API endpoints migrated and architecture rules enforced

View File

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

View File

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

View File

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

View File

@@ -517,39 +517,72 @@ class ArchitectureValidator:
def _check_endpoint_exception_handling( def _check_endpoint_exception_handling(
self, file_path: Path, content: str, lines: list[str] 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 The architecture uses:
exceptions (WizamartException subclasses) and converts them to HTTP - Dependencies (deps.py) for authentication/authorization validation
responses. Endpoints should let exceptions bubble up, not catch and - Services for business logic validation
convert them manually. - 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") rule = self._get_rule("API-003")
if not rule: if not rule:
return return
# Skip exception handler file - it's allowed to use HTTPException # Skip exception handler file and deps.py - they're allowed to raise exceptions
if "exceptions/handler.py" in str(file_path): file_path_str = str(file_path)
if "exceptions/handler.py" in file_path_str or file_path_str.endswith("deps.py"):
return return
for i, line in enumerate(lines, 1): # Patterns that indicate endpoints are raising exceptions (BAD)
# Check for raise HTTPException exception_patterns = [
if "raise HTTPException" in line: ("raise HTTPException", "Endpoint raises HTTPException directly"),
# Skip if it's a comment ("raise InvalidTokenException", "Endpoint raises InvalidTokenException - move to dependency"),
stripped = line.strip() ("raise InsufficientPermissionsException", "Endpoint raises permission exception - move to dependency"),
if stripped.startswith("#"): ("raise UnauthorizedVendorAccessException", "Endpoint raises auth exception - move to dependency or service"),
continue ]
self._add_violation( # Pattern that indicates redundant validation (BAD)
rule_id="API-003", redundant_patterns = [
rule_name=rule["name"], (r"if not hasattr\(current_user.*token_vendor", "Redundant token_vendor check - get_current_vendor_api guarantees this"),
severity=Severity.ERROR, (r"if not hasattr\(current_user.*token_vendor_id", "Redundant token_vendor_id check - dependency guarantees this"),
file_path=file_path, ]
line_number=i,
message="Endpoint raises HTTPException directly", for i, line in enumerate(lines, 1):
context=line.strip()[:80], # Skip comments
suggestion="Use domain exceptions (e.g., VendorNotFoundException) and let global handler convert", 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( def _check_endpoint_authentication(
self, file_path: Path, content: str, lines: list[str] self, file_path: Path, content: str, lines: list[str]
@@ -570,7 +603,21 @@ class ArchitectureValidator:
return return
# This is a warning-level check # 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): for i, line in enumerate(lines, 1):
if "@router." in line and ( if "@router." in line and (
"post" in line or "put" in line or "delete" in line "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 context_lines = lines[i - 1 : i + 15] # Include line before decorator
for ctx_line in context_lines: 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 has_auth = True
break break
# Check for public endpoint markers # 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( if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
lines[i : i + 15] 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( self._add_violation(
rule_id="API-004", rule_id="API-004",
rule_name=rule["name"], rule_name=rule["name"],
@@ -601,7 +660,7 @@ class ArchitectureValidator:
line_number=i, line_number=i,
message="Endpoint may be missing authentication", message="Endpoint may be missing authentication",
context=line.strip(), 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): def _validate_service_layer(self, target_path: Path):