diff --git a/.architecture-rules/api.yaml b/.architecture-rules/api.yaml index 023b59de..a141988d 100644 --- a/.architecture-rules/api.yaml +++ b/.architecture-rules/api.yaml @@ -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: | diff --git a/app/api/v1/admin/vendor_products.py b/app/api/v1/admin/vendor_products.py index c8a4abc6..2a2bb4c1 100644 --- a/app/api/v1/admin/vendor_products.py +++ b/app/api/v1/admin/vendor_products.py @@ -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 # ============================================================================ diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py new file mode 100644 index 00000000..4d120a6e --- /dev/null +++ b/models/schema/vendor_product.py @@ -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