Repository: saleor/saleor

Issue:
productVariantUpdate mutation fails
### What I'm trying to achieve
I'm trying to update quantity of a variant (but it will probably fail for other fields -it's a general problem with updating variants).

### Steps to reproduce the problem
It can be tested in the dashboard:
1. Go to variant details page.
2. Change quantity in save changes.
3. "This variant already exists" error is shown:

![image](https://user-images.githubusercontent.com/5421321/68127984-3761d580-ff17-11e9-84b6-5520df526c69.png)




Discussion:

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
@@ -50,6 +50,7 @@
     ProductVariant,
 )
 from ..utils import (
+    get_used_attibute_values_for_variant,
     get_used_variants_attribute_values,
     validate_attribute_input_for_product,
     validate_attribute_input_for_variant,
@@ -1043,7 +1044,9 @@ def clean_attributes(
         return attributes
 
     @classmethod
-    def validate_duplicated_attribute_values(cls, attributes, used_attribute_values):
+    def validate_duplicated_attribute_values(
+        cls, attributes, used_attribute_values, instance=None
+    ):
         attribute_values = defaultdict(list)
         for attribute in attributes:
             attribute_values[attribute.id].extend(attribute.values)
@@ -1088,7 +1091,7 @@ def clean_input(cls, info, instance: models.ProductVariant, data: dict):
 
             try:
                 cls.validate_duplicated_attribute_values(
-                    attributes, used_attribute_values
+                    attributes, used_attribute_values, instance
                 )
                 cleaned_input["attributes"] = cls.clean_attributes(
                     attributes, product_type
@@ -1149,6 +1152,22 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def validate_duplicated_attribute_values(
+        cls, attributes, used_attribute_values, instance=None
+    ):
+        # Check if the variant is getting updated,
+        # and the assigned attributes do not change
+        if instance.product_id is not None:
+            assigned_attributes = get_used_attibute_values_for_variant(instance)
+            input_attribute_values = defaultdict(list)
+            for attribute in attributes:
+                input_attribute_values[attribute.id].extend(attribute.values)
+            if input_attribute_values == assigned_attributes:
+                return
+        # if assigned attributes is getting updated run duplicated attribute validation
+        super().validate_duplicated_attribute_values(attributes, used_attribute_values)
+
 
 class ProductVariantDelete(ModelDeleteMutation):
     class Arguments:
diff --git a/saleor/graphql/product/utils.py b/saleor/graphql/product/utils.py
--- a/saleor/graphql/product/utils.py
+++ b/saleor/graphql/product/utils.py
@@ -49,6 +49,24 @@ def validate_attribute_input_for_variant(instance: models.Attribute, values: Lis
         )
 
 
+def get_used_attibute_values_for_variant(variant):
+    """Create a dict of attributes values for variant.
+
+    Sample result is:
+    {
+        "attribute_1_global_id": ["ValueAttr1_1"],
+        "attribute_2_global_id": ["ValueAttr2_1"]
+    }
+    """
+    attribute_values = defaultdict(list)
+    for assigned_variant_attribute in variant.attributes.all():
+        attribute = assigned_variant_attribute.attribute
+        attribute_id = graphene.Node.to_global_id("Attribute", attribute.id)
+        for variant in assigned_variant_attribute.values.all():
+            attribute_values[attribute_id].append(variant.slug)
+    return attribute_values
+
+
 def get_used_variants_attribute_values(product):
     """Create list of attributes values for all existing `ProductVariants` for product.
 
@@ -72,11 +90,6 @@ def get_used_variants_attribute_values(product):
     )
     used_attribute_values = []
     for variant in variants:
-        attribute_values = defaultdict(list)
-        for assigned_variant_attribute in variant.attributes.all():
-            attribute = assigned_variant_attribute.attribute
-            attribute_id = graphene.Node.to_global_id("Attribute", attribute.id)
-            for variant in assigned_variant_attribute.values.all():
-                attribute_values[attribute_id].append(variant.slug)
+        attribute_values = get_used_attibute_values_for_variant(variant)
         used_attribute_values.append(attribute_values)
     return used_attribute_values


Test patch:
diff --git a/tests/api/test_variant.py b/tests/api/test_variant.py
--- a/tests/api/test_variant.py
+++ b/tests/api/test_variant.py
@@ -7,6 +7,7 @@
 
 from saleor.product.error_codes import ProductErrorCode
 from saleor.product.models import ProductVariant
+from saleor.product.utils.attributes import associate_attribute_values_to_instance
 from tests.api.utils import get_graphql_content
 
 
@@ -466,7 +467,7 @@ def test_update_product_variant_unset_amounts(
 QUERY_UPDATE_VARIANT_ATTRIBUTES = """
     mutation updateVariant (
         $id: ID!,
-        $sku: String!,
+        $sku: String,
         $attributes: [AttributeValueInput]!) {
             productVariantUpdate(
                 id: $id,
@@ -478,6 +479,10 @@ def test_update_product_variant_unset_amounts(
                     field
                     message
                 }
+                productErrors {
+                    field
+                    code
+                }
             }
         }
 """
@@ -519,6 +524,142 @@ def test_update_product_variant_not_all_attributes(
     assert not product.variants.filter(sku=sku).exists()
 
 
+def test_update_product_variant_with_current_attribut(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    product = product_with_two_variants
+    variant = product.variants.first()
+    sku = str(uuid4())[:12]
+    assert not variant.sku == sku
+    assert variant.attributes.first().values.first().slug == "red"
+    assert variant.attributes.last().values.first().slug == "small"
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
+
+    variables = {
+        "id": variant_id,
+        "sku": sku,
+        "attributes": [
+            {"id": color_attribute_id, "values": ["red"]},
+            {"id": size_attribute_id, "values": ["small"]},
+        ],
+    }
+
+    response = staff_api_client.post_graphql(
+        QUERY_UPDATE_VARIANT_ATTRIBUTES,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+
+    data = content["data"]["productVariantUpdate"]
+    assert not data["errors"]
+    variant.refresh_from_db()
+    assert variant.sku == sku
+    assert variant.attributes.first().values.first().slug == "red"
+    assert variant.attributes.last().values.first().slug == "small"
+
+
+def test_update_product_variant_with_new_attribute(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    product = product_with_two_variants
+    variant = product.variants.first()
+    sku = str(uuid4())[:12]
+    assert not variant.sku == sku
+    assert variant.attributes.first().values.first().slug == "red"
+    assert variant.attributes.last().values.first().slug == "small"
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
+
+    variables = {
+        "id": variant_id,
+        "sku": sku,
+        "attributes": [
+            {"id": color_attribute_id, "values": ["red"]},
+            {"id": size_attribute_id, "values": ["big"]},
+        ],
+    }
+
+    response = staff_api_client.post_graphql(
+        QUERY_UPDATE_VARIANT_ATTRIBUTES,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+
+    data = content["data"]["productVariantUpdate"]
+    assert not data["errors"]
+    variant.refresh_from_db()
+    assert variant.sku == sku
+    assert variant.attributes.first().values.first().slug == "red"
+    assert variant.attributes.last().values.first().slug == "big"
+
+
+def test_update_product_variant_with_duplicated_attribute(
+    staff_api_client,
+    product_with_two_variants,
+    color_attribute,
+    size_attribute,
+    permission_manage_products,
+):
+    product = product_with_two_variants
+    variant = product.variants.first()
+    variant2 = product.variants.first()
+
+    variant2.pk = None
+    variant2.sku = str(uuid4())[:12]
+    variant2.save()
+    associate_attribute_values_to_instance(
+        variant2, color_attribute, color_attribute.values.last()
+    )
+    associate_attribute_values_to_instance(
+        variant2, size_attribute, size_attribute.values.last()
+    )
+
+    assert variant.attributes.first().values.first().slug == "red"
+    assert variant.attributes.last().values.first().slug == "small"
+    assert variant2.attributes.first().values.first().slug == "blue"
+    assert variant2.attributes.last().values.first().slug == "big"
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    color_attribute_id = graphene.Node.to_global_id("Attribute", color_attribute.pk)
+    size_attribute_id = graphene.Node.to_global_id("Attribute", size_attribute.pk)
+
+    variables = {
+        "id": variant_id,
+        "attributes": [
+            {"id": color_attribute_id, "values": ["blue"]},
+            {"id": size_attribute_id, "values": ["big"]},
+        ],
+    }
+
+    response = staff_api_client.post_graphql(
+        QUERY_UPDATE_VARIANT_ATTRIBUTES,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+
+    data = content["data"]["productVariantUpdate"]
+    assert data["productErrors"][0] == {
+        "field": "attributes",
+        "code": ProductErrorCode.UNIQUE.name,
+    }
+
+
 @pytest.mark.parametrize(
     "values, message",
     (