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:
@@ -75,17 +75,42 @@ api_endpoint_rules:
|
||||
# NOTE: db.commit() is intentionally NOT listed - it's allowed for transaction control
|
||||
|
||||
- id: "API-003"
|
||||
name: "Endpoint must NOT raise HTTPException directly"
|
||||
name: "Endpoint must NOT raise ANY exceptions directly"
|
||||
severity: "error"
|
||||
description: |
|
||||
API endpoints should NOT raise HTTPException directly. Instead, let domain
|
||||
exceptions bubble up to the global exception handler which converts them
|
||||
to appropriate HTTP responses. This ensures consistent error formatting
|
||||
and centralized error handling.
|
||||
API endpoints should NOT raise exceptions directly. Endpoints are a thin
|
||||
orchestration layer that:
|
||||
1. Accepts request parameters (validated by Pydantic)
|
||||
2. Calls dependencies for authentication/authorization (deps.py raises exceptions)
|
||||
3. Calls services for business logic (services raise domain exceptions)
|
||||
4. Returns response (formatted by Pydantic)
|
||||
|
||||
Exception raising belongs in:
|
||||
- Dependencies (app/api/deps.py) - authentication/authorization validation
|
||||
- Services (app/services/) - business logic validation
|
||||
|
||||
The global exception handler catches all WizamartException subclasses and
|
||||
converts them to appropriate HTTP responses.
|
||||
|
||||
WRONG (endpoint raises exception):
|
||||
@router.get("/orders")
|
||||
def get_orders(current_user: User = Depends(get_current_vendor_api)):
|
||||
if not hasattr(current_user, "token_vendor_id"): # ❌ Redundant check
|
||||
raise InvalidTokenException("...") # ❌ Don't raise here
|
||||
return order_service.get_orders(db, current_user.token_vendor_id)
|
||||
|
||||
RIGHT (dependency guarantees, endpoint trusts):
|
||||
@router.get("/orders")
|
||||
def get_orders(current_user: User = Depends(get_current_vendor_api)):
|
||||
# Dependency guarantees token_vendor_id is present
|
||||
return order_service.get_orders(db, current_user.token_vendor_id)
|
||||
pattern:
|
||||
file_pattern: "app/api/v1/**/*.py"
|
||||
anti_patterns:
|
||||
- "raise HTTPException"
|
||||
- "raise InvalidTokenException"
|
||||
- "raise InsufficientPermissionsException"
|
||||
- "if not hasattr\\(current_user.*token_vendor"
|
||||
exceptions:
|
||||
- "app/exceptions/handler.py" # Handler is allowed to use HTTPException
|
||||
|
||||
|
||||
@@ -275,7 +275,9 @@ def get_current_vendor_api(
|
||||
Get current vendor user from Authorization header ONLY.
|
||||
|
||||
Used for vendor API endpoints that should not accept cookies.
|
||||
Validates that user still has access to the vendor specified in the token.
|
||||
Validates that:
|
||||
1. Token contains vendor context (token_vendor_id)
|
||||
2. User still has access to the vendor specified in the token
|
||||
|
||||
Args:
|
||||
credentials: Bearer token from Authorization header
|
||||
@@ -285,7 +287,7 @@ def get_current_vendor_api(
|
||||
User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role)
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token or invalid token
|
||||
InvalidTokenException: If no token, invalid token, or missing vendor context
|
||||
InsufficientPermissionsException: If user is not vendor or lost access to vendor
|
||||
"""
|
||||
if not credentials:
|
||||
@@ -302,23 +304,25 @@ def get_current_vendor_api(
|
||||
logger.warning(f"Non-vendor user {user.username} attempted vendor API")
|
||||
raise InsufficientPermissionsException("Vendor privileges required")
|
||||
|
||||
# Validate vendor access if token is vendor-scoped
|
||||
if hasattr(user, "token_vendor_id"):
|
||||
vendor_id = user.token_vendor_id
|
||||
# Require vendor context in token
|
||||
if not hasattr(user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
# Verify user still has access to this vendor
|
||||
if not user.is_member_of(vendor_id):
|
||||
logger.warning(
|
||||
f"User {user.username} lost access to vendor_id={vendor_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to vendor has been revoked. Please login again."
|
||||
)
|
||||
vendor_id = user.token_vendor_id
|
||||
|
||||
logger.debug(
|
||||
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
|
||||
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
|
||||
# Verify user still has access to this vendor
|
||||
if not user.is_member_of(vendor_id):
|
||||
logger.warning(
|
||||
f"User {user.username} lost access to vendor_id={vendor_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to vendor has been revoked. Please login again."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
|
||||
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ from models.schema.admin import (
|
||||
PlatformAlertCreate,
|
||||
PlatformAlertListResponse,
|
||||
PlatformAlertResolve,
|
||||
PlatformAlertResponse,
|
||||
)
|
||||
from models.schema.notification import (
|
||||
AlertStatisticsResponse,
|
||||
MessageResponse,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
@@ -49,17 +53,17 @@ def get_notifications(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
def get_unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get count of unread notifications."""
|
||||
# TODO: Implement
|
||||
return {"unread_count": 0}
|
||||
return UnreadCountResponse(unread_count=0)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
@router.put("/{notification_id}/read", response_model=MessageResponse)
|
||||
def mark_as_read(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -67,17 +71,17 @@ def mark_as_read(
|
||||
):
|
||||
"""Mark notification as read."""
|
||||
# TODO: Implement
|
||||
return {"message": "Notification marked as read"}
|
||||
return MessageResponse(message="Notification marked as read")
|
||||
|
||||
|
||||
@router.put("/mark-all-read")
|
||||
@router.put("/mark-all-read", response_model=MessageResponse)
|
||||
def mark_all_as_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Mark all notifications as read."""
|
||||
# TODO: Implement
|
||||
return {"message": "All notifications marked as read"}
|
||||
return MessageResponse(message="All notifications marked as read")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -101,19 +105,19 @@ def get_platform_alerts(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts", response_model=PlatformAlertResponse)
|
||||
@router.post("/alerts", response_model=MessageResponse)
|
||||
def create_platform_alert(
|
||||
alert_data: PlatformAlertCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create new platform alert (manual)."""
|
||||
# TODO: Implement
|
||||
# TODO: Implement - return PlatformAlertResponse when service is ready
|
||||
logger.info(f"Admin {current_admin.username} created alert: {alert_data.title}")
|
||||
return {}
|
||||
return MessageResponse(message="Platform alert creation coming soon")
|
||||
|
||||
|
||||
@router.put("/alerts/{alert_id}/resolve")
|
||||
@router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
|
||||
def resolve_platform_alert(
|
||||
alert_id: int,
|
||||
resolve_data: PlatformAlertResolve,
|
||||
@@ -123,19 +127,19 @@ def resolve_platform_alert(
|
||||
"""Resolve platform alert."""
|
||||
# TODO: Implement
|
||||
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
|
||||
return {"message": "Alert resolved successfully"}
|
||||
return MessageResponse(message="Alert resolved successfully")
|
||||
|
||||
|
||||
@router.get("/alerts/stats")
|
||||
@router.get("/alerts/stats", response_model=AlertStatisticsResponse)
|
||||
def get_alert_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get alert statistics for dashboard."""
|
||||
# TODO: Implement
|
||||
return {
|
||||
"total_alerts": 0,
|
||||
"active_alerts": 0,
|
||||
"critical_alerts": 0,
|
||||
"resolved_today": 0,
|
||||
}
|
||||
return AlertStatisticsResponse(
|
||||
total_alerts=0,
|
||||
active_alerts=0,
|
||||
critical_alerts=0,
|
||||
resolved_today=0,
|
||||
)
|
||||
|
||||
14
app/api/v1/vendor/analytics.py
vendored
14
app/api/v1/vendor/analytics.py
vendored
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Vendor analytics and reporting endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.stats_service import stats_service
|
||||
from models.database.user import User
|
||||
|
||||
@@ -20,13 +20,6 @@ router = APIRouter(prefix="/analytics")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_id_from_token(current_user: User) -> int:
|
||||
"""Helper to get vendor_id from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return current_user.token_vendor_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_vendor_analytics(
|
||||
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
|
||||
@@ -34,5 +27,4 @@ def get_vendor_analytics(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor analytics data for specified time period."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return stats_service.get_vendor_analytics(db, vendor_id, period)
|
||||
return stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)
|
||||
|
||||
24
app/api/v1/vendor/customers.py
vendored
24
app/api/v1/vendor/customers.py
vendored
@@ -1,9 +1,9 @@
|
||||
# Vendor customer management
|
||||
# app/api/v1/vendor/customers.py
|
||||
"""
|
||||
Vendor customer management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
|
||||
@@ -21,13 +20,6 @@ router = APIRouter(prefix="/customers")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_vendor_customers(
|
||||
skip: int = Query(0, ge=0),
|
||||
@@ -46,7 +38,7 @@ def get_vendor_customers(
|
||||
- Support filtering by active status
|
||||
- Return paginated results
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {
|
||||
"customers": [],
|
||||
"total": 0,
|
||||
@@ -71,7 +63,7 @@ def get_customer_details(
|
||||
- Include order history
|
||||
- Include total spent, etc.
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {"message": "Customer details coming in Slice 4"}
|
||||
|
||||
|
||||
@@ -89,7 +81,7 @@ def get_customer_orders(
|
||||
- Filter by vendor_id
|
||||
- Return order details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {"orders": [], "message": "Customer orders coming in Slice 5"}
|
||||
|
||||
|
||||
@@ -108,7 +100,7 @@ def update_customer(
|
||||
- Verify customer belongs to vendor
|
||||
- Update customer preferences
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {"message": "Customer update coming in Slice 4"}
|
||||
|
||||
|
||||
@@ -126,7 +118,7 @@ def toggle_customer_status(
|
||||
- Verify customer belongs to vendor
|
||||
- Log the change
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {"message": "Customer status toggle coming in Slice 4"}
|
||||
|
||||
|
||||
@@ -145,7 +137,7 @@ def get_customer_statistics(
|
||||
- Average order value
|
||||
- Last order date
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return {
|
||||
"total_orders": 0,
|
||||
"total_spent": 0.0,
|
||||
|
||||
9
app/api/v1/vendor/dashboard.py
vendored
9
app/api/v1/vendor/dashboard.py
vendored
@@ -1,6 +1,9 @@
|
||||
# app/api/v1/vendor/dashboard.py
|
||||
"""
|
||||
Vendor dashboard and statistics endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -10,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException, VendorNotActiveException
|
||||
from app.exceptions import VendorNotActiveException
|
||||
from app.services.stats_service import stats_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
@@ -37,10 +40,6 @@ def get_vendor_dashboard_stats(
|
||||
Vendor is determined from the JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
# Get vendor ID from token (set by get_current_vendor_api)
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get vendor object (raises VendorNotFoundException if not found)
|
||||
|
||||
36
app/api/v1/vendor/info.py
vendored
36
app/api/v1/vendor/info.py
vendored
@@ -10,48 +10,16 @@ This module provides:
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import VendorNotFoundException
|
||||
from models.database.vendor import Vendor
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.schema.vendor import VendorDetailResponse
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_by_code(db: Session, vendor_code: str) -> Vendor:
|
||||
"""
|
||||
Helper to get active vendor by vendor_code.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Vendor object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found or inactive
|
||||
"""
|
||||
vendor = (
|
||||
db.query(Vendor)
|
||||
.filter(
|
||||
func.upper(Vendor.vendor_code) == vendor_code.upper(),
|
||||
Vendor.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not vendor:
|
||||
logger.warning(f"Vendor not found or inactive: {vendor_code}")
|
||||
raise VendorNotFoundException(vendor_code, identifier_type="code")
|
||||
|
||||
return vendor
|
||||
|
||||
|
||||
@router.get("/{vendor_code}", response_model=VendorDetailResponse)
|
||||
def get_vendor_info(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
@@ -81,7 +49,7 @@ def get_vendor_info(
|
||||
"""
|
||||
logger.info(f"Public vendor info request: {vendor_code}")
|
||||
|
||||
vendor = _get_vendor_by_code(db, vendor_code)
|
||||
vendor = vendor_service.get_active_vendor_by_code(db, vendor_code)
|
||||
|
||||
logger.info(f"Vendor info retrieved: {vendor.name} ({vendor.vendor_code})")
|
||||
|
||||
|
||||
38
app/api/v1/vendor/inventory.py
vendored
38
app/api/v1/vendor/inventory.py
vendored
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Vendor inventory management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -11,7 +12,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.inventory_service import inventory_service
|
||||
from models.database.user import User
|
||||
from models.schema.inventory import (
|
||||
@@ -28,13 +28,6 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_id_from_token(current_user: User) -> int:
|
||||
"""Helper to get vendor_id from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return current_user.token_vendor_id
|
||||
|
||||
|
||||
@router.post("/inventory/set", response_model=InventoryResponse)
|
||||
def set_inventory(
|
||||
inventory: InventoryCreate,
|
||||
@@ -42,8 +35,7 @@ def set_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set exact inventory quantity (replaces existing)."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.set_inventory(db, vendor_id, inventory)
|
||||
return inventory_service.set_inventory(db, current_user.token_vendor_id, inventory)
|
||||
|
||||
|
||||
@router.post("/inventory/adjust", response_model=InventoryResponse)
|
||||
@@ -53,8 +45,7 @@ def adjust_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Adjust inventory (positive to add, negative to remove)."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
|
||||
return inventory_service.adjust_inventory(db, current_user.token_vendor_id, adjustment)
|
||||
|
||||
|
||||
@router.post("/inventory/reserve", response_model=InventoryResponse)
|
||||
@@ -64,8 +55,7 @@ def reserve_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reserve inventory for an order."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.reserve_inventory(db, vendor_id, reservation)
|
||||
return inventory_service.reserve_inventory(db, current_user.token_vendor_id, reservation)
|
||||
|
||||
|
||||
@router.post("/inventory/release", response_model=InventoryResponse)
|
||||
@@ -75,8 +65,7 @@ def release_reservation(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Release reserved inventory (cancel order)."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.release_reservation(db, vendor_id, reservation)
|
||||
return inventory_service.release_reservation(db, current_user.token_vendor_id, reservation)
|
||||
|
||||
|
||||
@router.post("/inventory/fulfill", response_model=InventoryResponse)
|
||||
@@ -86,8 +75,7 @@ def fulfill_reservation(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fulfill reservation (complete order, remove from stock)."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
|
||||
return inventory_service.fulfill_reservation(db, current_user.token_vendor_id, reservation)
|
||||
|
||||
|
||||
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
|
||||
@@ -97,8 +85,7 @@ def get_product_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get inventory summary for a product."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.get_product_inventory(db, vendor_id, product_id)
|
||||
return inventory_service.get_product_inventory(db, current_user.token_vendor_id, product_id)
|
||||
|
||||
|
||||
@router.get("/inventory", response_model=InventoryListResponse)
|
||||
@@ -111,9 +98,8 @@ def get_vendor_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get all inventory for vendor."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
inventories = inventory_service.get_vendor_inventory(
|
||||
db, vendor_id, skip, limit, location, low_stock
|
||||
db, current_user.token_vendor_id, skip, limit, location, low_stock
|
||||
)
|
||||
|
||||
# Get total count
|
||||
@@ -132,9 +118,8 @@ def update_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update inventory entry."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
return inventory_service.update_inventory(
|
||||
db, vendor_id, inventory_id, inventory_update
|
||||
db, current_user.token_vendor_id, inventory_id, inventory_update
|
||||
)
|
||||
|
||||
|
||||
@@ -145,6 +130,5 @@ def delete_inventory(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete inventory entry."""
|
||||
vendor_id = _get_vendor_id_from_token(current_user)
|
||||
inventory_service.delete_inventory(db, vendor_id, inventory_id)
|
||||
inventory_service.delete_inventory(db, current_user.token_vendor_id, inventory_id)
|
||||
return {"message": "Inventory deleted successfully"}
|
||||
|
||||
26
app/api/v1/vendor/marketplace.py
vendored
26
app/api/v1/vendor/marketplace.py
vendored
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Marketplace import endpoints for vendors.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
|
||||
from app.services.marketplace_import_job_service import marketplace_import_job_service
|
||||
from app.services.vendor_service import vendor_service
|
||||
from app.tasks.background_tasks import process_marketplace_import
|
||||
@@ -27,13 +27,6 @@ router = APIRouter(prefix="/marketplace")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MarketplaceImportJobResponse)
|
||||
@rate_limit(max_requests=10, window_seconds=3600)
|
||||
async def import_products_from_marketplace(
|
||||
@@ -43,7 +36,7 @@ async def import_products_from_marketplace(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Import products from marketplace CSV with background processing (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
logger.info(
|
||||
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
|
||||
@@ -90,13 +83,10 @@ def get_marketplace_import_status(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get status of marketplace import job (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
|
||||
|
||||
# Verify job belongs to current vendor
|
||||
if job.vendor_id != vendor.id:
|
||||
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
|
||||
# Service validates that job belongs to vendor and raises UnauthorizedVendorAccessException if not
|
||||
job = marketplace_import_job_service.get_import_job_for_vendor(
|
||||
db, job_id, current_user.token_vendor_id
|
||||
)
|
||||
|
||||
return marketplace_import_job_service.convert_to_response_model(job)
|
||||
|
||||
@@ -110,7 +100,7 @@ def get_marketplace_import_jobs(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get marketplace import jobs for current vendor (Protected)."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
jobs = marketplace_import_job_service.get_import_jobs(
|
||||
db=db,
|
||||
|
||||
107
app/api/v1/vendor/media.py
vendored
107
app/api/v1/vendor/media.py
vendored
@@ -1,9 +1,9 @@
|
||||
# File and media management
|
||||
# app/api/v1/vendor/media.py
|
||||
"""
|
||||
Vendor media and file management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,22 +13,23 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.schema.media import (
|
||||
MediaDetailResponse,
|
||||
MediaListResponse,
|
||||
MediaMetadataUpdate,
|
||||
MediaUploadResponse,
|
||||
MediaUsageResponse,
|
||||
MultipleUploadResponse,
|
||||
OptimizationResultResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/media")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", response_model=MediaListResponse)
|
||||
def get_media_library(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
@@ -47,17 +48,17 @@ def get_media_library(
|
||||
- Support pagination
|
||||
- Return file URLs, sizes, metadata
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"media": [],
|
||||
"total": 0,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"message": "Media library coming in Slice 3",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaListResponse(
|
||||
media=[],
|
||||
total=0,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
message="Media library coming in Slice 3",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@router.post("/upload", response_model=MediaUploadResponse)
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
folder: str | None = Query(None, description="products, general, etc."),
|
||||
@@ -75,15 +76,15 @@ async def upload_media(
|
||||
- Save metadata to database
|
||||
- Return file URL
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"file_url": None,
|
||||
"thumbnail_url": None,
|
||||
"message": "Media upload coming in Slice 3",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaUploadResponse(
|
||||
file_url=None,
|
||||
thumbnail_url=None,
|
||||
message="Media upload coming in Slice 3",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload/multiple")
|
||||
@router.post("/upload/multiple", response_model=MultipleUploadResponse)
|
||||
async def upload_multiple_media(
|
||||
files: list[UploadFile] = File(...),
|
||||
folder: str | None = Query(None),
|
||||
@@ -99,15 +100,15 @@ async def upload_multiple_media(
|
||||
- Return list of uploaded file URLs
|
||||
- Handle errors gracefully
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"uploaded_files": [],
|
||||
"failed_files": [],
|
||||
"message": "Multiple upload coming in Slice 3",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MultipleUploadResponse(
|
||||
uploaded_files=[],
|
||||
failed_files=[],
|
||||
message="Multiple upload coming in Slice 3",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{media_id}")
|
||||
@router.get("/{media_id}", response_model=MediaDetailResponse)
|
||||
def get_media_details(
|
||||
media_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -121,14 +122,14 @@ def get_media_details(
|
||||
- Return file URL
|
||||
- Return usage information (which products use this file)
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media details coming in Slice 3"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaDetailResponse(message="Media details coming in Slice 3")
|
||||
|
||||
|
||||
@router.put("/{media_id}")
|
||||
@router.put("/{media_id}", response_model=MediaDetailResponse)
|
||||
def update_media_metadata(
|
||||
media_id: int,
|
||||
metadata: dict,
|
||||
metadata: MediaMetadataUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -141,11 +142,11 @@ def update_media_metadata(
|
||||
- Update tags/categories
|
||||
- Update description
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media update coming in Slice 3"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaDetailResponse(message="Media update coming in Slice 3")
|
||||
|
||||
|
||||
@router.delete("/{media_id}")
|
||||
@router.delete("/{media_id}", response_model=MediaDetailResponse)
|
||||
def delete_media(
|
||||
media_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -161,11 +162,11 @@ def delete_media(
|
||||
- Delete database record
|
||||
- Return success/error
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media deletion coming in Slice 3"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaDetailResponse(message="Media deletion coming in Slice 3")
|
||||
|
||||
|
||||
@router.get("/{media_id}/usage")
|
||||
@router.get("/{media_id}/usage", response_model=MediaUsageResponse)
|
||||
def get_media_usage(
|
||||
media_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -179,15 +180,15 @@ def get_media_usage(
|
||||
- Check other entities using this media
|
||||
- Return list of usage
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"products": [],
|
||||
"other_usage": [],
|
||||
"message": "Media usage tracking coming in Slice 3",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MediaUsageResponse(
|
||||
products=[],
|
||||
other_usage=[],
|
||||
message="Media usage tracking coming in Slice 3",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/optimize/{media_id}")
|
||||
@router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)
|
||||
def optimize_media(
|
||||
media_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -202,5 +203,5 @@ def optimize_media(
|
||||
- Keep original
|
||||
- Update database with new versions
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Media optimization coming in Slice 3"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return OptimizationResultResponse(message="Media optimization coming in Slice 3")
|
||||
|
||||
110
app/api/v1/vendor/notifications.py
vendored
110
app/api/v1/vendor/notifications.py
vendored
@@ -1,9 +1,9 @@
|
||||
# Notification management
|
||||
# app/api/v1/vendor/notifications.py
|
||||
"""
|
||||
Vendor notification management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,22 +13,24 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.schema.notification import (
|
||||
MessageResponse,
|
||||
NotificationListResponse,
|
||||
NotificationSettingsResponse,
|
||||
NotificationSettingsUpdate,
|
||||
NotificationTemplateListResponse,
|
||||
NotificationTemplateUpdate,
|
||||
TestNotificationRequest,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
def get_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
@@ -45,16 +47,16 @@ def get_notifications(
|
||||
- Support pagination
|
||||
- Return notification details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"notifications": [],
|
||||
"total": 0,
|
||||
"unread_count": 0,
|
||||
"message": "Notifications coming in Slice 5",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return NotificationListResponse(
|
||||
notifications=[],
|
||||
total=0,
|
||||
unread_count=0,
|
||||
message="Notifications coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
def get_unread_count(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -66,11 +68,11 @@ def get_unread_count(
|
||||
- Count unread notifications for vendor
|
||||
- Used for notification badge
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return UnreadCountResponse(unread_count=0, message="Unread count coming in Slice 5")
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
@router.put("/{notification_id}/read", response_model=MessageResponse)
|
||||
def mark_as_read(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -83,11 +85,11 @@ def mark_as_read(
|
||||
- Mark single notification as read
|
||||
- Update read timestamp
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Mark as read coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Mark as read coming in Slice 5")
|
||||
|
||||
|
||||
@router.put("/mark-all-read")
|
||||
@router.put("/mark-all-read", response_model=MessageResponse)
|
||||
def mark_all_as_read(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -99,11 +101,11 @@ def mark_all_as_read(
|
||||
- Mark all vendor notifications as read
|
||||
- Update timestamps
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Mark all as read coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Mark all as read coming in Slice 5")
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
@router.delete("/{notification_id}", response_model=MessageResponse)
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
@@ -116,11 +118,11 @@ def delete_notification(
|
||||
- Delete single notification
|
||||
- Verify notification belongs to vendor
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Notification deletion coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Notification deletion coming in Slice 5")
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
@router.get("/settings", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -133,18 +135,18 @@ def get_notification_settings(
|
||||
- Get in-app notification settings
|
||||
- Get notification types enabled/disabled
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"email_notifications": True,
|
||||
"in_app_notifications": True,
|
||||
"notification_types": {},
|
||||
"message": "Notification settings coming in Slice 5",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return NotificationSettingsResponse(
|
||||
email_notifications=True,
|
||||
in_app_notifications=True,
|
||||
notification_types={},
|
||||
message="Notification settings coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
@router.put("/settings", response_model=MessageResponse)
|
||||
def update_notification_settings(
|
||||
settings: dict,
|
||||
settings: NotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -156,11 +158,11 @@ def update_notification_settings(
|
||||
- Update in-app notification settings
|
||||
- Enable/disable specific notification types
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Notification settings update coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Notification settings update coming in Slice 5")
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
@router.get("/templates", response_model=NotificationTemplateListResponse)
|
||||
def get_notification_templates(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -173,14 +175,16 @@ def get_notification_templates(
|
||||
- Include: order confirmation, shipping notification, etc.
|
||||
- Return template details
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"templates": [], "message": "Notification templates coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return NotificationTemplateListResponse(
|
||||
templates=[], message="Notification templates coming in Slice 5"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
@router.put("/templates/{template_id}", response_model=MessageResponse)
|
||||
def update_notification_template(
|
||||
template_id: int,
|
||||
template_data: dict,
|
||||
template_data: NotificationTemplateUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -193,13 +197,13 @@ def update_notification_template(
|
||||
- Validate template variables
|
||||
- Preview template
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Template update coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Template update coming in Slice 5")
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
@router.post("/test", response_model=MessageResponse)
|
||||
def send_test_notification(
|
||||
notification_data: dict,
|
||||
notification_data: TestNotificationRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -211,5 +215,5 @@ def send_test_notification(
|
||||
- Use specified template
|
||||
- Send to current user's email
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Test notification coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return MessageResponse(message="Test notification coming in Slice 5")
|
||||
|
||||
47
app/api/v1/vendor/orders.py
vendored
47
app/api/v1/vendor/orders.py
vendored
@@ -1,6 +1,9 @@
|
||||
# app/api/v1/vendor/orders.py
|
||||
"""
|
||||
Vendor order management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -42,20 +45,9 @@ def get_vendor_orders(
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
orders, total = order_service.get_vendor_orders(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
@@ -81,18 +73,9 @@ def get_order_details(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id)
|
||||
order = order_service.get_order(
|
||||
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
|
||||
)
|
||||
|
||||
return OrderDetailResponse.model_validate(order)
|
||||
|
||||
@@ -117,19 +100,11 @@ def update_order_status(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.update_order_status(
|
||||
db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=order_id,
|
||||
order_update=order_update,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
116
app/api/v1/vendor/payments.py
vendored
116
app/api/v1/vendor/payments.py
vendored
@@ -1,9 +1,9 @@
|
||||
# Payment configuration and processing
|
||||
# app/api/v1/vendor/payments.py
|
||||
"""
|
||||
Vendor payment configuration and processing endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -13,22 +13,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.schema.payment import (
|
||||
PaymentBalanceResponse,
|
||||
PaymentConfigResponse,
|
||||
PaymentConfigUpdate,
|
||||
PaymentConfigUpdateResponse,
|
||||
PaymentMethodsResponse,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
StripeConnectRequest,
|
||||
StripeConnectResponse,
|
||||
StripeDisconnectResponse,
|
||||
TransactionsResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/payments")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
@router.get("/config", response_model=PaymentConfigResponse)
|
||||
def get_payment_configuration(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -42,19 +47,19 @@ def get_payment_configuration(
|
||||
- Get currency settings
|
||||
- Return masked/secure information only
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"payment_gateway": None,
|
||||
"accepted_methods": [],
|
||||
"currency": "EUR",
|
||||
"stripe_connected": False,
|
||||
"message": "Payment configuration coming in Slice 5",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return PaymentConfigResponse(
|
||||
payment_gateway=None,
|
||||
accepted_methods=[],
|
||||
currency="EUR",
|
||||
stripe_connected=False,
|
||||
message="Payment configuration coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
@router.put("/config", response_model=PaymentConfigUpdateResponse)
|
||||
def update_payment_configuration(
|
||||
payment_config: dict,
|
||||
payment_config: PaymentConfigUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -67,13 +72,15 @@ def update_payment_configuration(
|
||||
- Update accepted payment methods
|
||||
- Validate configuration before saving
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Payment configuration update coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return PaymentConfigUpdateResponse(
|
||||
message="Payment configuration update coming in Slice 5"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stripe/connect")
|
||||
@router.post("/stripe/connect", response_model=StripeConnectResponse)
|
||||
def connect_stripe_account(
|
||||
stripe_data: dict,
|
||||
stripe_data: StripeConnectRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -86,11 +93,11 @@ def connect_stripe_account(
|
||||
- Verify Stripe account is active
|
||||
- Enable payment processing
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Stripe connection coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return StripeConnectResponse(message="Stripe connection coming in Slice 5")
|
||||
|
||||
|
||||
@router.delete("/stripe/disconnect")
|
||||
@router.delete("/stripe/disconnect", response_model=StripeDisconnectResponse)
|
||||
def disconnect_stripe_account(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -103,11 +110,11 @@ def disconnect_stripe_account(
|
||||
- Disable payment processing
|
||||
- Warn about pending payments
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Stripe disconnection coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return StripeDisconnectResponse(message="Stripe disconnection coming in Slice 5")
|
||||
|
||||
|
||||
@router.get("/methods")
|
||||
@router.get("/methods", response_model=PaymentMethodsResponse)
|
||||
def get_payment_methods(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -119,11 +126,14 @@ def get_payment_methods(
|
||||
- Return list of enabled payment methods
|
||||
- Include: credit card, PayPal, bank transfer, etc.
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"methods": [], "message": "Payment methods coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return PaymentMethodsResponse(
|
||||
methods=[],
|
||||
message="Payment methods coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
@router.get("/transactions", response_model=TransactionsResponse)
|
||||
def get_payment_transactions(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -137,15 +147,15 @@ def get_payment_transactions(
|
||||
- Include payment details
|
||||
- Support pagination
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"transactions": [],
|
||||
"total": 0,
|
||||
"message": "Payment transactions coming in Slice 5",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return TransactionsResponse(
|
||||
transactions=[],
|
||||
total=0,
|
||||
message="Payment transactions coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/balance")
|
||||
@router.get("/balance", response_model=PaymentBalanceResponse)
|
||||
def get_payment_balance(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -159,20 +169,20 @@ def get_payment_balance(
|
||||
- Get next payout date
|
||||
- Get payout history
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {
|
||||
"available_balance": 0.0,
|
||||
"pending_balance": 0.0,
|
||||
"currency": "EUR",
|
||||
"next_payout_date": None,
|
||||
"message": "Payment balance coming in Slice 5",
|
||||
}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return PaymentBalanceResponse(
|
||||
available_balance=0.0,
|
||||
pending_balance=0.0,
|
||||
currency="EUR",
|
||||
next_payout_date=None,
|
||||
message="Payment balance coming in Slice 5",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refund/{payment_id}")
|
||||
@router.post("/refund/{payment_id}", response_model=RefundResponse)
|
||||
def refund_payment(
|
||||
payment_id: int,
|
||||
refund_data: dict,
|
||||
refund_data: RefundRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -185,5 +195,5 @@ def refund_payment(
|
||||
- Update order status
|
||||
- Send refund notification to customer
|
||||
"""
|
||||
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
|
||||
return {"message": "Payment refund coming in Slice 5"}
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
|
||||
return RefundResponse(message="Payment refund coming in Slice 5")
|
||||
|
||||
77
app/api/v1/vendor/products.py
vendored
77
app/api/v1/vendor/products.py
vendored
@@ -1,6 +1,9 @@
|
||||
# app/api/v1/vendor/products.py
|
||||
"""
|
||||
Vendor product catalog management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -10,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidTokenException
|
||||
from app.services.product_service import product_service
|
||||
from models.database.user import User
|
||||
from models.schema.product import (
|
||||
@@ -45,15 +47,9 @@ def get_vendor_products(
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
"""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
products, total = product_service.get_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
@@ -75,14 +71,8 @@ def get_product_details(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed product information including inventory."""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(
|
||||
db=db, vendor_id=vendor_id, product_id=product_id
|
||||
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
|
||||
)
|
||||
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
@@ -99,14 +89,8 @@ def add_product_to_catalog(
|
||||
|
||||
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||
"""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -125,14 +109,11 @@ def update_product(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.update_product(
|
||||
db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
product_id=product_id,
|
||||
product_update=product_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -150,13 +131,9 @@ def remove_product_from_catalog(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove product from vendor catalog."""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id)
|
||||
product_service.delete_product(
|
||||
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} removed from catalog by user {current_user.username} "
|
||||
@@ -177,18 +154,12 @@ def publish_from_marketplace(
|
||||
|
||||
Shortcut endpoint for publishing directly from marketplace import.
|
||||
"""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_data = ProductCreate(
|
||||
marketplace_product_id=marketplace_product_id, is_active=True
|
||||
)
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -206,13 +177,9 @@ def toggle_product_active(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product active status."""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
product = product_service.get_product(
|
||||
db, current_user.token_vendor_id, product_id
|
||||
)
|
||||
|
||||
product.is_active = not product.is_active
|
||||
db.commit()
|
||||
@@ -231,13 +198,9 @@ def toggle_product_featured(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product featured status."""
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
product = product_service.get_product(
|
||||
db, current_user.token_vendor_id, product_id
|
||||
)
|
||||
|
||||
product.is_featured = not product.is_featured
|
||||
db.commit()
|
||||
|
||||
24
app/api/v1/vendor/profile.py
vendored
24
app/api/v1/vendor/profile.py
vendored
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Vendor profile management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
from models.schema.vendor import VendorResponse, VendorUpdate
|
||||
@@ -21,20 +21,13 @@ router = APIRouter(prefix="/profile")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_vendor_from_token(current_user: User, db: Session):
|
||||
"""Helper to get vendor from JWT token."""
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
@router.get("", response_model=VendorResponse)
|
||||
def get_vendor_profile(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get current vendor profile information."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
return vendor
|
||||
|
||||
|
||||
@@ -45,10 +38,7 @@ def update_vendor_profile(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update vendor profile information."""
|
||||
vendor = _get_vendor_from_token(current_user, db)
|
||||
|
||||
# Verify user has permission to update vendor
|
||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
||||
raise InsufficientPermissionsException(required_permission="vendor:profile:update")
|
||||
|
||||
return vendor_service.update_vendor(db, vendor.id, vendor_update)
|
||||
# Service handles permission checking and raises InsufficientPermissionsException if needed
|
||||
return vendor_service.update_vendor(
|
||||
db, current_user.token_vendor_id, vendor_update, current_user
|
||||
)
|
||||
|
||||
41
app/api/v1/vendor/settings.py
vendored
41
app/api/v1/vendor/settings.py
vendored
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Vendor settings and configuration endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
|
||||
from app.services.vendor_service import vendor_service
|
||||
from models.database.user import User
|
||||
|
||||
@@ -26,10 +26,6 @@ def get_vendor_settings(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor settings and configuration."""
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
return {
|
||||
@@ -56,32 +52,7 @@ def update_marketplace_settings(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update marketplace integration settings."""
|
||||
# Get vendor ID from JWT token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("Token missing vendor information. Please login again.")
|
||||
|
||||
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
|
||||
|
||||
# Verify permissions
|
||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
||||
raise InsufficientPermissionsException(
|
||||
required_permission="vendor:settings:update"
|
||||
)
|
||||
|
||||
# Update Letzshop URLs
|
||||
if "letzshop_csv_url_fr" in marketplace_config:
|
||||
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
|
||||
if "letzshop_csv_url_en" in marketplace_config:
|
||||
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
|
||||
if "letzshop_csv_url_de" in marketplace_config:
|
||||
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
|
||||
|
||||
db.commit()
|
||||
db.refresh(vendor)
|
||||
|
||||
return {
|
||||
"message": "Marketplace settings updated successfully",
|
||||
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
|
||||
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
||||
}
|
||||
# Service handles permission checking and raises InsufficientPermissionsException if needed
|
||||
return vendor_service.update_marketplace_settings(
|
||||
db, current_user.token_vendor_id, marketplace_config, current_user
|
||||
)
|
||||
|
||||
2
app/api/v1/vendor/team.py
vendored
2
app/api/v1/vendor/team.py
vendored
@@ -164,7 +164,7 @@ def invite_team_member(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accept-invitation", response_model=InvitationAcceptResponse)
|
||||
@router.post("/accept-invitation", response_model=InvitationAcceptResponse) # public
|
||||
def accept_invitation(acceptance: InvitationAccept, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Accept a team invitation and activate account.
|
||||
|
||||
@@ -94,6 +94,50 @@ class MarketplaceImportJobService:
|
||||
logger.error(f"Error getting import job {job_id}: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import job")
|
||||
|
||||
def get_import_job_for_vendor(
|
||||
self, db: Session, job_id: int, vendor_id: int
|
||||
) -> MarketplaceImportJob:
|
||||
"""
|
||||
Get a marketplace import job by ID with vendor access control.
|
||||
|
||||
Validates that the job belongs to the specified vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
job_id: Import job ID
|
||||
vendor_id: Vendor ID from token (to verify ownership)
|
||||
|
||||
Raises:
|
||||
ImportJobNotFoundException: If job not found
|
||||
UnauthorizedVendorAccessException: If job doesn't belong to vendor
|
||||
"""
|
||||
from app.exceptions import UnauthorizedVendorAccessException
|
||||
|
||||
try:
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not job:
|
||||
raise ImportJobNotFoundException(job_id)
|
||||
|
||||
# Verify job belongs to vendor (service layer validation)
|
||||
if job.vendor_id != vendor_id:
|
||||
raise UnauthorizedVendorAccessException(
|
||||
vendor_code=str(vendor_id),
|
||||
user_id=0, # Not user-specific, but vendor mismatch
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
except (ImportJobNotFoundException, UnauthorizedVendorAccessException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting import job {job_id} for vendor {vendor_id}: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import job")
|
||||
|
||||
def get_import_jobs(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -252,6 +252,44 @@ class VendorService:
|
||||
|
||||
return vendor
|
||||
|
||||
def get_active_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
|
||||
"""
|
||||
Get active vendor by vendor_code for public access (no auth required).
|
||||
|
||||
This method is specifically designed for public endpoints where:
|
||||
- No authentication is required
|
||||
- Only active vendors should be returned
|
||||
- Inactive/disabled vendors are hidden
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Vendor object with company and owner loaded
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found or inactive
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from models.database.company import Company
|
||||
|
||||
vendor = (
|
||||
db.query(Vendor)
|
||||
.options(joinedload(Vendor.company).joinedload(Company.owner))
|
||||
.filter(
|
||||
func.upper(Vendor.vendor_code) == vendor_code.upper(),
|
||||
Vendor.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not vendor:
|
||||
logger.warning(f"Vendor not found or inactive: {vendor_code}")
|
||||
raise VendorNotFoundException(vendor_code, identifier_type="code")
|
||||
|
||||
return vendor
|
||||
|
||||
def get_vendor_by_identifier(self, db: Session, identifier: str) -> Vendor:
|
||||
"""
|
||||
Get vendor by ID or vendor_code (admin use - no access control).
|
||||
@@ -544,6 +582,107 @@ class VendorService:
|
||||
"""Check if user is vendor owner (via company ownership)."""
|
||||
return vendor.company and vendor.company.owner_user_id == user.id
|
||||
|
||||
def can_update_vendor(self, vendor: Vendor, user: User) -> bool:
|
||||
"""
|
||||
Check if user has permission to update vendor settings.
|
||||
|
||||
Permission granted to:
|
||||
- Admins (always)
|
||||
- Vendor owners (company owner)
|
||||
- Team members with appropriate role (owner role in VendorUser)
|
||||
"""
|
||||
# Admins can always update
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
# Check if user is vendor owner via company
|
||||
if self._is_vendor_owner(vendor, user):
|
||||
return True
|
||||
|
||||
# Check if user is owner via VendorUser relationship
|
||||
if user.is_owner_of(vendor.id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
vendor_update,
|
||||
current_user: User,
|
||||
) -> "Vendor":
|
||||
"""
|
||||
Update vendor profile with permission checking.
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
InsufficientPermissionsException: If user lacks permission
|
||||
"""
|
||||
from app.exceptions import InsufficientPermissionsException
|
||||
|
||||
vendor = self.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Check permissions in service layer
|
||||
if not self.can_update_vendor(vendor, current_user):
|
||||
raise InsufficientPermissionsException(
|
||||
required_permission="vendor:profile:update"
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
update_data = vendor_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if hasattr(vendor, field):
|
||||
setattr(vendor, field, value)
|
||||
|
||||
db.add(vendor)
|
||||
db.flush()
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
def update_marketplace_settings(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
marketplace_config: dict,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
"""
|
||||
Update marketplace integration settings with permission checking.
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
InsufficientPermissionsException: If user lacks permission
|
||||
"""
|
||||
from app.exceptions import InsufficientPermissionsException
|
||||
|
||||
vendor = self.get_vendor_by_id(db, vendor_id)
|
||||
|
||||
# Check permissions in service layer
|
||||
if not self.can_update_vendor(vendor, current_user):
|
||||
raise InsufficientPermissionsException(
|
||||
required_permission="vendor:settings:update"
|
||||
)
|
||||
|
||||
# Update Letzshop URLs
|
||||
if "letzshop_csv_url_fr" in marketplace_config:
|
||||
vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"]
|
||||
if "letzshop_csv_url_en" in marketplace_config:
|
||||
vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"]
|
||||
if "letzshop_csv_url_de" in marketplace_config:
|
||||
vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"]
|
||||
|
||||
db.add(vendor)
|
||||
db.flush()
|
||||
db.refresh(vendor)
|
||||
|
||||
return {
|
||||
"message": "Marketplace settings updated successfully",
|
||||
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
|
||||
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
||||
}
|
||||
|
||||
|
||||
# Create service instance following the same pattern as other services
|
||||
vendor_service = VendorService()
|
||||
|
||||
@@ -453,14 +453,37 @@ current_user: User = Depends(get_current_vendor_from_cookie_or_header)
|
||||
|
||||
**Purpose:** Authenticate vendor users for API endpoints
|
||||
**Accepts:** Authorization header ONLY
|
||||
**Returns:** `User` object with `role="vendor"`
|
||||
**Returns:** `User` object with `role="vendor"` and **guaranteed** attributes:
|
||||
- `current_user.token_vendor_id` - Vendor ID from JWT token
|
||||
- `current_user.token_vendor_code` - Vendor code from JWT token
|
||||
- `current_user.token_vendor_role` - User's role in vendor (owner, manager, etc.)
|
||||
|
||||
**Raises:**
|
||||
- `InvalidTokenException` - No token or invalid token
|
||||
- `InsufficientPermissionsException` - User is not vendor or is admin
|
||||
- `InvalidTokenException` - No token, invalid token, or **missing vendor context in token**
|
||||
- `InsufficientPermissionsException` - User is not vendor, is admin, or lost access to vendor
|
||||
|
||||
**Guarantees:**
|
||||
This dependency **guarantees** that `token_vendor_id` is present. Endpoints should NOT check for its existence:
|
||||
|
||||
```python
|
||||
# ❌ WRONG - Redundant check violates API-003
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise InvalidTokenException("...")
|
||||
|
||||
# ✅ CORRECT - Dependency guarantees this attribute exists
|
||||
vendor_id = current_user.token_vendor_id
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
current_user: User = Depends(get_current_vendor_api)
|
||||
@router.get("/orders")
|
||||
def get_orders(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# Safe to use directly - dependency guarantees token_vendor_id
|
||||
orders = order_service.get_vendor_orders(db, current_user.token_vendor_id)
|
||||
return orders
|
||||
```
|
||||
|
||||
#### `get_current_customer_from_cookie_or_header()`
|
||||
|
||||
@@ -195,27 +195,20 @@ def get_current_vendor_api(
|
||||
def get_vendor_products(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Only need this
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Guarantees token_vendor_id
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all products in vendor catalog.
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
The get_current_vendor_api dependency GUARANTEES token_vendor_id is present.
|
||||
"""
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Use vendor_id from token for business logic
|
||||
# NO validation needed - dependency guarantees token_vendor_id exists
|
||||
products, total = product_service.get_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
vendor_id=current_user.token_vendor_id, # Safe to use directly
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -223,6 +216,9 @@ def get_vendor_products(
|
||||
return ProductListResponse(products=products, total=total)
|
||||
```
|
||||
|
||||
> **IMPORTANT**: The `get_current_vendor_api()` dependency now **guarantees** that `token_vendor_id` is present.
|
||||
> Endpoints should NOT check for its existence - this would be redundant validation that belongs in the dependency layer.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Identify Endpoints Using require_vendor_context()
|
||||
@@ -264,21 +260,14 @@ product = product_service.get_product(db, vendor.id, product_id)
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Use vendor_id from token
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
# Use vendor_id from token directly - dependency guarantees it exists
|
||||
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
|
||||
```
|
||||
|
||||
> **NOTE**: Do NOT add validation like `if not hasattr(current_user, "token_vendor_id")`.
|
||||
> The `get_current_vendor_api` dependency guarantees this attribute is present.
|
||||
> Adding such checks violates the architecture rule API-003 (endpoints should not raise exceptions).
|
||||
|
||||
### Step 4: Update Logging References
|
||||
|
||||
**Before:**
|
||||
@@ -325,24 +314,14 @@ def update_product(
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Only dependency
|
||||
current_user: User = Depends(get_current_vendor_api), # ✅ Guarantees token_vendor_id
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Extract vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id # ✅ From token
|
||||
|
||||
# NO validation needed - dependency guarantees token_vendor_id exists
|
||||
product = product_service.update_product(
|
||||
db=db,
|
||||
vendor_id=vendor_id, # ✅ From token
|
||||
vendor_id=current_user.token_vendor_id, # ✅ From token - safe to use directly
|
||||
product_id=product_id,
|
||||
product_update=product_data
|
||||
)
|
||||
@@ -355,6 +334,9 @@ def update_product(
|
||||
return ProductResponse.model_validate(product)
|
||||
```
|
||||
|
||||
> **Architecture Rule API-003**: Endpoints should NOT raise exceptions. The `get_current_vendor_api` dependency
|
||||
> handles all validation and raises `InvalidTokenException` if `token_vendor_id` is missing.
|
||||
|
||||
## Migration Status
|
||||
|
||||
**COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern.
|
||||
@@ -498,9 +480,81 @@ def test_vendor_login_and_api_access():
|
||||
# All products should belong to token vendor
|
||||
```
|
||||
|
||||
## Architecture Rules
|
||||
## Architecture Rules and Design Pattern Enforcement
|
||||
|
||||
See `docs/architecture/rules/API-VND-001.md` for the formal architecture rule enforcing this pattern.
|
||||
### The Layered Exception Pattern
|
||||
|
||||
The architecture enforces a strict layered pattern for where exceptions should be raised:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ENDPOINTS (Thin Layer) - app/api/v1/**/*.py │
|
||||
│ │
|
||||
│ ❌ MUST NOT raise exceptions │
|
||||
│ ❌ MUST NOT check hasattr(current_user, 'token_vendor_id') │
|
||||
│ ✅ MUST trust dependencies to handle validation │
|
||||
│ ✅ MUST directly use current_user.token_vendor_id │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ DEPENDENCIES (Validation Layer) - app/api/deps.py │
|
||||
│ │
|
||||
│ ✅ MUST raise InvalidTokenException if token_vendor_id missing │
|
||||
│ ✅ MUST validate user still has vendor access │
|
||||
│ ✅ GUARANTEES token_vendor_id, token_vendor_code, token_vendor_role │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SERVICES (Business Logic) - app/services/**/*.py │
|
||||
│ │
|
||||
│ ✅ MUST raise domain exceptions for business rule violations │
|
||||
│ ✅ Examples: VendorNotFoundException, ProductNotFoundException │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ GLOBAL EXCEPTION HANDLER - app/exceptions/handler.py │
|
||||
│ │
|
||||
│ ✅ Catches all WizamartException subclasses │
|
||||
│ ✅ Converts to appropriate HTTP responses │
|
||||
│ ✅ Provides consistent error formatting │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Enforced by Architecture Validation
|
||||
|
||||
The validation script (`scripts/validate_architecture.py`) enforces these rules:
|
||||
|
||||
**Rule API-003: Endpoints must NOT raise exceptions directly**
|
||||
- Detects `raise HTTPException`, `raise InvalidTokenException`, etc. in endpoint files
|
||||
- Detects redundant validation like `if not hasattr(current_user, 'token_vendor_id')`
|
||||
- Blocks commits via pre-commit hook if violations found
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
Architecture validation runs on every commit:
|
||||
|
||||
```yaml
|
||||
# .pre-commit-config.yaml
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: validate-architecture
|
||||
name: Validate Architecture Patterns
|
||||
entry: python scripts/validate_architecture.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
```
|
||||
|
||||
To run manually:
|
||||
```bash
|
||||
python scripts/validate_architecture.py # Full validation
|
||||
python scripts/validate_architecture.py -d app/api/v1/vendor/ # Specific directory
|
||||
```
|
||||
|
||||
See `.architecture-rules.yaml` for the complete rule definitions.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
@@ -519,4 +573,4 @@ The vendor-in-token architecture:
|
||||
- ✅ Simplifies endpoint implementation
|
||||
- ✅ Follows architecture best practices
|
||||
|
||||
**Migration Status:** In progress - 9 endpoint files remaining to migrate
|
||||
**Migration Status:** ✅ COMPLETED - All vendor API endpoints migrated and architecture rules enforced
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -517,39 +517,72 @@ class ArchitectureValidator:
|
||||
def _check_endpoint_exception_handling(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
):
|
||||
"""API-003: Check that endpoints do NOT raise HTTPException directly.
|
||||
"""API-003: Check that endpoints do NOT raise exceptions directly.
|
||||
|
||||
The architecture uses a global exception handler that catches domain
|
||||
exceptions (WizamartException subclasses) and converts them to HTTP
|
||||
responses. Endpoints should let exceptions bubble up, not catch and
|
||||
convert them manually.
|
||||
The architecture uses:
|
||||
- Dependencies (deps.py) for authentication/authorization validation
|
||||
- Services for business logic validation
|
||||
- Global exception handler that catches WizamartException subclasses
|
||||
|
||||
Endpoints should be a thin orchestration layer that trusts dependencies
|
||||
and services to handle all validation. They should NOT raise exceptions.
|
||||
"""
|
||||
rule = self._get_rule("API-003")
|
||||
if not rule:
|
||||
return
|
||||
|
||||
# Skip exception handler file - it's allowed to use HTTPException
|
||||
if "exceptions/handler.py" in str(file_path):
|
||||
# Skip exception handler file and deps.py - they're allowed to raise exceptions
|
||||
file_path_str = str(file_path)
|
||||
if "exceptions/handler.py" in file_path_str or file_path_str.endswith("deps.py"):
|
||||
return
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Check for raise HTTPException
|
||||
if "raise HTTPException" in line:
|
||||
# Skip if it's a comment
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
# Patterns that indicate endpoints are raising exceptions (BAD)
|
||||
exception_patterns = [
|
||||
("raise HTTPException", "Endpoint raises HTTPException directly"),
|
||||
("raise InvalidTokenException", "Endpoint raises InvalidTokenException - move to dependency"),
|
||||
("raise InsufficientPermissionsException", "Endpoint raises permission exception - move to dependency"),
|
||||
("raise UnauthorizedVendorAccessException", "Endpoint raises auth exception - move to dependency or service"),
|
||||
]
|
||||
|
||||
self._add_violation(
|
||||
rule_id="API-003",
|
||||
rule_name=rule["name"],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message="Endpoint raises HTTPException directly",
|
||||
context=line.strip()[:80],
|
||||
suggestion="Use domain exceptions (e.g., VendorNotFoundException) and let global handler convert",
|
||||
)
|
||||
# Pattern that indicates redundant validation (BAD)
|
||||
redundant_patterns = [
|
||||
(r"if not hasattr\(current_user.*token_vendor", "Redundant token_vendor check - get_current_vendor_api guarantees this"),
|
||||
(r"if not hasattr\(current_user.*token_vendor_id", "Redundant token_vendor_id check - dependency guarantees this"),
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Check for direct exception raising
|
||||
for pattern, message in exception_patterns:
|
||||
if pattern in line:
|
||||
self._add_violation(
|
||||
rule_id="API-003",
|
||||
rule_name=rule["name"],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=message,
|
||||
context=stripped[:80],
|
||||
suggestion="Let dependencies or services handle validation and raise exceptions",
|
||||
)
|
||||
|
||||
# Check for redundant validation patterns
|
||||
for pattern, message in redundant_patterns:
|
||||
if re.search(pattern, line):
|
||||
self._add_violation(
|
||||
rule_id="API-003",
|
||||
rule_name=rule["name"],
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=i,
|
||||
message=message,
|
||||
context=stripped[:80],
|
||||
suggestion="Remove redundant check - auth dependency guarantees this attribute is present",
|
||||
)
|
||||
|
||||
def _check_endpoint_authentication(
|
||||
self, file_path: Path, content: str, lines: list[str]
|
||||
@@ -570,7 +603,21 @@ class ArchitectureValidator:
|
||||
return
|
||||
|
||||
# This is a warning-level check
|
||||
# Look for endpoints without Depends(get_current_*)
|
||||
# Look for endpoints without proper authentication
|
||||
# Valid auth patterns:
|
||||
# - Depends(get_current_*) - direct user authentication
|
||||
# - Depends(require_vendor_*) - vendor permission dependencies
|
||||
# - Depends(require_any_vendor_*) - any permission check
|
||||
# - Depends(require_all_vendor*) - all permissions check
|
||||
# - Depends(get_user_permissions) - permission fetching
|
||||
auth_patterns = [
|
||||
"Depends(get_current_",
|
||||
"Depends(require_vendor_",
|
||||
"Depends(require_any_vendor_",
|
||||
"Depends(require_all_vendor",
|
||||
"Depends(get_user_permissions",
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "@router." in line and (
|
||||
"post" in line or "put" in line or "delete" in line
|
||||
@@ -582,7 +629,8 @@ class ArchitectureValidator:
|
||||
context_lines = lines[i - 1 : i + 15] # Include line before decorator
|
||||
|
||||
for ctx_line in context_lines:
|
||||
if "Depends(get_current_" in ctx_line:
|
||||
# Check for any valid auth pattern
|
||||
if any(pattern in ctx_line for pattern in auth_patterns):
|
||||
has_auth = True
|
||||
break
|
||||
# Check for public endpoint markers
|
||||
@@ -593,6 +641,17 @@ class ArchitectureValidator:
|
||||
if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
|
||||
lines[i : i + 15]
|
||||
):
|
||||
# Determine appropriate suggestion based on file path
|
||||
file_path_str = str(file_path)
|
||||
if "/vendor/" in file_path_str:
|
||||
suggestion = "Add Depends(get_current_vendor_api) or permission dependency, or mark as '# public'"
|
||||
elif "/admin/" in file_path_str:
|
||||
suggestion = "Add Depends(get_current_admin_api), or mark as '# public'"
|
||||
elif "/shop/" in file_path_str:
|
||||
suggestion = "Add Depends(get_current_customer_api), or mark as '# public'"
|
||||
else:
|
||||
suggestion = "Add authentication dependency or mark as '# public' if intentionally unauthenticated"
|
||||
|
||||
self._add_violation(
|
||||
rule_id="API-004",
|
||||
rule_name=rule["name"],
|
||||
@@ -601,7 +660,7 @@ class ArchitectureValidator:
|
||||
line_number=i,
|
||||
message="Endpoint may be missing authentication",
|
||||
context=line.strip(),
|
||||
suggestion="Add Depends(get_current_user) or mark as '# public' if intentionally unauthenticated",
|
||||
suggestion=suggestion,
|
||||
)
|
||||
|
||||
def _validate_service_layer(self, target_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user