Repository: saleor/saleor

Issue:
Unify mutation handling
We need to rework how mutations are handled and what they return to avoid complex logic on the client side.


Discussion:

Resolving patch:
diff --git a/saleor/graphql/core/mutations.py b/saleor/graphql/core/mutations.py
--- a/saleor/graphql/core/mutations.py
+++ b/saleor/graphql/core/mutations.py
@@ -144,7 +144,7 @@ def clean_input(cls, info, instance, input, errors):
                 # handle list of IDs field
                 if value is not None and (
                     isinstance(field.type, graphene.List)) and (
-                    field.type.of_type == graphene.ID):
+                        field.type.of_type == graphene.ID):
                     instances = cls.get_nodes_or_error(
                         value, errors=errors, field=field.name) if value else []
                     cleaned_input[field_name] = instances
@@ -317,9 +317,8 @@ def resolve(cls, root, info):
 
 
 class VerifyToken(Verify):
-    """Mutation that confirm if token is valid and also return user data.
+    """Mutation that confirm if token is valid and also return user data."""
 
-    """
     user = graphene.Field(User)
 
     def resolve_user(self, info, **kwargs):
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
@@ -9,26 +9,10 @@
 from ...core.types.common import Decimal, Error, SeoInput
 from ...core.utils import clean_seo_fields
 from ...file_upload.types import Upload
-from ...utils import get_attributes_dict_from_list, get_node, get_nodes
+from ...utils import get_attributes_dict_from_list
 from ..types import Collection, Product, ProductImage, ProductVariant
 
 
-def update_variants_names(instance, saved_attributes):
-    initial_attributes = set(instance.variant_attributes.all())
-    attributes_changed = initial_attributes.intersection(saved_attributes)
-    if not attributes_changed:
-        return
-    variants_to_be_updated = models.ProductVariant.objects.filter(
-        product__in=instance.products.all(),
-        product__product_type__variant_attributes__in=attributes_changed)
-    variants_to_be_updated = variants_to_be_updated.prefetch_related(
-        'product__product_type__variant_attributes__values').all()
-    attributes = instance.variant_attributes.all()
-    for variant in variants_to_be_updated:
-        variant.name = get_name_from_attributes(variant, attributes)
-        variant.save()
-
-
 def update_variants_names(instance, saved_attributes):
     initial_attributes = set(instance.variant_attributes.all())
     attributes_changed = initial_attributes.intersection(saved_attributes)
@@ -166,6 +150,10 @@ def user_is_allowed(cls, user, input):
 
 
 class CollectionAddProducts(BaseMutation):
+    collection = graphene.Field(
+        Collection,
+        description='Collection to which products will be added.')
+
     class Arguments:
         collection_id = graphene.Argument(
             graphene.ID, required=True,
@@ -174,41 +162,45 @@ class Arguments:
             graphene.ID, required=True,
             description='List of product IDs.')
 
-    collection = graphene.Field(
-        Collection,
-        description='Collection to which products will be added.')
-
     class Meta:
         description = 'Adds products to a collection.'
 
+    @classmethod
     @permission_required('product.manage_products')
-    def mutate(self, info, collection_id, products):
-        collection = get_node(info, collection_id, only_type=Collection)
-        products = get_nodes(products, Product)
+    def mutate(cls, root, info, collection_id, products):
+        errors = []
+        collection = cls.get_node_or_error(
+            info, collection_id, errors, 'collectionId', only_type=Collection)
+        products = cls.get_nodes_or_error(
+            products, errors, 'products', only_type=Product)
         collection.products.add(*products)
-        return CollectionAddProducts(collection=collection)
+        return CollectionAddProducts(collection=collection, errors=errors)
 
 
 class CollectionRemoveProducts(BaseMutation):
+    collection = graphene.Field(
+        Collection,
+        description='Collection from which products will be removed.')
+
     class Arguments:
         collection_id = graphene.Argument(
             graphene.ID, required=True, description='ID of a collection.')
         products = graphene.List(
             graphene.ID, required=True, description='List of product IDs.')
 
-    collection = graphene.Field(
-        Collection,
-        description='Collection from which products will be removed.')
-
     class Meta:
         description = 'Remove products from a collection.'
 
+    @classmethod
     @permission_required('product.manage_products')
