diff --git a/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py b/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py new file mode 100644 index 00000000..b8e334bb --- /dev/null +++ b/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py @@ -0,0 +1,43 @@ +# alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py +"""Make marketplace_product_id nullable for direct product creation. + +Revision ID: x2c3d4e5f6g7 +Revises: w1b2c3d4e5f6 +Create Date: 2026-01-06 23:15:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "x2c3d4e5f6g7" +down_revision: str = "w1b2c3d4e5f6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Make marketplace_product_id nullable to allow direct product creation.""" + # SQLite doesn't support ALTER COLUMN, so we need to recreate the table + # For SQLite, we use batch mode which handles this automatically + with op.batch_alter_table("products") as batch_op: + batch_op.alter_column( + "marketplace_product_id", + existing_type=sa.Integer(), + nullable=True, + ) + + +def downgrade() -> None: + """Revert marketplace_product_id to NOT NULL.""" + # Note: This will fail if there are any NULL values in the column + with op.batch_alter_table("products") as batch_op: + batch_op.alter_column( + "marketplace_product_id", + existing_type=sa.Integer(), + nullable=False, + ) diff --git a/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py b/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py new file mode 100644 index 00000000..a7db7e52 --- /dev/null +++ b/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py @@ -0,0 +1,47 @@ +# alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py +"""Add is_digital and product_type columns to products table. + +Makes Product fully independent from MarketplaceProduct for product type info. + +Revision ID: y3d4e5f6g7h8 +Revises: x2c3d4e5f6g7 +Create Date: 2026-01-07 10:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "y3d4e5f6g7h8" +down_revision: str = "x2c3d4e5f6g7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add is_digital and product_type columns to products table.""" + with op.batch_alter_table("products") as batch_op: + batch_op.add_column( + sa.Column("is_digital", sa.Boolean(), nullable=False, server_default="0") + ) + batch_op.add_column( + sa.Column( + "product_type", + sa.String(20), + nullable=False, + server_default="physical", + ) + ) + batch_op.create_index("idx_product_is_digital", ["is_digital"]) + + +def downgrade() -> None: + """Remove is_digital and product_type columns.""" + with op.batch_alter_table("products") as batch_op: + batch_op.drop_index("idx_product_is_digital") + batch_op.drop_column("product_type") + batch_op.drop_column("is_digital") diff --git a/app/api/v1/vendor/products.py b/app/api/v1/vendor/products.py index e9e7c502..0d402e9a 100644 --- a/app/api/v1/vendor/products.py +++ b/app/api/v1/vendor/products.py @@ -15,6 +15,7 @@ from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.services.product_service import product_service from app.services.subscription_service import subscription_service +from app.services.vendor_product_service import vendor_product_service from models.database.user import User from models.schema.product import ( ProductCreate, @@ -25,6 +26,10 @@ from models.schema.product import ( ProductToggleResponse, ProductUpdate, ) +from models.schema.vendor_product import ( + VendorDirectProductCreate, + VendorProductCreateResponse, +) router = APIRouter(prefix="/products") logger = logging.getLogger(__name__) @@ -106,6 +111,50 @@ def add_product_to_catalog( return ProductResponse.model_validate(product) +@router.post("/create", response_model=VendorProductCreateResponse) +def create_product_direct( + product_data: VendorDirectProductCreate, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Create a new product directly without marketplace product. + + This creates a Product and ProductTranslation without requiring + an existing MarketplaceProduct. + """ + # Check product limit before creating + subscription_service.check_product_limit(db, current_user.token_vendor_id) + + # Build data dict with vendor_id from token + data = { + "vendor_id": current_user.token_vendor_id, + "title": product_data.title, + "brand": product_data.brand, + "vendor_sku": product_data.vendor_sku, + "gtin": product_data.gtin, + "price": product_data.price, + "currency": product_data.currency, + "availability": product_data.availability, + "is_active": product_data.is_active, + "is_featured": product_data.is_featured, + "description": product_data.description, + } + + product = vendor_product_service.create_product(db=db, data=data) + db.commit() + + logger.info( + f"Product {product.id} created by user {current_user.username} " + f"for vendor {current_user.token_vendor_code}" + ) + + return VendorProductCreateResponse( + id=product.id, + message="Product created successfully", + ) + + @router.put("/{product_id}", response_model=ProductResponse) def update_product( product_id: int, diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 91d9d850..f47f4eb7 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -280,6 +280,25 @@ async def vendor_products_page( ) +@router.get( + "/{vendor_code}/products/create", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_product_create_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render product creation page. + JavaScript handles form submission via API. + """ + return templates.TemplateResponse( + "vendor/product-create.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + # ============================================================================ # ORDER MANAGEMENT # ============================================================================ diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index 4d5a88ef..d03a3646 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -47,6 +47,7 @@ class VendorProductService: .options( joinedload(Product.vendor), joinedload(Product.marketplace_product), + joinedload(Product.translations), ) ) @@ -246,26 +247,67 @@ class VendorProductService: Args: db: Database session - data: Product data dict + data: Product data dict (includes translations dict for multiple languages) Returns: Created Product instance """ + from models.database.product_translation import ProductTranslation + + # Determine product_type from is_digital flag + is_digital = data.get("is_digital", False) + product_type = "digital" if is_digital else data.get("product_type", "physical") + product = Product( vendor_id=data["vendor_id"], vendor_sku=data.get("vendor_sku"), brand=data.get("brand"), gtin=data.get("gtin"), - price=data.get("price"), + gtin_type=data.get("gtin_type"), currency=data.get("currency", "EUR"), + tax_rate_percent=data.get("tax_rate_percent", 17), availability=data.get("availability"), + primary_image_url=data.get("primary_image_url"), is_active=data.get("is_active", True), is_featured=data.get("is_featured", False), - is_digital=data.get("is_digital", False), - description=data.get("description"), + is_digital=is_digital, + product_type=product_type, ) + # Handle price fields via setters (convert to cents) + if data.get("price") is not None: + product.price = data["price"] + if data.get("sale_price") is not None: + product.sale_price = data["sale_price"] + db.add(product) + db.flush() # Get the product ID + + # Handle translations dict (new format with multiple languages) + translations = data.get("translations") + if translations: + for lang, trans_data in translations.items(): + if trans_data and (trans_data.get("title") or trans_data.get("description")): + translation = ProductTranslation( + product_id=product.id, + language=lang, + title=trans_data.get("title"), + description=trans_data.get("description"), + ) + db.add(translation) + else: + # Fallback for old format with single title/description + title = data.get("title") + description = data.get("description") + if title or description: + translation = ProductTranslation( + product_id=product.id, + language="en", + title=title, + description=description, + ) + db.add(translation) + db.flush() logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}") @@ -327,7 +369,6 @@ class VendorProductService: product.cost = data["cost"] # Uses property setter # Update other allowed fields - # Note: is_digital is derived from marketplace_product, not directly updatable updatable_fields = [ "vendor_sku", "brand", @@ -335,6 +376,8 @@ class VendorProductService: "gtin_type", "currency", "tax_rate_percent", + "availability", + "is_digital", "is_active", "is_featured", "primary_image_url", @@ -370,9 +413,22 @@ class VendorProductService: """Build a product list item dict.""" mp = product.marketplace_product - # Get title from marketplace product translations + # Get title: prefer vendor translations, fallback to marketplace translations title = None - if mp: + # First try vendor's own translations + if product.translations: + for trans in product.translations: + if trans.language == language and trans.title: + title = trans.title + break + # Fallback to English if requested language not found + if not title: + for trans in product.translations: + if trans.language == "en" and trans.title: + title = trans.title + break + # Fallback to marketplace translations + if not title and mp: title = mp.get_title(language) return { diff --git a/app/templates/admin/vendor-product-create.html b/app/templates/admin/vendor-product-create.html index 683b24f5..cea076e8 100644 --- a/app/templates/admin/vendor-product-create.html +++ b/app/templates/admin/vendor-product-create.html @@ -61,28 +61,88 @@ Vendor
- +

