Repository: saleor/saleor

Issue:
Mutation to create products doesn't create the default variant
### Steps to reproduce the problem
1. In the new dashboard, create a product of a type that doesn't use variants.

### What I expected to happen
A product is created correctly, but it is missing the default variant, that should be created in that case as well.



Discussion:
Does it mean a field `defaultVariant` should be added to `ProductCreateInput`, which should be ignored in case if this type has variants and added as a variant otherwise?
To give a bit more explanation - our whole cart and checkout flow is based on `ProductVariant` instances, but at the same time, we want to allow creating simple products that don't have variants. Hiding variants is done at the presentation layer (in the dashboard and the storefront), while under the hood we always create a "hidden" variant to make the checkout possible.

The issue is that this logic hasn't been implemented in the API yet. For instance, take a look at [the implementation of this logic in the old dashboard](https://github.com/mirumee/saleor/blob/master/saleor/dashboard/product/views.py#L107). As you can see, we artificially create an empty variant there, only for holding the SKU and quantity.

We need to implement the same logic in the API. So when the user creates a product of a type that doesn't use variants, API should create a default variant anyway. I don't know yet how to tackle passing additional data (e.g. SKU or quantity) in the mutation for creating products. Maybe we could try what you propose and have this extra field. I don't know, we would have to implement it and see how it works and looks. For sure there sure be a good description of that field explaining that it is only used in that particular case.

Resolving patch:
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -1,6 +1,7 @@
 from textwrap import dedent
 
 import graphene
+from django.db import transaction
 from django.template.defaultfilters import slugify
 from graphene.types import InputObjectType
 from graphql_jwt.decorators import permission_required
@@ -272,6 +273,18 @@ class ProductInput(graphene.InputObjectType):
     seo = SeoInput(description='Search engine optimization fields.')
     weight = WeightScalar(
         description='Weight of the Product.', required=False)
+    sku = graphene.String(
+        description=dedent("""Stock keeping unit of a product. Note: this
+        field is only used if a product doesn't use variants."""))
+    quantity = graphene.Int(
+        description=dedent("""The total quantity of a product available for
+        sale. Note: this field is only used if a product doesn't
+        use variants."""))
+    track_inventory = graphene.Boolean(
+        description=dedent("""Determines if the inventory of this product
+        should be tracked. If false, the quantity won't change when customers
+        buy this item. Note: this field is only used if a product doesn't
+        use variants."""))
 
 
 class ProductCreateInput(ProductInput):
@@ -311,8 +324,35 @@ def clean_input(cls, info, instance, input, errors):
             else:
                 cleaned_input['attributes'] = attributes
         clean_seo_fields(cleaned_input)
+        cls.clean_sku(product_type, cleaned_input, errors)
         return cleaned_input
 
+    @classmethod
+    def clean_sku(cls, product_type, cleaned_input, errors):
+        """Validate SKU input field.
+
+        When creating products that don't use variants, SKU is required in
+        the input in order to create the default variant underneath.
+        See the documentation for `has_variants` field for details:
+        http://docs.getsaleor.com/en/latest/architecture/products.html#product-types
+        """
+        if not product_type.has_variants and not cleaned_input.get('sku'):
+            cls.add_error(errors, 'sku', 'This field cannot be blank.')
+
+    @classmethod
+    @transaction.atomic
+    def save(cls, info, instance, cleaned_input):
+        instance.save()
+        if not instance.product_type.has_variants:
+            site_settings = info.context.site.settings
+            track_inventory = cleaned_input.get(
+                'track_inventory', site_settings.track_inventory_by_default)
+            quantity = cleaned_input.get('quantity', 0)
+            sku = cleaned_input.get('sku')
+            models.ProductVariant.objects.create(
+                product=instance, track_inventory=track_inventory,
+                sku=sku, quantity=quantity)
+
     @classmethod
     def _save_m2m(cls, info, instance, cleaned_data):
         collections = cleaned_data.get('collections', None)