-    def mutate(self, info, collection_id, products):
-        collection = get_node(info, collection_id, only_type=Collection)
-        products = get_nodes(products, Product)
+    def mutate(cls, root, info, collection_id, products):
+        errors = []
+        collection = cls.get_node_or_error(
+            info, collection_id, errors, 'collectionId', only_type=Collection)
+        products = cls.get_nodes_or_error(
+            products, errors, 'products', only_type=Product)
         collection.products.remove(*products)
-        return CollectionRemoveProducts(collection=collection)
+        return CollectionRemoveProducts(collection=collection, errors=errors)
 
 
 class AttributeValueInput(InputObjectType):
@@ -373,6 +365,7 @@ def save(cls, info, instance, cleaned_input):
     def user_is_allowed(cls, user, input):
         return user.has_perm('product.manage_products')
 
+
 class ProductVariantUpdate(ProductVariantCreate):
     class Arguments:
         id = graphene.ID(
@@ -476,10 +469,14 @@ class ProductImageCreateInput(graphene.InputObjectType):
     image = Upload(
         required=True,
         description='Represents an image file in a multipart request.')
-    product = graphene.ID(description='ID of an product.', name='product')
+    product = graphene.ID(
+        required=True, description='ID of an product.', name='product')
 
 
-class ProductImageCreate(ModelMutation):
+class ProductImageCreate(BaseMutation):
+    product = graphene.Field(Product)
+    image = graphene.Field(ProductImage)
+
     class Arguments:
         input = ProductImageCreateInput(
             required=True,
@@ -488,27 +485,33 @@ class Arguments:
     class Meta:
         description = '''Create a product image. This mutation must be sent
         as a `multipart` request. More detailed specs of the upload format can
-        be found here: https://github.com/jaydenseric/graphql-multipart-request-spec'''
-        model = models.ProductImage
+        be found here:
+        https://github.com/jaydenseric/graphql-multipart-request-spec'''
 
     @classmethod
-    def clean_input(cls, info, instance, input, errors):
-        cleaned_input = super().clean_input(info, instance, input, errors)
-        uploaded_image = cleaned_input['image']
-        if not uploaded_image.content_type.startswith('image/'):
+    @permission_required('product.manage_products')
+    def mutate(cls, root, info, input):
+        errors = []
+        product = cls.get_node_or_error(
+            info, input['product'], errors, 'product', only_type=Product)
+        image_data = info.context.FILES.get(input['image'])
+        if not image_data.content_type.startswith('image/'):
             cls.add_error(errors, 'image', 'Invalid file type')
-        return cleaned_input
-
-    @classmethod
-    def user_is_allowed(cls, user, input):
-        return user.has_perm('product.manage_products')
+        image = None
+        if not errors:
+            image = product.images.create(
+                image=image_data, alt=input.get('alt', ''))
+        return ProductImageCreate(product=product, image=image, errors=errors)
 
 
 class ProductImageUpdateInput(graphene.InputObjectType):
     alt = graphene.String(description='Alt text for an image.')
 
 
-class ProductImageUpdate(ModelMutation):
+class ProductImageUpdate(BaseMutation):
+    product = graphene.Field(Product)
+    image = graphene.Field(ProductImage)
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of a product image to update.')
@@ -518,14 +521,24 @@ class Arguments:
 
     class Meta:
         description = 'Updates a product image.'
-        model = models.ProductImage
 
     @classmethod
-    def user_is_allowed(cls, user, input):
-        return user.has_perm('product.manage_products')
+    @permission_required('product.manage_products')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        image = cls.get_node_or_error(
+            info, id, errors, 'id', only_type=ProductImage)
+        product = image.product
+        if not errors:
+            image.alt = input.get('alt', '')
+            image.save()
+        return ProductImageUpdate(product=product, image=image, errors=errors)
 
 
 class ProductImageReorder(BaseMutation):
+    product = graphene.Field(Product)
+    images = graphene.List(ProductImage)
+
     class Arguments:
         product_id = graphene.ID(
             required=True,
@@ -537,44 +550,62 @@ class Arguments:
     class Meta:
         description = 'Changes ordering of the product image.'
 
-    product_images = graphene.List(
-        ProductImage,
-        description='Product image which sort order will be altered.')
-
     @classmethod
     @permission_required('product.manage_products')
     def mutate(cls, root, info, product_id, images_ids):
-        product = get_node(info, product_id, Product)
+        errors = []
+        product = cls.get_node_or_error(
+            info, product_id, errors, 'productId', Product)
         if len(images_ids) != product.images.count():
-            return cls(
-                errors=[
-                    Error(field='order',
-                          message='Incorrect number of image IDs provided.')])
-        for order, image_id in enumerate(images_ids):
-            image = get_node(info, image_id, only_type=ProductImage)
-            image.sort_order = order
-            image.save()
-        product_images = get_nodes(images_ids, ProductImage)
-        return ProductImageReorder(product_images=product_images)
-
+            cls.add_error(
+                errors, 'order', 'Incorrect number of image IDs provided.')
+        images = [
+            cls.get_node_or_error(
+                info, image_id, errors, 'order', only_type=ProductImage)
+            for image_id in images_ids]
+        if not errors:
+            for image in images:
+                if image.product != product:
+                    cls.add_error(
+                        errors, 'order',
+                        "Image with id %r does not belong to product %r" % (
+                            image_id, product_id))
+        if not errors:
+            for order, image in enumerate(images):
+                image.sort_order = order
+                image.save()
+        return ProductImageReorder(
+            product=product, images=images, errors=errors)
+
+
+class ProductImageDelete(BaseMutation):
+    product = graphene.Field(Product)
+    image = graphene.Field(ProductImage)
 
-class ProductImageDelete(ModelDeleteMutation):
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of a product image to delete.')
 
     class Meta:
         description = 'Deletes a product image.'
-        model = models.ProductImage
 
     @classmethod
-    def user_is_allowed(cls, user, input):
-        return user.has_perm('product.manage_products')
+    @permission_required('product.manage_products')
+    def mutate(cls, root, info, id):
+        errors = []
+        image = cls.get_node_or_error(
+            info, id, errors, 'id', only_type=ProductImage)
+        image_id = image.id
+        if not errors:
+            image.delete()
+        image.id = image_id
+        return ProductImageDelete(
+            product=image.product, image=image, errors=errors)
 
 
 class VariantImageAssign(BaseMutation):
-    image = graphene.Field(
-        ProductImage, description='Assigned product image.')
+    product_variant = graphene.Field(ProductVariant)
+    image = graphene.Field(ProductImage)
 
     class Arguments:
         image_id = graphene.ID(
@@ -604,10 +635,14 @@ def mutate(cls, root, info, image_id, variant_id):
             else:
                 cls.add_error(
                     errors, 'imageId', 'Image must be for this product')
-        return VariantImageAssign(image=image, errors=errors)
+        return VariantImageAssign(
+            product_variant=variant, image=image, errors=errors)
 
 
 class VariantImageUnassign(BaseMutation):
+    product_variant = graphene.Field(ProductVariant)
+    image = graphene.Field(ProductImage)
+
     class Arguments:
         image_id = graphene.ID(
             required=True,
@@ -615,9 +650,6 @@ class Arguments:
         variant_id = graphene.ID(
             required=True, description='ID of a product variant.')
 
-    image = graphene.Field(
-        ProductImage, description='Unassigned product image.')
-
     class Meta:
         description = 'Unassign an image from a product variant'
 
@@ -629,14 +661,16 @@ def mutate(cls, root, info, image_id, variant_id):
             info, image_id, errors, 'imageId', ProductImage)
         variant = cls.get_node_or_error(
             info, variant_id, errors, 'variantId', ProductVariant)
-        if image and variant:
-            try:
-                variant_image = models.VariantImage.objects.get(
-                    image=image, variant=variant)
-            except models.VariantImage.DoesNotExist:
-                cls.add_error(
-                    errors, 'imageId',
-                    'Image is not assigned to this variant.')
-            else:
-                variant_image.delete()
-        return VariantImageUnassign(image=image, errors=errors)
+        if not errors:
+            if image and variant:
+                try:
+                    variant_image = models.VariantImage.objects.get(
+                        image=image, variant=variant)
+                except models.VariantImage.DoesNotExist:
+                    cls.add_error(
+                        errors, 'imageId',
+                        'Image is not assigned to this variant.')
+                else:
+                    variant_image.delete()
+        return VariantImageUnassign(
+            product_variant=variant, image=image, errors=errors)


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
@@ -818,10 +818,8 @@ def test_product_image_create_mutation(admin_api_client, product):
     query = """
     mutation createProductImage($image: Upload!, $product: ID!) {
         productImageCreate(input: {image: $image, product: $product}) {
-            productImage {
+            image {
                 id
-                url
-                sortOrder
             }
         }
     }
@@ -843,7 +841,7 @@ def test_invalid_product_image_create_mutation(admin_api_client, product):
     query = """
     mutation createProductImage($image: Upload!, $product: ID!) {
         productImageCreate(input: {image: $image, product: $product}) {
-            productImage {
+            image {
                 id
                 url
                 sortOrder
@@ -862,6 +860,7 @@ def test_invalid_product_image_create_mutation(admin_api_client, product):
     body = get_multipart_request_body(query, variables, image_file, image_name)
     response = admin_api_client.post_multipart(reverse('api'), body)
     content = get_graphql_content(response)
+    assert 'errors' not in content
     assert content['data']['productImageCreate']['errors'] == [{
         'field': 'image',
         'message': 'Invalid file type'}]
@@ -873,7 +872,7 @@ def test_product_image_update_mutation(admin_api_client, product_with_image):
     query = """
     mutation updateProductImage($imageId: ID!, $alt: String) {
         productImageUpdate(id: $imageId, input: {alt: $alt}) {
-            productImage {
+            image {
                 alt
             }
         }
@@ -888,7 +887,7 @@ def test_product_image_update_mutation(admin_api_client, product_with_image):
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)
     assert 'errors' not in content
-    data = content['data']['productImageUpdate']['productImage']['alt'] == alt
+    data = content['data']['productImageUpdate']['image']['alt'] == alt
 
 
 def test_product_image_delete(admin_api_client, product_with_image):
@@ -896,9 +895,9 @@ def test_product_image_delete(admin_api_client, product_with_image):
     query = """
             mutation deleteProductImage($id: ID!) {
                 productImageDelete(id: $id) {
-                    productImage {
-                        url
+                    image {
                         id
+                        url
                     }
                 }
             }
@@ -911,17 +910,17 @@ def test_product_image_delete(admin_api_client, product_with_image):
     content = get_graphql_content(response)
     assert 'errors' not in content
     data = content['data']['productImageDelete']
-    assert data['productImage']['url'] == image_obj.image.url
+    assert data['image']['url'] == image_obj.image.url
     with pytest.raises(image_obj._meta.model.DoesNotExist):
         image_obj.refresh_from_db()
-    assert node_id == data['productImage']['id']
+    assert node_id == data['image']['id']
 
 
 def test_reorder_images(admin_api_client, product_with_images):
     query = """
     mutation reorderImages($product_id: ID!, $images_ids: [ID]!) {
         productImageReorder(productId: $product_id, imagesIds: $images_ids) {
-            productImages {
+            product {
                 id
             }
         }
@@ -1090,7 +1089,7 @@ def test_add_products_to_collection(
         'products']['totalCount'] == no_products_before + len(product_ids)
 
 
-def test_remove_products_to_collection(
+def test_remove_products_from_collection(
         admin_api_client, collection, product_list):
     query = """
         mutation collectionRemoveProducts(
@@ -1120,7 +1119,8 @@ def test_remove_products_to_collection(
         'products']['totalCount'] == no_products_before - len(product_ids)
 
 
-def test_assign_variant_image(admin_api_client, user_api_client, product_with_image):
+def test_assign_variant_image(
+        admin_api_client, user_api_client, product_with_image):
     query = """
     mutation assignVariantImageMutation($variantId: ID!, $imageId: ID!) {
         variantImageAssign(variantId: $variantId, imageId: $imageId) {
@@ -1128,7 +1128,7 @@ def test_assign_variant_image(admin_api_client, user_api_client, product_with_im
                 field
                 message
             }
-            image {
+            productVariant {
                 id
             }
         }
@@ -1166,7 +1166,8 @@ def test_assign_variant_image(admin_api_client, user_api_client, product_with_im
     assert_no_permission(response)
 
 
-def test_unassign_variant_image(admin_api_client, user_api_client, product_with_image):
+def test_unassign_variant_image(
+        admin_api_client, user_api_client, product_with_image):
     image = product_with_image.images.first()
     variant = product_with_image.variants.first()
     variant.variant_images.create(image=image)
@@ -1178,7 +1179,7 @@ def test_unassign_variant_image(admin_api_client, user_api_client, product_with_
                 field
                 message
             }
-            image {
+            productVariant {
                 id
             }
         }