Repository: saleor/saleor

Issue:
Bug: adding attrs values for multiselect input in bulk mutations



Discussion:

Resolving patch:
diff --git a/saleor/graphql/attribute/utils.py b/saleor/graphql/attribute/utils.py
--- a/saleor/graphql/attribute/utils.py
+++ b/saleor/graphql/attribute/utils.py
@@ -7,7 +7,6 @@
 import graphene
 from django.core.exceptions import ValidationError
 from django.db.models import Q
-from django.db.utils import IntegrityError
 from django.template.defaultfilters import truncatechars
 from django.utils import timezone
 from django.utils.text import slugify
@@ -181,26 +180,24 @@ def _get_assigned_attribute_value_if_exists(
         )
 
     @classmethod
-    def _create_value_instance(
+    def _get_create_value_instance(
         cls, attribute, attr_value, external_ref, attr_and_value_slugs_map
     ):
-        try:
-            slug = prepare_unique_slug(
-                slugify(unidecode(attr_value)), attr_and_value_slugs_map[attribute]
-            )
-            value = attribute_models.AttributeValue.objects.create(
-                external_reference=external_ref,
-                attribute=attribute,
-                name=attr_value,
-                slug=slug,
-            )
-        except IntegrityError:
-            raise ValidationError(
-                "Attribute value with given externalReference already exists."
-            )
+        slug = prepare_unique_slug(
+            slugify(unidecode(attr_value)), attr_and_value_slugs_map[attribute]
+        )
+        value, created = attribute_models.AttributeValue.objects.get_or_create(
+            external_reference=external_ref,
+            defaults={
+                "attribute": attribute,
+                "name": attr_value,
+                "slug": slug,
+            },
+        )
+        if created:
+            attr_and_value_slugs_map[attribute].add(slug)
 
-        attr_and_value_slugs_map[attribute].add(slug)
-        return (value,)
+        return value
 
     @classmethod
     def clean_input(
@@ -449,27 +446,29 @@ def _pre_save_dropdown_value(
         external_ref = attr_values.dropdown.external_reference
 
         if external_ref and attr_value:
-            value = cls._create_value_instance(
+            value = cls._get_create_value_instance(
                 attribute, attr_value, external_ref, attr_and_value_slugs_map
             )
-            return value
+            return (value,)
 
         if external_ref:
-            value = attribute_models.AttributeValue.objects.get(
-                external_reference=external_ref
-            )
-            if not value:
+            try:
+                value = attribute_models.AttributeValue.objects.get(
+                    external_reference=external_ref
+                )
+                return (value,)
+            except attribute_models.AttributeValue.DoesNotExist:
                 raise ValidationError(
                     "Attribute value with given externalReference can't be found"
                 )
-            return (value,)
 
         if id := attr_values.dropdown.id:
-            _, attr_value_id = from_global_id_or_error(id)
-            value = attribute_models.AttributeValue.objects.get(pk=attr_value_id)
-            if not value:
+            try:
+                _, attr_value_id = from_global_id_or_error(id)
+                value = attribute_models.AttributeValue.objects.get(pk=attr_value_id)
+                return (value,)
+            except attribute_models.AttributeValue.DoesNotExist:
                 raise ValidationError("Attribute value with given ID can't be found")
-            return (value,)
 
         if attr_value:
             value = prepare_attribute_values(attribute, [attr_value])
@@ -492,7 +491,7 @@ def _pre_save_swatch_value(
         external_ref = attr_values.swatch.external_reference
 
         if external_ref and attr_value:
-            value = cls._create_value_instance(
+            value = cls._get_create_value_instance(
                 attribute, attr_value, external_ref, attr_and_value_slugs_map
             )
             return value
@@ -536,30 +535,35 @@ def _pre_save_multiselect_values(
             external_ref = attr_value.external_reference
 
             if external_ref and attr_value.value:
-                value = cls._create_value_instance(
+                value = cls._get_create_value_instance(
                     attribute, attr_value.value, external_ref, attr_and_value_slugs_map
                 )
-                return value
+                if value.id not in [a.id for a in attribute_values]:
+                    attribute_values.append(value)
 
             if external_ref:
-                value = attribute_models.AttributeValue.objects.get(
-                    external_reference=external_ref
-                )
-                if not value:
+                try:
+                    value = attribute_models.AttributeValue.objects.get(
+                        external_reference=external_ref
+                    )
+                    if value.id not in [a.id for a in attribute_values]:
+                        attribute_values.append(value)
+                except attribute_models.AttributeValue.DoesNotExist:
                     raise ValidationError(
                         "Attribute value with given externalReference can't be found"
                     )
-                return (value,)
 
             if attr_value.id:
-                _, attr_value_id = from_global_id_or_error(attr_value.id)
-                attr_value_model = attribute_models.AttributeValue.objects.get(
-                    pk=attr_value_id
-                )
-                if not attr_value_model:
+                try:
+                    _, attr_value_id = from_global_id_or_error(attr_value.id)
+                    attr_value_model = attribute_models.AttributeValue.objects.get(
+                        pk=attr_value_id
+                    )
+                except attribute_models.AttributeValue.DoesNotExist:
                     raise ValidationError(
                         "Attribute value with given ID can't be found"
                     )
+
                 if attr_value_model.id not in [a.id for a in attribute_values]:
                     attribute_values.append(attr_value_model)
 


Test patch:
diff --git a/saleor/graphql/product/tests/mutations/test_product_bulk_create.py b/saleor/graphql/product/tests/mutations/test_product_bulk_create.py
--- a/saleor/graphql/product/tests/mutations/test_product_bulk_create.py
+++ b/saleor/graphql/product/tests/mutations/test_product_bulk_create.py
@@ -8,6 +8,7 @@
 import pytest
 import pytz
 
+from .....attribute import AttributeInputType
 from .....product.error_codes import ProductBulkCreateErrorCode
 from .....product.models import Product
 from .....product.tests.utils import create_image
@@ -769,6 +770,198 @@ def test_product_bulk_create_with_attributes_and_create_new_value_with_external_
     assert first_attribute_assignment.values.count() == 1
 
 
+def test_product_bulk_create_with_same_attrs_and_new_value_with_external_ref(
+    staff_api_client,
+    product_type,
+    category,
+    description_json,
+    permission_manage_products,
+    channel_USD,
+):
+    # given
+    description_json = json.dumps(description_json)
+    product_type_id = graphene.Node.to_global_id("ProductType", product_type.pk)
+    category_id = graphene.Node.to_global_id("Category", category.pk)
+
+    product_name_1 = "test name 1"
+    product_name_2 = "test name 2"
+    base_product_slug = "product-test-slug"
+    product_charge_taxes = True
+    product_tax_rate = "STANDARD"
+
+    # Default attribute defined in product_type fixture
+    color_attr = product_type.product_attributes.get(name="Color")
+    color_attr_values_count = color_attr.values.count()
+    color_value_external_reference = color_attr.values.first().external_reference
+
+    new_value = "NewTestValue"
+    new_external_ref = color_value_external_reference + "New"
+
+    attrs = [
+        {
+            "externalReference": color_attr.external_reference,
+            "dropdown": {
+                "externalReference": new_external_ref,
+                "value": new_value,
+            },
+        }
+    ]
+
+    products = [
+        {
+            "productType": product_type_id,
+            "category": category_id,
+            "name": product_name_1,
+            "slug": f"{base_product_slug}-1",
+            "description": description_json,
+            "chargeTaxes": product_charge_taxes,
+            "taxCode": product_tax_rate,
+            "weight": 2,
+            "attributes": attrs,
+        },
+        {
+            "productType": product_type_id,
+            "category": category_id,
+            "name": product_name_2,
+            "slug": f"{base_product_slug}-2",
+            "description": description_json,
+            "chargeTaxes": product_charge_taxes,
+            "taxCode": product_tax_rate,
+            "weight": 2,
+            "attributes": attrs,
+        },
+    ]
+
+    # when
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_BULK_CREATE_MUTATION, {"products": products}
+    )
+    content = get_graphql_content(response, ignore_errors=True)
+    data = content["data"]["productBulkCreate"]
+
+    # then
+    product = Product.objects.last()
+
+    assert not data["results"][0]["errors"]
+    assert not data["results"][1]["errors"]
+    assert data["count"] == 2
+    assert (
+        data["results"][0]["product"]["attributes"][0]["attribute"]["slug"]
+        == color_attr.slug
+    )
+    assert (
+        data["results"][1]["product"]["attributes"][0]["attribute"]["slug"]
+        == color_attr.slug
+    )
+    assert color_attr.values.count() == color_attr_values_count + 1
+    first_attribute_assignment = product.attributes.first()
+    assert product.attributes.count() == 1
+    assert first_attribute_assignment.attribute == color_attr
+    assert first_attribute_assignment.values.count() == 1
+
+
+def test_product_bulk_create_with_same_multiselect_attrs_and_value_with_external_ref(
+    staff_api_client,
+    product_type,
+    category,
+    description_json,
+    permission_manage_products,
+    color_attribute,
+    channel_USD,
+):
+    # given
+    description_json = json.dumps(description_json)
+    product_type_id = graphene.Node.to_global_id("ProductType", product_type.pk)
+    category_id = graphene.Node.to_global_id("Category", category.pk)
+
+    product_name_1 = "test name 1"
+    product_name_2 = "test name 2"
+    base_product_slug = "product-test-slug"
+    product_charge_taxes = True
+    product_tax_rate = "STANDARD"
+
+    color_attribute.input_type = AttributeInputType.MULTISELECT
+    color_attribute.save(update_fields=["input_type"])
+
+    color_attr_values_count = color_attribute.values.count()
+    color_value_external_reference = color_attribute.values.first().external_reference
+
+    new_value_1 = "Green"
+    new_value_2 = "Black"
+    new_external_ref_1 = color_value_external_reference + "New1"
+    new_external_ref_2 = color_value_external_reference + "New2"
+
+    attrs = [
+        {
+            "externalReference": color_attribute.external_reference,
+            "multiselect": [
+                {
+                    "externalReference": new_external_ref_1,
+                    "value": new_value_1,
+                },
+                {
+                    "externalReference": new_external_ref_2,
+                    "value": new_value_2,
+                },
+            ],
+        }
+    ]
+
+    products = [
+        {
+            "productType": product_type_id,
+            "category": category_id,
+            "name": product_name_1,
+            "slug": f"{base_product_slug}-1",
+            "description": description_json,
+            "chargeTaxes": product_charge_taxes,
+            "taxCode": product_tax_rate,
+            "weight": 2,
+            "attributes": attrs,
+        },
+        {
+            "productType": product_type_id,
+            "category": category_id,
+            "name": product_name_2,
+            "slug": f"{base_product_slug}-2",
+            "description": description_json,
+            "chargeTaxes": product_charge_taxes,
+            "taxCode": product_tax_rate,
+            "weight": 2,
+            "attributes": attrs,
+        },
+    ]
+
+    # when
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        PRODUCT_BULK_CREATE_MUTATION, {"products": products}
+    )
+    content = get_graphql_content(response, ignore_errors=True)
+    data = content["data"]["productBulkCreate"]
+
+    # then
+    product = Product.objects.last()
+
+    assert not data["results"][0]["errors"]
+    assert not data["results"][1]["errors"]
+    assert data["count"] == 2
+    assert (
+        data["results"][0]["product"]["attributes"][0]["attribute"]["slug"]
+        == color_attribute.slug
+    )
+    assert (
+        data["results"][1]["product"]["attributes"][0]["attribute"]["slug"]
+        == color_attribute.slug
+    )
+    assert color_attribute.values.count() == color_attr_values_count + 2
+    first_attribute_assignment = product.attributes.first()
+    assert product.attributes.count() == 1
+    assert first_attribute_assignment.attribute == color_attribute
+    assert first_attribute_assignment.values.count() == 2
+
+
 def test_product_bulk_create_return_error_when_attribute_id_and_external_ref_provided(
     staff_api_client,
     product_type,