@@ -335,6 +375,31 @@ class Meta:
         description = 'Updates an existing product.'
         model = models.Product
 
+    @classmethod
+    def clean_sku(cls, product_type, cleaned_input, errors):
+        # SKU is an optional field in the product update mutation,
+        # so we explicitly skip the validation.
+        pass
+
+    @classmethod
+    @transaction.atomic
+    def save(cls, info, instance, cleaned_input):
+        instance.save()
+        if not instance.product_type.has_variants:
+            variant = instance.variants.first()
+            update_fields = []
+            if 'track_inventory' in cleaned_input:
+                variant.track_inventory = cleaned_input['track_inventory']
+                update_fields.append('track_inventory')
+            if 'quantity' in cleaned_input:
+                variant.quantity = cleaned_input['quantity']
+                update_fields.append('quantity')
+            if 'sku' in cleaned_input:
+                variant.sku = cleaned_input['sku']
+                update_fields.append('sku')
+            if update_fields:
+                variant.save(update_fields=update_fields)
+
 
 class ProductDelete(ModelDeleteMutation):
     class Arguments:


Test patch:
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -477,6 +477,128 @@ def test_create_product(
     assert color_value_slug in values
 
 
+QUERY_CREATE_PRODUCT_WITHOUT_VARIANTS = """
+    mutation createProduct(
+        $productTypeId: ID!,
+        $categoryId: ID!
+        $name: String!,
+        $description: String!,
+        $price: Decimal!,
+        $sku: String,
+        $quantity: Int,
+        $trackInventory: Boolean)
+    {
+        productCreate(
+            input: {
+                category: $categoryId,
+                productType: $productTypeId,
+                name: $name,
+                description: $description,
+                price: $price,
+                sku: $sku,
+                quantity: $quantity,
+                trackInventory: $trackInventory
+            })
+        {
+            product {
+                id
+                name
+                variants{
+                    id
+                    sku
+                    quantity
+                    trackInventory
+                }
+                category {
+                    name
+                }
+                productType {
+                    name
+                }
+            }
+            errors {
+                message
+                field
+            }
+        }
+    }
+    """
+
+
+def test_create_product_without_variants(
+        staff_api_client, product_type_without_variant, category,
+        permission_manage_products):
+    query = QUERY_CREATE_PRODUCT_WITHOUT_VARIANTS
+
+    product_type = product_type_without_variant
+    product_type_id = graphene.Node.to_global_id(
+        'ProductType', product_type.pk)
+    category_id = graphene.Node.to_global_id(
+        'Category', category.pk)
+    product_name = 'test name'
+    product_description = 'description'
+    product_price = 10
+    sku = 'sku'
+    quantity = 1
+    track_inventory = True
+
+    variables = {
+        'productTypeId': product_type_id,
+        'categoryId': category_id,
+        'name': product_name,
+        'description': product_description,
+        'price': product_price,
+        'sku': sku,
+        'quantity': quantity,
+        'trackInventory': track_inventory}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['productCreate']
+    assert data['errors'] == []
+    assert data['product']['name'] == product_name
+    assert data['product']['productType']['name'] == product_type.name
+    assert data['product']['category']['name'] == category.name
+    assert data['product']['variants'][0]['sku'] == sku
+    assert data['product']['variants'][0]['quantity'] == quantity
+    assert data['product']['variants'][0]['trackInventory'] == track_inventory
+
+
+def test_create_product_without_variants_sku_validation(
+        staff_api_client, product_type_without_variant, category,
+        permission_manage_products):
+    query = QUERY_CREATE_PRODUCT_WITHOUT_VARIANTS
+
+    product_type = product_type_without_variant
+    product_type_id = graphene.Node.to_global_id(
+        'ProductType', product_type.pk)
+    category_id = graphene.Node.to_global_id(
+        'Category', category.pk)
+    product_name = 'test name'
+    product_description = 'description'
+    product_price = 10
+    quantity = 1
+    track_inventory = True
+
+    variables = {
+        'productTypeId': product_type_id,
+        'categoryId': category_id,
+        'name': product_name,
+        'description': product_description,
+        'price': product_price,
+        'sku': None,
+        'quantity': quantity,
+        'trackInventory': track_inventory}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['productCreate']
+    assert data['errors'][0]['field'] == 'sku'
+    assert data['errors'][0]['message'] == 'This field cannot be blank.'
+
+
 def test_update_product(
         staff_api_client, category, non_default_category, product,
         permission_manage_products):
@@ -567,6 +689,68 @@ def test_update_product(
     assert not data['product']['category']['name'] == category.name
 
 
+def test_update_product_without_variants(
+        staff_api_client, product_with_default_variant,
+        permission_manage_products):
+    query = """
+    mutation updateProduct(
+        $productId: ID!,
+        $sku: String,
+        $quantity: Int,
+        $trackInventory: Boolean,
+        $description: String)
+    {
+        productUpdate(
+            id: $productId,
+            input: {
+                sku: $sku,
+                quantity: $quantity,
+                trackInventory: $trackInventory,
+                description: $description
+            })
+        {
+            product {
+                id
+                variants{
+                    id
+                    sku
+                    quantity
+                    trackInventory
+                }
+            }
+            errors {
+                message
+                field
+            }
+        }
+    }
+    """
+
+    product = product_with_default_variant
+    product_id = graphene.Node.to_global_id('Product', product.pk)
+    product_sku = "test_sku"
+    product_quantity = 10
+    product_track_inventory = False
+    product_description = "test description"
+
+    variables = {
+        'productId': product_id,
+        'sku': product_sku,
+        'quantity': product_quantity,
+        'trackInventory': product_track_inventory,
+        'description': product_description}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['productUpdate']
+    assert data['errors'] == []
+    product = data['product']['variants'][0]
+    assert product['sku'] == product_sku
+    assert product['quantity'] == product_quantity
+    assert product['trackInventory'] == product_track_inventory
+
+
 def test_delete_product(staff_api_client, product, permission_manage_products):
     query = """
         mutation DeleteProduct($id: ID!) {
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -278,12 +278,19 @@ def permission_manage_orders():
 @pytest.fixture
 def product_type(color_attribute, size_attribute):
     product_type = ProductType.objects.create(
-        name='Default Type', has_variants=False, is_shipping_required=True)
+        name='Default Type', has_variants=True, is_shipping_required=True)
     product_type.product_attributes.add(color_attribute)
     product_type.variant_attributes.add(size_attribute)
     return product_type
 
 
+@pytest.fixture
+def product_type_without_variant():
+    product_type = ProductType.objects.create(
+        name='Type', has_variants=False, is_shipping_required=True)
+    return product_type
+
+
 @pytest.fixture
 def product(product_type, category):
     product_attr = product_type.product_attributes.first()
@@ -305,6 +312,17 @@ def product(product_type, category):
     return product
 
 
+@pytest.fixture
+def product_with_default_variant(product_type_without_variant, category):
+    product = Product.objects.create(
+        name='Test product', price=Money('10.00', 'USD'),
+        product_type=product_type_without_variant, category=category)
+    ProductVariant.objects.create(
+        product=product, sku='123', track_inventory=True,
+        quantity=100)
+    return product
+
+
 @pytest.fixture
 def variant(product):
     product_variant = ProductVariant.objects.create(
diff --git a/tests/dashboard/test_product.py b/tests/dashboard/test_product.py
--- a/tests/dashboard/test_product.py
+++ b/tests/dashboard/test_product.py
@@ -467,7 +467,8 @@ def test_view_product_variant_details(admin_client, product):
 
 
 def test_view_product_variant_details_redirect_to_product(
-        admin_client, product):
+        admin_client, product_with_default_variant):
+    product = product_with_default_variant
     variant = product.variants.get()
     url = reverse(
         'dashboard:variant-details',