Repository: saleor/saleor

Issue:
Allow creating attributes with values in one mutation
We need to change the signature of `productAttributeCreate` mutation to allow creating attribute values in the same mutation. This is required by the new UI for the product attributes dashboard.


Discussion:
@maarcingebala should we change update mutation as well?

I suppose this mutation shall return `Attribute` and a list of created `AttributeValues`?
I think we should do that only in the "create" mutation. "Update" mutation is more problematic because you would have to always pass the whole list of attributes; also, if you accidentally passed an empty list you'd remove all values. Take a look at `MenuCreate` and `MenuUpdate`, we did a similar change there recently.

Resolving patch:
diff --git a/saleor/graphql/product/mutations/attributes.py b/saleor/graphql/product/mutations/attributes.py
--- a/saleor/graphql/product/mutations/attributes.py
+++ b/saleor/graphql/product/mutations/attributes.py
@@ -2,13 +2,7 @@
 
 from ....product import models
 from ...core.mutations import ModelDeleteMutation, ModelMutation
-
-
-class AttributesInput(graphene.InputObjectType):
-    slug = graphene.String(
-        required=True, description='Internal name.')
-    name = graphene.String(
-        required=True, description='Name displayed in the interface.')
+from django.core.exceptions import ValidationError
 
 
 class AttributeValueCreateInput(graphene.InputObjectType):
@@ -24,16 +18,22 @@ class AttributeValueCreateInput(graphene.InputObjectType):
         required=True, description='Real value eg. HEX color.')
 
 
-class AttributeValueUpdateInput(graphene.InputObjectType):
+class AttributeInput(graphene.InputObjectType):
     slug = graphene.String(
         required=True, description='Internal name.')
     name = graphene.String(
         required=True, description='Name displayed in the interface.')
 
 
+class AttributeCreateInput(AttributeInput):
+    values = graphene.List(
+        AttributeValueCreateInput,
+        description='Attribute values to be created for this attribute.')
+
+
 class AttributeCreate(ModelMutation):
     class Arguments:
-        input = AttributesInput(
+        input = AttributeCreateInput(
             required=True,
             description='Fields required to create an attribute.')
 
@@ -45,12 +45,42 @@ class Meta:
     def user_is_allowed(cls, user, input):
         return user.has_perm('product.manage_products')
 
+    @classmethod
+    def clean_input(cls, info, instance, input, errors):
+        cleaned_input = super().clean_input(info, instance, input, errors)
+
+        values = cleaned_input.get('values', [])
+        names = [value['name'] for value in values]
+        if len(set(names)) != len(names):
+            cls.add_error(
+                errors, 'values', 'Duplicated attribute value names provided.')
+        for value_data in values:
+            attribute_value = models.AttributeValue(**value_data)
+            try:
+                attribute_value.full_clean()
+            except ValidationError as validation_errors:
+                for field in validation_errors.message_dict:
+                    # Attribute instance is not created yet so we cannot add it
+                    if field == 'attribute':
+                        continue
+                    for message in validation_errors.message_dict[field]:
+                        error_field = 'values:%(field)s' % {'field': field}
+                        cls.add_error(errors, error_field, message)
+        return cleaned_input
+
+    @classmethod
+    def _save_m2m(cls, info, instance, cleaned_data):
+        super()._save_m2m(info, instance, cleaned_data)
+        values = cleaned_data.get('values', [])
+        for value in values:
+            instance.values.create(**value)
+
 
 class AttributeUpdate(AttributeCreate):
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of an attribute to update.')
-        input = AttributesInput(
+        input = AttributeInput(
             required=True,
             description='Fields required to update an attribute.')
 
@@ -93,7 +123,7 @@ class Arguments:
         id = graphene.ID(
             required=True,
             description='ID of an attribute choice value to update.')
-        input = AttributeValueUpdateInput(
+        input = AttributeInput(
             required=True,
             description='Fields required to update an attribute choice value.')
 


Test patch:
diff --git a/tests/api/test_attributes.py b/tests/api/test_attributes.py
--- a/tests/api/test_attributes.py
+++ b/tests/api/test_attributes.py
@@ -90,10 +90,16 @@ def test_attributes_in_category_query(user_api_client, product):
     assert len(attributes_data) == Attribute.objects.count()
 
 
-def test_create_attribute(admin_api_client):
-    query = """
-    mutation createAttribute($name: String!, $slug: String!) {
-        attributeCreate(input: {name: $name, slug: $slug}) {
+CREATE_ATTRIBUTES_QUERY = """
+    mutation createAttribute(
+            $name: String!, $slug: String!,
+            $values: [AttributeValueCreateInput]) {
+        attributeCreate(
+                input: {name: $name, slug: $slug, values: $values}) {
+            errors {
+                field
+                message
+            }
             attribute {
                 name
                 slug
@@ -104,20 +110,63 @@ def test_create_attribute(admin_api_client):
             }
         }
     }
-    """
+"""
+
+
+def test_create_attribute(admin_api_client):
+    query = CREATE_ATTRIBUTES_QUERY
     name = 'test name'
     slug = 'test-slug'
-    variables = json.dumps({'name': name, 'slug': slug})
+    variables = json.dumps({'name': name, 'slug': slug, 'values': []})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)
     assert 'errors' not in content
+    assert not content['data']['attributeCreate']['errors']
     data = content['data']['attributeCreate']['attribute']
     assert data['name'] == name
     assert data['slug'] == slug
     assert not data['values']
 
 
+def test_create_attribute_and_attribute_values(admin_api_client):
+    query = CREATE_ATTRIBUTES_QUERY
+    name = 'Value name'
+    slug = 'value-slug'
+    variables = json.dumps({
+        'name': 'Example name', 'slug': 'example-slug',
+        'values': [{'slug': slug, 'name': name, 'value': '#1231'}]})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    assert 'errors' not in content
+    assert not content['data']['attributeCreate']['errors']
+    data = content['data']['attributeCreate']['attribute']['values']
+    assert len(data) == 1
+    assert data[0]['name'] == name
+    assert data[0]['slug'] == slug
+
+
+def test_create_attribute_and_attribute_values_errors(admin_api_client):
+    query = CREATE_ATTRIBUTES_QUERY
+    variables = json.dumps({
+        'name': 'Example name', 'slug': 'example-slug',
+        'values': [
+            {'slug': 'slug', 'name': 'Red Color', 'value': '#1231'},
+            {'slug': 'Incorrect slug', 'name': 'Red Color', 'value': '#121'}]})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    errors = content['data']['attributeCreate']['errors']
+    assert errors
+    assert errors[0]['field'] == 'values'
+    assert errors[0]['message'] == 'Duplicated attribute value names provided.'
+    assert errors[1]['field'] == 'values:slug'
+    assert errors[1]['message'] == (
+        'Enter a valid \'slug\' consisting of letters, '
+        'numbers, underscores or hyphens.')
+
+
 def test_update_attribute(admin_api_client, color_attribute):
     attribute = color_attribute
     query = """