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(): ...
|
||||
|
||||
- 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"
|
||||
severity: "error"
|
||||
description: |
|
||||
@@ -76,7 +124,7 @@ api_endpoint_rules:
|
||||
- "db.delete("
|
||||
- "db.query("
|
||||
|
||||
- id: "API-003"
|
||||
- id: "API-004"
|
||||
name: "Endpoint must NOT raise ANY exceptions directly"
|
||||
severity: "error"
|
||||
description: |
|
||||
@@ -116,7 +164,7 @@ api_endpoint_rules:
|
||||
exceptions:
|
||||
- "app/exceptions/handler.py"
|
||||
|
||||
- id: "API-004"
|
||||
- id: "API-005"
|
||||
name: "Endpoint must have proper authentication/authorization"
|
||||
severity: "warning"
|
||||
description: |
|
||||
@@ -128,7 +176,7 @@ api_endpoint_rules:
|
||||
|
||||
Public endpoint markers (place on line before or after decorator):
|
||||
- # 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:
|
||||
# public - Stripe webhook receives external callbacks
|
||||
@@ -143,9 +191,9 @@ api_endpoint_rules:
|
||||
- "*/auth.py"
|
||||
public_markers:
|
||||
- "# public"
|
||||
- "# noqa: api-004"
|
||||
- "# noqa: api-005"
|
||||
|
||||
- id: "API-005"
|
||||
- id: "API-006"
|
||||
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
||||
severity: "error"
|
||||
description: |
|
||||
|
||||
@@ -6,162 +6,35 @@ Provides management of vendor-specific product catalogs:
|
||||
- Browse products in vendor catalogs
|
||||
- View product details with override info
|
||||
- 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
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.services.vendor_product_service import vendor_product_service
|
||||
from models.database.user import User
|
||||
from models.schema.vendor_product import (
|
||||
CatalogVendor,
|
||||
CatalogVendorsResponse,
|
||||
RemoveProductResponse,
|
||||
VendorProductDetail,
|
||||
VendorProductListItem,
|
||||
VendorProductListResponse,
|
||||
VendorProductStats,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vendor-products")
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
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