feat: make Product fully independent from MarketplaceProduct

- Add is_digital and product_type columns to Product model
- Remove is_digital/product_type properties that derived from MarketplaceProduct
- Update Create form with translation tabs, GTIN type, sale price, VAT rate, image
- Update Edit form to allow editing is_digital (remove disabled state)
- Add Availability field to Edit form
- Fix Detail page for directly created products (no marketplace source)
- Update vendor_product_service to handle new fields in create/update
- Add VendorProductCreate/Update schema fields for translations and is_digital
- Add unit tests for is_digital column and direct product creation
- Add integration tests for create/update API with new fields
- Create product-architecture.md documenting the independent copy pattern
- Add migration y3d4e5f6g7h8 for is_digital and product_type columns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-08 01:11:00 +01:00
parent 7b81f59eba
commit fa2a3bf89a
19 changed files with 1603 additions and 201 deletions

View File

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

View File

@@ -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()