refactor: move vendor product schemas to models/schema and add API-002 rule
- Add API-002 architecture rule preventing Pydantic imports in API endpoints - Move inline Pydantic models from vendor_products.py to models/schema/vendor_product.py - Update vendor_products.py to import schemas from proper location 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,54 @@ api_endpoint_rules:
|
|||||||
def get_data(): ...
|
def get_data(): ...
|
||||||
|
|
||||||
- id: "API-002"
|
- id: "API-002"
|
||||||
|
name: "No Pydantic imports in API endpoint files"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
API endpoint files must NOT import Pydantic directly (BaseModel, Field, etc.).
|
||||||
|
All Pydantic schemas must be defined in models/schema/*.py and imported from there.
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Separation of concerns: API handles HTTP, schemas handle data structure
|
||||||
|
- Discoverability: All schemas in one place for easy review
|
||||||
|
- Reusability: Schemas can be shared across endpoints
|
||||||
|
- Prevents inline schema definitions that violate API-001
|
||||||
|
|
||||||
|
WRONG:
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
class MyRequest(BaseModel): # Inline schema
|
||||||
|
name: str
|
||||||
|
|
||||||
|
RIGHT:
|
||||||
|
# In models/schema/my_feature.py
|
||||||
|
from pydantic import BaseModel
|
||||||
|
class MyRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
# In app/api/v1/admin/my_feature.py
|
||||||
|
from models.schema.my_feature import MyRequest
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/api/v1/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "from pydantic import"
|
||||||
|
- "from pydantic.main import"
|
||||||
|
example_good: |
|
||||||
|
# In app/api/v1/admin/vendors.py
|
||||||
|
from models.schema.vendor import (
|
||||||
|
LetzshopExportRequest,
|
||||||
|
LetzshopExportResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/export", response_model=LetzshopExportResponse)
|
||||||
|
def export_products(request: LetzshopExportRequest):
|
||||||
|
...
|
||||||
|
example_bad: |
|
||||||
|
# In app/api/v1/admin/vendors.py
|
||||||
|
from pydantic import BaseModel # WRONG: Don't import pydantic here
|
||||||
|
|
||||||
|
class LetzshopExportRequest(BaseModel): # WRONG: Define in models/schema/
|
||||||
|
include_inactive: bool = False
|
||||||
|
|
||||||
|
- id: "API-003"
|
||||||
name: "Endpoint must NOT contain business logic"
|
name: "Endpoint must NOT contain business logic"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
@@ -76,7 +124,7 @@ api_endpoint_rules:
|
|||||||
- "db.delete("
|
- "db.delete("
|
||||||
- "db.query("
|
- "db.query("
|
||||||
|
|
||||||
- id: "API-003"
|
- id: "API-004"
|
||||||
name: "Endpoint must NOT raise ANY exceptions directly"
|
name: "Endpoint must NOT raise ANY exceptions directly"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
@@ -116,7 +164,7 @@ api_endpoint_rules:
|
|||||||
exceptions:
|
exceptions:
|
||||||
- "app/exceptions/handler.py"
|
- "app/exceptions/handler.py"
|
||||||
|
|
||||||
- id: "API-004"
|
- id: "API-005"
|
||||||
name: "Endpoint must have proper authentication/authorization"
|
name: "Endpoint must have proper authentication/authorization"
|
||||||
severity: "warning"
|
severity: "warning"
|
||||||
description: |
|
description: |
|
||||||
@@ -128,7 +176,7 @@ api_endpoint_rules:
|
|||||||
|
|
||||||
Public endpoint markers (place on line before or after decorator):
|
Public endpoint markers (place on line before or after decorator):
|
||||||
- # public - Descriptive marker for intentionally unauthenticated endpoints
|
- # public - Descriptive marker for intentionally unauthenticated endpoints
|
||||||
- # noqa: API-004 - Standard noqa style to suppress warning
|
- # noqa: API-005 - Standard noqa style to suppress warning
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
# public - Stripe webhook receives external callbacks
|
# public - Stripe webhook receives external callbacks
|
||||||
@@ -143,9 +191,9 @@ api_endpoint_rules:
|
|||||||
- "*/auth.py"
|
- "*/auth.py"
|
||||||
public_markers:
|
public_markers:
|
||||||
- "# public"
|
- "# public"
|
||||||
- "# noqa: api-004"
|
- "# noqa: api-005"
|
||||||
|
|
||||||
- id: "API-005"
|
- id: "API-006"
|
||||||
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@@ -6,162 +6,35 @@ Provides management of vendor-specific product catalogs:
|
|||||||
- Browse products in vendor catalogs
|
- Browse products in vendor catalogs
|
||||||
- View product details with override info
|
- View product details with override info
|
||||||
- Remove products from catalog
|
- Remove products from catalog
|
||||||
|
|
||||||
|
Architecture Notes:
|
||||||
|
- All Pydantic schemas are defined in models/schema/vendor_product.py
|
||||||
|
- Business logic is delegated to vendor_product_service
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.vendor_product_service import vendor_product_service
|
from app.services.vendor_product_service import vendor_product_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
from models.schema.vendor_product import (
|
||||||
|
CatalogVendor,
|
||||||
|
CatalogVendorsResponse,
|
||||||
|
RemoveProductResponse,
|
||||||
|
VendorProductDetail,
|
||||||
|
VendorProductListItem,
|
||||||
|
VendorProductListResponse,
|
||||||
|
VendorProductStats,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/vendor-products")
|
router = APIRouter(prefix="/vendor-products")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pydantic Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class VendorProductListItem(BaseModel):
|
|
||||||
"""Product item for vendor catalog list view."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
vendor_name: str | None = None
|
|
||||||
vendor_code: str | None = None
|
|
||||||
marketplace_product_id: int
|
|
||||||
vendor_sku: str | None = None
|
|
||||||
title: str | None = None
|
|
||||||
brand: str | None = None
|
|
||||||
effective_price: float | None = None
|
|
||||||
effective_currency: str | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
is_featured: bool | None = None
|
|
||||||
is_digital: bool | None = None
|
|
||||||
image_url: str | None = None
|
|
||||||
source_marketplace: str | None = None
|
|
||||||
source_vendor: str | None = None
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class VendorProductListResponse(BaseModel):
|
|
||||||
"""Paginated vendor product list response."""
|
|
||||||
|
|
||||||
products: list[VendorProductListItem]
|
|
||||||
total: int
|
|
||||||
skip: int
|
|
||||||
limit: int
|
|
||||||
|
|
||||||
|
|
||||||
class VendorProductStats(BaseModel):
|
|
||||||
"""Vendor product statistics."""
|
|
||||||
|
|
||||||
total: int
|
|
||||||
active: int
|
|
||||||
inactive: int
|
|
||||||
featured: int
|
|
||||||
digital: int
|
|
||||||
physical: int
|
|
||||||
by_vendor: dict[str, int]
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogVendor(BaseModel):
|
|
||||||
"""Vendor with products in catalog."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
vendor_code: str
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogVendorsResponse(BaseModel):
|
|
||||||
"""Response for catalog vendors list."""
|
|
||||||
|
|
||||||
vendors: list[CatalogVendor]
|
|
||||||
|
|
||||||
|
|
||||||
class VendorProductDetail(BaseModel):
|
|
||||||
"""Detailed vendor product information."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
vendor_name: str | None = None
|
|
||||||
vendor_code: str | None = None
|
|
||||||
marketplace_product_id: int
|
|
||||||
vendor_sku: str | None = None
|
|
||||||
# Override info from get_override_info()
|
|
||||||
price: float | None = None
|
|
||||||
price_overridden: bool | None = None
|
|
||||||
price_source: float | None = None
|
|
||||||
sale_price: float | None = None
|
|
||||||
sale_price_overridden: bool | None = None
|
|
||||||
sale_price_source: float | None = None
|
|
||||||
currency: str | None = None
|
|
||||||
currency_overridden: bool | None = None
|
|
||||||
currency_source: str | None = None
|
|
||||||
brand: str | None = None
|
|
||||||
brand_overridden: bool | None = None
|
|
||||||
brand_source: str | None = None
|
|
||||||
condition: str | None = None
|
|
||||||
condition_overridden: bool | None = None
|
|
||||||
condition_source: str | None = None
|
|
||||||
availability: str | None = None
|
|
||||||
availability_overridden: bool | None = None
|
|
||||||
availability_source: str | None = None
|
|
||||||
primary_image_url: str | None = None
|
|
||||||
primary_image_url_overridden: bool | None = None
|
|
||||||
primary_image_url_source: str | None = None
|
|
||||||
is_digital: bool | None = None
|
|
||||||
product_type: str | None = None
|
|
||||||
# Vendor-specific fields
|
|
||||||
is_featured: bool | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
display_order: int | None = None
|
|
||||||
min_quantity: int | None = None
|
|
||||||
max_quantity: int | None = None
|
|
||||||
# Supplier tracking
|
|
||||||
supplier: str | None = None
|
|
||||||
supplier_product_id: str | None = None
|
|
||||||
cost: float | None = None # What vendor pays to acquire product
|
|
||||||
margin_percent: float | None = None
|
|
||||||
# Tax/profit info
|
|
||||||
tax_rate_percent: int | None = None
|
|
||||||
net_price: float | None = None
|
|
||||||
vat_amount: float | None = None
|
|
||||||
profit: float | None = None
|
|
||||||
profit_margin_percent: float | None = None
|
|
||||||
# Digital fulfillment
|
|
||||||
download_url: str | None = None
|
|
||||||
license_type: str | None = None
|
|
||||||
fulfillment_email_template: str | None = None
|
|
||||||
# Source info
|
|
||||||
source_marketplace: str | None = None
|
|
||||||
source_vendor: str | None = None
|
|
||||||
source_gtin: str | None = None
|
|
||||||
source_sku: str | None = None
|
|
||||||
# Translations
|
|
||||||
marketplace_translations: dict | None = None
|
|
||||||
vendor_translations: dict | None = None
|
|
||||||
# Timestamps
|
|
||||||
created_at: str | None = None
|
|
||||||
updated_at: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RemoveProductResponse(BaseModel):
|
|
||||||
"""Response from product removal."""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
145
models/schema/vendor_product.py
Normal file
145
models/schema/vendor_product.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# models/schema/vendor_product.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for vendor product catalog operations.
|
||||||
|
|
||||||
|
Used by admin vendor product endpoints for:
|
||||||
|
- Product listing and filtering
|
||||||
|
- Product statistics
|
||||||
|
- Product detail views
|
||||||
|
- Catalog vendor listings
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class VendorProductListItem(BaseModel):
|
||||||
|
"""Product item for vendor catalog list view."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
vendor_name: str | None = None
|
||||||
|
vendor_code: str | None = None
|
||||||
|
marketplace_product_id: int
|
||||||
|
vendor_sku: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
effective_price: float | None = None
|
||||||
|
effective_currency: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
is_featured: bool | None = None
|
||||||
|
is_digital: bool | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
source_marketplace: str | None = None
|
||||||
|
source_vendor: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VendorProductListResponse(BaseModel):
|
||||||
|
"""Paginated vendor product list response."""
|
||||||
|
|
||||||
|
products: list[VendorProductListItem]
|
||||||
|
total: int
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class VendorProductStats(BaseModel):
|
||||||
|
"""Vendor product statistics."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
active: int
|
||||||
|
inactive: int
|
||||||
|
featured: int
|
||||||
|
digital: int
|
||||||
|
physical: int
|
||||||
|
by_vendor: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogVendor(BaseModel):
|
||||||
|
"""Vendor with products in catalog."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
vendor_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogVendorsResponse(BaseModel):
|
||||||
|
"""Response for catalog vendors list."""
|
||||||
|
|
||||||
|
vendors: list[CatalogVendor]
|
||||||
|
|
||||||
|
|
||||||
|
class VendorProductDetail(BaseModel):
|
||||||
|
"""Detailed vendor product information."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
vendor_name: str | None = None
|
||||||
|
vendor_code: str | None = None
|
||||||
|
marketplace_product_id: int
|
||||||
|
vendor_sku: str | None = None
|
||||||
|
# Override info from get_override_info()
|
||||||
|
price: float | None = None
|
||||||
|
price_overridden: bool | None = None
|
||||||
|
price_source: float | None = None
|
||||||
|
sale_price: float | None = None
|
||||||
|
sale_price_overridden: bool | None = None
|
||||||
|
sale_price_source: float | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
currency_overridden: bool | None = None
|
||||||
|
currency_source: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
brand_overridden: bool | None = None
|
||||||
|
brand_source: str | None = None
|
||||||
|
condition: str | None = None
|
||||||
|
condition_overridden: bool | None = None
|
||||||
|
condition_source: str | None = None
|
||||||
|
availability: str | None = None
|
||||||
|
availability_overridden: bool | None = None
|
||||||
|
availability_source: str | None = None
|
||||||
|
primary_image_url: str | None = None
|
||||||
|
primary_image_url_overridden: bool | None = None
|
||||||
|
primary_image_url_source: str | None = None
|
||||||
|
is_digital: bool | None = None
|
||||||
|
product_type: str | None = None
|
||||||
|
# Vendor-specific fields
|
||||||
|
is_featured: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
display_order: int | None = None
|
||||||
|
min_quantity: int | None = None
|
||||||
|
max_quantity: int | None = None
|
||||||
|
# Supplier tracking
|
||||||
|
supplier: str | None = None
|
||||||
|
supplier_product_id: str | None = None
|
||||||
|
cost: float | None = None # What vendor pays to acquire product
|
||||||
|
margin_percent: float | None = None
|
||||||
|
# Tax/profit info
|
||||||
|
tax_rate_percent: int | None = None
|
||||||
|
net_price: float | None = None
|
||||||
|
vat_amount: float | None = None
|
||||||
|
profit: float | None = None
|
||||||
|
profit_margin_percent: float | None = None
|
||||||
|
# Digital fulfillment
|
||||||
|
download_url: str | None = None
|
||||||
|
license_type: str | None = None
|
||||||
|
fulfillment_email_template: str | None = None
|
||||||
|
# Source info
|
||||||
|
source_marketplace: str | None = None
|
||||||
|
source_vendor: str | None = None
|
||||||
|
source_gtin: str | None = None
|
||||||
|
source_sku: str | None = None
|
||||||
|
# Translations
|
||||||
|
marketplace_translations: dict | None = None
|
||||||
|
vendor_translations: dict | None = None
|
||||||
|
# Timestamps
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveProductResponse(BaseModel):
|
||||||
|
"""Response from product removal."""
|
||||||
|
|
||||||
|
message: str
|
||||||
Reference in New Issue
Block a user