The vendor whose catalog this product will be added to

- +

- Basic Information + Product Information *

-
+ + +
+ +
+ + + +
+ + +
+

+ Product Identifiers +

+
- - + +
+ + +
@@ -93,24 +153,28 @@ placeholder="Brand name" />
-
- - -
+
+ + +
@@ -119,17 +183,35 @@

Pricing

-
- {# noqa: FE-008 - Using raw number input for price field #} +
+ {# noqa: FE-008 - Using raw number input for price with EUR prefix #}
- - + +
+ EUR + +
+
+
+ +
+ EUR + +
@@ -142,6 +224,18 @@
+
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+

+ Product Type & Status

+ -
- -
-

- Description -

- -
-
+ +
@@ -416,7 +416,7 @@ class="px-4 py-2 text-sm font-medium text-red-600 border border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20" :disabled="saving" > - + Delete
diff --git a/app/templates/vendor/product-create.html b/app/templates/vendor/product-create.html new file mode 100644 index 00000000..55699c41 --- /dev/null +++ b/app/templates/vendor/product-create.html @@ -0,0 +1,174 @@ +{# app/templates/vendor/product-create.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} + +{% block title %}Create Product{% endblock %} + +{% block alpine_data %}vendorProductCreate(){% endblock %} + +{% block content %} +{% call detail_page_header("'Create Product'", backUrl) %} + Add a new product to your catalog +{% endcall %} + + +
+ +
+

+ Basic Information +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Pricing +

+
+ {# noqa: FE-008 - Using raw number input for price field #} +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ Status +

+
+ + + +
+
+ + +
+

+ Description +

+ +
+ + +
+ + Cancel + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md new file mode 100644 index 00000000..d64b8826 --- /dev/null +++ b/docs/architecture/product-architecture.md @@ -0,0 +1,291 @@ +# Product Architecture + +## Overview + +The product management system uses an **independent copy pattern** where vendor products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only. + +## Core Principles + +| Principle | Description | +|-----------|-------------| +| **Full Independence** | Vendor products have all their own fields - no inheritance or fallback to marketplace | +| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly | +| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation | +| **Source for Display Only** | Source comparison info is read-only, used for "view original" display | + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MarketplaceProduct │ +│ (Central Repository - raw imported data from marketplaces) │ +│ │ +│ - marketplace_product_id (unique) │ +│ - gtin, mpn, sku │ +│ - brand, price_cents, sale_price_cents │ +│ - is_digital, product_type_enum │ +│ - translations (via MarketplaceProductTranslation) │ +└──────────────────────────────────────────────────────────────────────┘ + + ╳ No runtime dependency + │ + │ Optional FK (for "view source" display only) + │ marketplace_product_id (nullable) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Product │ +│ (Vendor's Independent Product - fully standalone) │ +│ │ +│ === IDENTIFIERS === │ +│ - vendor_id (required) │ +│ - vendor_sku │ +│ - gtin, gtin_type │ +│ │ +│ === PRODUCT TYPE (own columns) === │ +│ - is_digital (Boolean) │ +│ - product_type (String: physical, digital, service, subscription) │ +│ │ +│ === PRICING === │ +│ - price_cents, sale_price_cents │ +│ - currency, tax_rate_percent │ +│ │ +│ === CONTENT === │ +│ - brand, condition, availability │ +│ - primary_image_url, additional_images │ +│ - translations (via ProductTranslation) │ +│ │ +│ === STATUS === │ +│ - is_active, is_featured │ +│ │ +│ === SUPPLIER === │ +│ - supplier, cost_cents │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Product Creation Patterns + +### 1. From Marketplace Source (Import) + +When copying from a marketplace product: +- All fields are **copied** at creation time +- `marketplace_product_id` is set for source reference +- No ongoing relationship - product is immediately independent + +```python +# Service copies all fields at import time +product = Product( + vendor_id=vendor.id, + marketplace_product_id=marketplace_product.id, # Source reference + # All fields copied - no inheritance + brand=marketplace_product.brand, + price=marketplace_product.price, + is_digital=marketplace_product.is_digital, + product_type=marketplace_product.product_type_enum, + # ... all other fields +) +``` + +### 2. Direct Creation (No Marketplace Source) + +Vendors can create products directly without a marketplace source: + +```python +product = Product( + vendor_id=vendor.id, + marketplace_product_id=None, # No source + vendor_sku="DIRECT_001", + brand="MyBrand", + price=29.99, + is_digital=True, + product_type="digital", + is_active=True, +) +``` + +--- + +## Key Fields + +### Product Type Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) | +| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription | + +These are **independent columns** on Product, not derived from MarketplaceProduct. + +### Source Reference + +| Field | Type | Nullable | Description | +|-------|------|----------|-------------| +| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct | + +--- + +## Inventory Handling + +Digital and physical products have different inventory behavior: + +```python +@property +def has_unlimited_inventory(self) -> bool: + """Digital products have unlimited inventory.""" + return self.is_digital + +@property +def total_inventory(self) -> int: + """Get total inventory across all locations.""" + if self.is_digital: + return Product.UNLIMITED_INVENTORY # 999999 + return sum(inv.quantity for inv in self.inventory_entries) +``` + +--- + +## Source Comparison (Display Only) + +For products with a marketplace source, we provide comparison info for display: + +```python +def get_source_comparison_info(self) -> dict: + """Get current values with source values for comparison. + + Used for "view original source" display feature. + """ + mp = self.marketplace_product + return { + "price": self.price, + "price_source": mp.price if mp else None, + "brand": self.brand, + "brand_source": mp.brand if mp else None, + # ... other fields + } +``` + +This is **read-only** - there's no mechanism to "reset" to source values. + +--- + +## UI Behavior + +### Detail Page + +| Product Type | Source Info Card | Edit Button Text | +|-------------|------------------|------------------| +| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" | +| Directly created | Shows "Direct Creation" badge | "Edit Product" | + +### Info Banner + +- **Marketplace-sourced**: Purple banner - "Vendor Product Catalog Entry" +- **Directly created**: Blue banner - "Directly Created Product" + +--- + +## Database Schema + +### Product Table Key Columns + +```sql +CREATE TABLE products ( + id INTEGER PRIMARY KEY, + vendor_id INTEGER NOT NULL REFERENCES vendors(id), + marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable! + + -- Product Type (independent columns) + is_digital BOOLEAN DEFAULT FALSE, + product_type VARCHAR(20) DEFAULT 'physical', + + -- Identifiers + vendor_sku VARCHAR, + gtin VARCHAR, + gtin_type VARCHAR(10), + brand VARCHAR, + + -- Pricing (in cents) + price_cents INTEGER, + sale_price_cents INTEGER, + currency VARCHAR(3) DEFAULT 'EUR', + tax_rate_percent INTEGER DEFAULT 17, + availability VARCHAR, + + -- Media + primary_image_url VARCHAR, + additional_images JSON, + + -- Status + is_active BOOLEAN DEFAULT TRUE, + is_featured BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- Index for product type queries +CREATE INDEX idx_product_is_digital ON products(is_digital); +``` + +--- + +## Migration History + +| Migration | Description | +|-----------|-------------| +| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable | +| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products | + +--- + +## API Endpoints + +### Create Product (Admin) + +``` +POST /api/v1/admin/vendor-products +{ + "vendor_id": 1, + "translations": { + "en": {"title": "Product Name", "description": "..."}, + "fr": {"title": "Nom du produit", "description": "..."} + }, + "vendor_sku": "SKU001", + "brand": "BrandName", + "price": 29.99, + "is_digital": false, + "is_active": true +} +``` + +### Update Product (Admin) + +``` +PATCH /api/v1/admin/vendor-products/{id} +{ + "is_digital": true, + "price": 39.99, + "translations": { + "en": {"title": "Updated Name"} + } +} +``` + +--- + +## Testing + +Key test scenarios: + +1. **Direct Product Creation** - Create without marketplace source +2. **Digital Product Inventory** - Verify unlimited inventory for digital +3. **is_digital Column** - Verify it's an independent column, not derived +4. **Source Comparison** - Verify read-only source info display + +See: +- `tests/unit/models/database/test_product.py` +- `tests/integration/api/v1/admin/test_vendor_products.py` diff --git a/mkdocs.yml b/mkdocs.yml index 4b151532..2483bc26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Architecture Patterns: architecture/architecture-patterns.md - Multi-Tenant System: architecture/multi-tenant.md - Marketplace Integration: architecture/marketplace-integration.md + - Product Architecture: architecture/product-architecture.md - Language & i18n: architecture/language-i18n.md - Money Handling: architecture/money-handling.md - Company-Vendor Management: architecture/company-vendor-management.md diff --git a/models/database/product.py b/models/database/product.py index 35c69b0a..a07dcfbc 100644 --- a/models/database/product.py +++ b/models/database/product.py @@ -1,11 +1,11 @@ """Vendor Product model - independent copy pattern. -This model represents a vendor's copy of a marketplace product. Products are -independent entities with all fields populated at creation time from the source -marketplace product. +This model represents a vendor's product. Products can be: +1. Created from a marketplace import (has marketplace_product_id) +2. Created directly by the vendor (no marketplace_product_id) -The marketplace_product_id FK is kept for "view original source" feature, -allowing comparison with the original marketplace data. +When created from marketplace, the marketplace_product_id FK provides +"view original source" comparison feature. Money values are stored as integer cents (e.g., €105.91 = 10591). See docs/architecture/money-handling.md for details. @@ -29,11 +29,10 @@ from models.database.base import TimestampMixin class Product(Base, TimestampMixin): - """Vendor-specific product - independent copy. + """Vendor-specific product. - Each vendor has their own copy of a marketplace product with all fields - populated at creation time. The marketplace_product_id FK is kept for - "view original source" comparison feature. + Products can be created from marketplace imports or directly by vendors. + When from marketplace, marketplace_product_id provides source comparison. Price fields use integer cents for precision (€19.99 = 1999 cents). """ @@ -43,7 +42,7 @@ class Product(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) marketplace_product_id = Column( - Integer, ForeignKey("marketplace_products.id"), nullable=False + Integer, ForeignKey("marketplace_products.id"), nullable=True ) # === VENDOR REFERENCE === @@ -85,7 +84,11 @@ class Product(Base, TimestampMixin): cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550) - # === VENDOR-SPECIFIC (No inheritance) === + # === PRODUCT TYPE === + is_digital = Column(Boolean, default=False, index=True) + product_type = Column(String(20), default="physical") # physical, digital, service, subscription + + # === VENDOR-SPECIFIC === is_featured = Column(Boolean, default=False) is_active = Column(Boolean, default=True) display_order = Column(Integer, default=0) @@ -249,20 +252,6 @@ class Product(Base, TimestampMixin): return None return round((profit / net) * 100, 2) - # === MARKETPLACE PRODUCT PROPERTIES === - - @property - def is_digital(self) -> bool: - """Check if this is a digital product.""" - mp = self.marketplace_product - return mp.is_digital if mp else False - - @property - def product_type(self) -> str: - """Get product type from marketplace product.""" - mp = self.marketplace_product - return mp.product_type_enum if mp else "physical" - # === INVENTORY PROPERTIES === # Constant for unlimited inventory (digital products) @@ -305,6 +294,7 @@ class Product(Base, TimestampMixin): Returns a dict with current field values and original source values from the marketplace product. Used for "view original source" feature. + Only populated when product was created from a marketplace source. """ mp = self.marketplace_product return { @@ -331,7 +321,7 @@ class Product(Base, TimestampMixin): # Images "primary_image_url": self.primary_image_url, "primary_image_url_source": mp.image_link if mp else None, - # Product type info + # Product type (independent fields, no source comparison) "is_digital": self.is_digital, "product_type": self.product_type, } diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py index a47a1aea..cfffca01 100644 --- a/models/schema/vendor_product.py +++ b/models/schema/vendor_product.py @@ -85,7 +85,7 @@ class VendorProductDetail(BaseModel): vendor_id: int vendor_name: str | None = None vendor_code: str | None = None - marketplace_product_id: int + marketplace_product_id: int | None = None # Optional for direct product creation vendor_sku: str | None = None # Product identifiers gtin: str | None = None @@ -149,10 +149,46 @@ class RemoveProductResponse(BaseModel): message: str +class TranslationUpdate(BaseModel): + """Translation data for a single language.""" + + title: str | None = None + description: str | None = None + + class VendorProductCreate(BaseModel): - """Schema for creating a vendor product.""" + """Schema for creating a vendor product (admin use - includes vendor_id).""" vendor_id: int + + # Translations by language code (en, fr, de, lu) + translations: dict[str, TranslationUpdate] | None = None + + # Product identifiers + brand: str | None = None + vendor_sku: str | None = None + gtin: str | None = None + gtin_type: str | None = None # ean13, ean8, upc, isbn + + # Pricing + price: float | None = None + sale_price: float | None = None + currency: str = "EUR" + tax_rate_percent: int | None = 17 # Default Luxembourg VAT + availability: str | None = None + + # Image + primary_image_url: str | None = None + + # Status + is_active: bool = True + is_featured: bool = False + is_digital: bool = False + + +class VendorDirectProductCreate(BaseModel): + """Schema for vendor direct product creation (vendor_id from JWT token).""" + title: str brand: str | None = None vendor_sku: str | None = None @@ -162,14 +198,6 @@ class VendorProductCreate(BaseModel): availability: str | None = None is_active: bool = True is_featured: bool = False - is_digital: bool = False - description: str | None = None - - -class TranslationUpdate(BaseModel): - """Translation data for a single language.""" - - title: str | None = None description: str | None = None @@ -190,8 +218,10 @@ class VendorProductUpdate(BaseModel): sale_price: float | None = None # Optional sale price currency: str | None = None tax_rate_percent: int | None = None # 3, 8, 14, 17 + availability: str | None = None # in_stock, out_of_stock, preorder, backorder - # Status (is_digital is derived from marketplace product, not editable) + # Status + is_digital: bool | None = None is_active: bool | None = None is_featured: bool | None = None diff --git a/static/admin/js/vendor-product-create.js b/static/admin/js/vendor-product-create.js index bdcccb7f..610f7e86 100644 --- a/static/admin/js/vendor-product-create.js +++ b/static/admin/js/vendor-product-create.js @@ -1,7 +1,7 @@ // static/admin/js/vendor-product-create.js /** * Admin vendor product create page logic - * Create new vendor product entries + * Create new vendor product entries with translations */ const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate || @@ -12,6 +12,14 @@ adminVendorProductCreateLog.info('Loading...'); function adminVendorProductCreate() { adminVendorProductCreateLog.info('adminVendorProductCreate() called'); + // Default translations structure + const defaultTranslations = () => ({ + en: { title: '', description: '' }, + fr: { title: '', description: '' }, + de: { title: '', description: '' }, + lu: { title: '', description: '' } + }); + return { // Inherit base layout state ...data(), @@ -26,20 +34,31 @@ function adminVendorProductCreate() { // Tom Select instance vendorSelectInstance: null, + // Active language tab + activeLanguage: 'en', + // Form data form: { vendor_id: null, - title: '', - brand: '', + // Translations by language + translations: defaultTranslations(), + // Product identifiers vendor_sku: '', + brand: '', gtin: '', + gtin_type: '', + // Pricing price: null, + sale_price: null, currency: 'EUR', + tax_rate_percent: 17, availability: '', + // Image + primary_image_url: '', + // Status is_active: true, is_featured: false, - is_digital: false, - description: '' + is_digital: false }, async init() { @@ -111,6 +130,33 @@ function adminVendorProductCreate() { adminVendorProductCreateLog.info('Vendor select initialized'); }, + /** + * Generate a unique vendor SKU + * Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness) + */ + generateSku() { + const vendorId = this.form.vendor_id || 0; + + // Generate random alphanumeric segments + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const generateSegment = (length) => { + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + // First segment includes vendor ID (padded) + const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4); + + // Generate SKU: VID + random + random + const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`; + this.form.vendor_sku = sku; + + adminVendorProductCreateLog.info('Generated SKU:', sku); + }, + /** * Create the product */ @@ -120,30 +166,54 @@ function adminVendorProductCreate() { return; } - if (!this.form.title) { - Utils.showToast('Please enter a product title', 'error'); + if (!this.form.translations.en.title?.trim()) { + Utils.showToast('Please enter a product title (English)', 'error'); return; } this.saving = true; try { + // Build translations object for API (only include non-empty) + const translations = {}; + for (const lang of ['en', 'fr', 'de', 'lu']) { + const t = this.form.translations[lang]; + if (t.title?.trim() || t.description?.trim()) { + translations[lang] = { + title: t.title?.trim() || null, + description: t.description?.trim() || null + }; + } + } + // Build create payload const payload = { vendor_id: this.form.vendor_id, - title: this.form.title, - brand: this.form.brand || null, - vendor_sku: this.form.vendor_sku || null, - gtin: this.form.gtin || null, - price: this.form.price ? parseFloat(this.form.price) : null, + translations: Object.keys(translations).length > 0 ? translations : null, + // Product identifiers + brand: this.form.brand?.trim() || null, + vendor_sku: this.form.vendor_sku?.trim() || null, + gtin: this.form.gtin?.trim() || null, + gtin_type: this.form.gtin_type || null, + // Pricing + price: this.form.price !== null && this.form.price !== '' + ? parseFloat(this.form.price) : null, + sale_price: this.form.sale_price !== null && this.form.sale_price !== '' + ? parseFloat(this.form.sale_price) : null, currency: this.form.currency || 'EUR', + tax_rate_percent: this.form.tax_rate_percent !== null + ? parseInt(this.form.tax_rate_percent) : 17, availability: this.form.availability || null, + // Image + primary_image_url: this.form.primary_image_url?.trim() || null, + // Status is_active: this.form.is_active, is_featured: this.form.is_featured, - is_digital: this.form.is_digital, - description: this.form.description || null + is_digital: this.form.is_digital }; + adminVendorProductCreateLog.info('Creating product with payload:', payload); + const response = await apiClient.post('/admin/vendor-products', payload); adminVendorProductCreateLog.info('Product created:', response.id); diff --git a/static/admin/js/vendor-product-edit.js b/static/admin/js/vendor-product-edit.js index 6dc69024..c2aa2b8c 100644 --- a/static/admin/js/vendor-product-edit.js +++ b/static/admin/js/vendor-product-edit.js @@ -59,6 +59,7 @@ function adminVendorProductEdit() { sale_price: null, currency: 'EUR', tax_rate_percent: 17, + availability: '', // Image primary_image_url: '', // Product type & status @@ -125,6 +126,7 @@ function adminVendorProductEdit() { sale_price: response.sale_price || null, currency: response.currency || 'EUR', tax_rate_percent: response.tax_rate_percent ?? 17, + availability: response.availability || '', // Image primary_image_url: response.primary_image_url || '', // Product type & status @@ -238,9 +240,11 @@ function adminVendorProductEdit() { currency: this.form.currency || null, tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== '' ? parseInt(this.form.tax_rate_percent) : null, + availability: this.form.availability || null, // Image primary_image_url: this.form.primary_image_url?.trim() || null, - // Status (is_digital is derived from marketplace product, not editable) + // Status + is_digital: this.form.is_digital, is_active: this.form.is_active, is_featured: this.form.is_featured, // Optional supplier info diff --git a/static/vendor/js/product-create.js b/static/vendor/js/product-create.js new file mode 100644 index 00000000..6c59d694 --- /dev/null +++ b/static/vendor/js/product-create.js @@ -0,0 +1,114 @@ +// static/vendor/js/product-create.js +/** + * Vendor product creation page logic + */ + +const vendorProductCreateLog = window.LogConfig.loggers.vendorProductCreate || + window.LogConfig.createLogger('vendorProductCreate', false); + +vendorProductCreateLog.info('Loading...'); + +function vendorProductCreate() { + vendorProductCreateLog.info('vendorProductCreate() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'products', + + // Back URL + get backUrl() { + return `/vendor/${this.vendorCode}/products`; + }, + + // Loading states + loading: false, + saving: false, + error: '', + + // Form data + form: { + title: '', + brand: '', + vendor_sku: '', + gtin: '', + price: '', + currency: 'EUR', + availability: 'in_stock', + is_active: true, + is_featured: false, + is_digital: false, + description: '' + }, + + async init() { + // Guard against duplicate initialization + if (window._vendorProductCreateInitialized) return; + window._vendorProductCreateInitialized = true; + + vendorProductCreateLog.info('Initializing product create page...'); + + try { + // IMPORTANT: Call parent init first to set vendorCode from URL + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + vendorProductCreateLog.info('Product create page initialized'); + } catch (err) { + vendorProductCreateLog.error('Failed to initialize:', err); + this.error = err.message || 'Failed to initialize'; + } + }, + + async createProduct() { + if (!this.form.title || !this.form.price) { + this.showToast('Title and price are required', 'error'); + return; + } + + this.saving = true; + this.error = ''; + + try { + // Create product directly (vendor_id from JWT token) + const response = await apiClient.post('/vendor/products/create', { + title: this.form.title, + brand: this.form.brand || null, + vendor_sku: this.form.vendor_sku || null, + gtin: this.form.gtin || null, + price: parseFloat(this.form.price), + currency: this.form.currency, + availability: this.form.availability, + is_active: this.form.is_active, + is_featured: this.form.is_featured, + description: this.form.description || null + }); + + if (!response.ok) { + throw new Error(response.message || 'Failed to create product'); + } + + vendorProductCreateLog.info('Product created:', response.data); + this.showToast('Product created successfully', 'success'); + + // Navigate back to products list + setTimeout(() => { + window.location.href = this.backUrl; + }, 1000); + + } catch (err) { + vendorProductCreateLog.error('Failed to create product:', err); + this.error = err.message || 'Failed to create product'; + this.showToast(this.error, 'error'); + } finally { + this.saving = false; + } + } + }; +} + +vendorProductCreateLog.info('Loaded successfully'); diff --git a/tests/integration/api/v1/admin/test_vendor_products.py b/tests/integration/api/v1/admin/test_vendor_products.py index eed1c03f..4028fd77 100644 --- a/tests/integration/api/v1/admin/test_vendor_products.py +++ b/tests/integration/api/v1/admin/test_vendor_products.py @@ -210,3 +210,287 @@ class TestAdminVendorProductsAPI: ) assert response.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.admin +@pytest.mark.products +class TestAdminVendorProductCreateAPI: + """Tests for admin vendor product creation endpoints.""" + + def test_create_vendor_product_with_translations( + self, client, admin_headers, test_vendor + ): + """Test creating a product with multi-language translations.""" + payload = { + "vendor_id": test_vendor.id, + "translations": { + "en": {"title": "Test Product EN", "description": "English description"}, + "fr": {"title": "Test Product FR", "description": "French description"}, + }, + "vendor_sku": "CREATE_TEST_001", + "brand": "TestBrand", + "gtin": "1234567890123", + "gtin_type": "ean13", + "price": 29.99, + "currency": "EUR", + "tax_rate_percent": 17, + "is_active": True, + "is_digital": False, + } + + response = client.post( + "/api/v1/admin/vendor-products", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "id" in data + assert data["message"] == "Product created successfully" + + # Verify the created product + product_id = data["id"] + detail_response = client.get( + f"/api/v1/admin/vendor-products/{product_id}", + headers=admin_headers, + ) + assert detail_response.status_code == 200 + detail = detail_response.json() + assert detail["vendor_id"] == test_vendor.id + assert detail["vendor_sku"] == "CREATE_TEST_001" + assert detail["brand"] == "TestBrand" + assert detail["is_digital"] is False + assert detail["vendor_translations"]["en"]["title"] == "Test Product EN" + + def test_create_digital_product(self, client, admin_headers, test_vendor): + """Test creating a digital product directly.""" + payload = { + "vendor_id": test_vendor.id, + "translations": { + "en": {"title": "Digital Game Key", "description": "Steam game key"}, + }, + "vendor_sku": "DIGITAL_001", + "price": 49.99, + "is_digital": True, + "is_active": True, + } + + response = client.post( + "/api/v1/admin/vendor-products", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + + # Verify the product is digital + detail_response = client.get( + f"/api/v1/admin/vendor-products/{data['id']}", + headers=admin_headers, + ) + detail = detail_response.json() + assert detail["is_digital"] is True + assert detail["product_type"] == "digital" + + def test_create_product_without_marketplace_source( + self, client, admin_headers, test_vendor + ): + """Test creating a direct product without marketplace source.""" + payload = { + "vendor_id": test_vendor.id, + "translations": { + "en": {"title": "Direct Product", "description": "Created directly"}, + }, + "vendor_sku": "DIRECT_001", + "brand": "DirectBrand", + "price": 19.99, + "is_active": True, + } + + response = client.post( + "/api/v1/admin/vendor-products", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + + # Verify there's no marketplace source + detail_response = client.get( + f"/api/v1/admin/vendor-products/{data['id']}", + headers=admin_headers, + ) + detail = detail_response.json() + assert detail["marketplace_product_id"] is None + assert detail["source_marketplace"] is None + assert detail["source_vendor"] is None + + def test_create_product_non_admin(self, client, auth_headers, test_vendor): + """Test non-admin trying to create product.""" + payload = { + "vendor_id": test_vendor.id, + "translations": {"en": {"title": "Test"}}, + } + + response = client.post( + "/api/v1/admin/vendor-products", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.admin +@pytest.mark.products +class TestAdminVendorProductUpdateAPI: + """Tests for admin vendor product update endpoints.""" + + def test_update_vendor_product_translations( + self, client, admin_headers, test_vendor + ): + """Test updating product translations by first creating a product with translations.""" + # First create a product with translations + create_payload = { + "vendor_id": test_vendor.id, + "translations": { + "en": {"title": "Original Title EN", "description": "Original desc"}, + }, + "vendor_sku": "TRANS_TEST_001", + "price": 10.00, + "is_active": True, + } + create_response = client.post( + "/api/v1/admin/vendor-products", + json=create_payload, + headers=admin_headers, + ) + assert create_response.status_code == 200 + product_id = create_response.json()["id"] + + # Now update the translations + update_payload = { + "translations": { + "en": {"title": "Updated Title EN", "description": "Updated desc EN"}, + "de": {"title": "Updated Title DE", "description": "Updated desc DE"}, + } + } + + response = client.patch( + f"/api/v1/admin/vendor-products/{product_id}", + json=update_payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + + # Re-fetch the product to verify translations were saved + detail_response = client.get( + f"/api/v1/admin/vendor-products/{product_id}", + headers=admin_headers, + ) + assert detail_response.status_code == 200 + data = detail_response.json() + + # Check translations are present and updated + assert "vendor_translations" in data + assert data["vendor_translations"] is not None + assert "en" in data["vendor_translations"] + assert data["vendor_translations"]["en"]["title"] == "Updated Title EN" + + def test_update_vendor_product_is_digital( + self, client, admin_headers, test_product, db + ): + """Test updating product is_digital flag.""" + # First ensure it's not digital + test_product.is_digital = False + db.commit() + + payload = {"is_digital": True} + + response = client.patch( + f"/api/v1/admin/vendor-products/{test_product.id}", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["is_digital"] is True + + def test_update_vendor_product_pricing(self, client, admin_headers, test_product): + """Test updating product pricing fields.""" + payload = { + "price": 99.99, + "sale_price": 79.99, + "tax_rate_percent": 8, + "availability": "in_stock", + } + + response = client.patch( + f"/api/v1/admin/vendor-products/{test_product.id}", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["price"] == 99.99 + assert data["sale_price"] == 79.99 + assert data["tax_rate_percent"] == 8 + assert data["availability"] == "in_stock" + + def test_update_vendor_product_identifiers( + self, client, admin_headers, test_product + ): + """Test updating product identifiers.""" + payload = { + "vendor_sku": "UPDATED_SKU_001", + "brand": "UpdatedBrand", + "gtin": "9876543210123", + "gtin_type": "ean13", + } + + response = client.patch( + f"/api/v1/admin/vendor-products/{test_product.id}", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["vendor_sku"] == "UPDATED_SKU_001" + assert data["brand"] == "UpdatedBrand" + assert data["gtin"] == "9876543210123" + + def test_update_vendor_product_not_found(self, client, admin_headers): + """Test updating non-existent product.""" + payload = {"brand": "Test"} + + response = client.patch( + "/api/v1/admin/vendor-products/99999", + json=payload, + headers=admin_headers, + ) + + assert response.status_code == 404 + assert response.json()["error_code"] == "PRODUCT_NOT_FOUND" + + def test_update_vendor_product_non_admin(self, client, auth_headers, test_product): + """Test non-admin trying to update product.""" + payload = {"brand": "Test"} + + response = client.patch( + f"/api/v1/admin/vendor-products/{test_product.id}", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 403 diff --git a/tests/unit/models/database/test_product.py b/tests/unit/models/database/test_product.py index 9f07b4c4..5b22a568 100644 --- a/tests/unit/models/database/test_product.py +++ b/tests/unit/models/database/test_product.py @@ -72,6 +72,8 @@ class TestProductModel: assert product.is_active is True # Default assert product.is_featured is False # Default + assert product.is_digital is False # Default + assert product.product_type == "physical" # Default assert product.min_quantity == 1 # Default assert product.display_order == 0 # Default @@ -202,6 +204,82 @@ class TestProductModel: assert info["price_source"] == 100.00 assert info["brand_source"] == "SourceBrand" + def test_product_direct_creation_without_marketplace(self, db, test_vendor): + """Test creating a product directly without a marketplace source. + + Products can be created directly without a marketplace_product_id, + making them fully independent vendor products. + """ + product = Product( + vendor_id=test_vendor.id, + marketplace_product_id=None, # No marketplace source + vendor_sku="DIRECT_001", + brand="DirectBrand", + price=59.99, + currency="EUR", + is_digital=True, + product_type="digital", + is_active=True, + ) + db.add(product) + db.commit() + db.refresh(product) + + assert product.id is not None + assert product.marketplace_product_id is None + assert product.marketplace_product is None + assert product.vendor_sku == "DIRECT_001" + assert product.brand == "DirectBrand" + assert product.is_digital is True + assert product.product_type == "digital" + + def test_product_is_digital_column(self, db, test_vendor): + """Test is_digital is an independent column, not derived from marketplace.""" + # Create digital product without marketplace source + digital_product = Product( + vendor_id=test_vendor.id, + vendor_sku="DIGITAL_001", + is_digital=True, + product_type="digital", + ) + db.add(digital_product) + db.commit() + db.refresh(digital_product) + + assert digital_product.is_digital is True + assert digital_product.product_type == "digital" + + # Create physical product without marketplace source + physical_product = Product( + vendor_id=test_vendor.id, + vendor_sku="PHYSICAL_001", + is_digital=False, + product_type="physical", + ) + db.add(physical_product) + db.commit() + db.refresh(physical_product) + + assert physical_product.is_digital is False + assert physical_product.product_type == "physical" + + def test_product_type_values(self, db, test_vendor): + """Test product_type can be set to various values.""" + product_types = ["physical", "digital", "service", "subscription"] + + for ptype in product_types: + product = Product( + vendor_id=test_vendor.id, + vendor_sku=f"TYPE_{ptype.upper()}", + product_type=ptype, + is_digital=(ptype == "digital"), + ) + db.add(product) + db.commit() + db.refresh(product) + + assert product.product_type == ptype + @pytest.mark.unit @pytest.mark.database @@ -209,17 +287,13 @@ class TestProductModel: class TestProductInventoryProperties: """Test Product inventory properties including digital product handling.""" - def test_physical_product_no_inventory_returns_zero( - self, db, test_vendor, test_marketplace_product - ): + def test_physical_product_no_inventory_returns_zero(self, db, test_vendor): """Test physical product with no inventory entries returns 0.""" - # Ensure product is physical - test_marketplace_product.is_digital = False - db.commit() - product = Product( vendor_id=test_vendor.id, - marketplace_product_id=test_marketplace_product.id, + vendor_sku="PHYS_INV_001", + is_digital=False, + product_type="physical", ) db.add(product) db.commit() @@ -230,18 +304,15 @@ class TestProductInventoryProperties: assert product.total_inventory == 0 assert product.available_inventory == 0 - def test_physical_product_with_inventory( - self, db, test_vendor, test_marketplace_product - ): + def test_physical_product_with_inventory(self, db, test_vendor): """Test physical product calculates inventory from entries.""" from models.database.inventory import Inventory - test_marketplace_product.is_digital = False - db.commit() - product = Product( vendor_id=test_vendor.id, - marketplace_product_id=test_marketplace_product.id, + vendor_sku="PHYS_INV_002", + is_digital=False, + product_type="physical", ) db.add(product) db.commit() @@ -274,16 +345,13 @@ class TestProductInventoryProperties: assert product.total_inventory == 150 # 100 + 50 assert product.available_inventory == 135 # (100-10) + (50-5) - def test_digital_product_has_unlimited_inventory( - self, db, test_vendor, test_marketplace_product - ): + def test_digital_product_has_unlimited_inventory(self, db, test_vendor): """Test digital product returns unlimited inventory.""" - test_marketplace_product.is_digital = True - db.commit() - product = Product( vendor_id=test_vendor.id, - marketplace_product_id=test_marketplace_product.id, + vendor_sku="DIG_INV_001", + is_digital=True, + product_type="digital", ) db.add(product) db.commit() @@ -294,18 +362,15 @@ class TestProductInventoryProperties: assert product.total_inventory == Product.UNLIMITED_INVENTORY assert product.available_inventory == Product.UNLIMITED_INVENTORY - def test_digital_product_ignores_inventory_entries( - self, db, test_vendor, test_marketplace_product - ): + def test_digital_product_ignores_inventory_entries(self, db, test_vendor): """Test digital product returns unlimited even with inventory entries.""" from models.database.inventory import Inventory - test_marketplace_product.is_digital = True - db.commit() - product = Product( vendor_id=test_vendor.id, - marketplace_product_id=test_marketplace_product.id, + vendor_sku="DIG_INV_002", + is_digital=True, + product_type="digital", ) db.add(product) db.commit()