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(): ... 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: |

View File

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

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