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:
2025-12-20 22:21:39 +01:00
parent d910c1b0b3
commit 946417c4d4
3 changed files with 211 additions and 145 deletions

View File

@@ -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: |

View File

@@ -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
# ============================================================================

View 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