Repository: saleor/saleor

Issue:
Can't delete background_image of a collection in GrapQL
### What I'm trying to achieve
I want to have a functionality which allows staff to delete background_image of a collection.


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
@@ -3,6 +3,7 @@
 
 import graphene
 from django.core.exceptions import ImproperlyConfigured, ValidationError
+from django.db.models.fields.files import FileField
 from graphene.types.mutation import MutationOptions
 from graphene_django.registry import get_global_registry
 from graphql.error import GraphQLError
@@ -137,8 +138,15 @@ def construct_instance(cls, instance, cleaned_data):
                     f.name not in cleaned_data]):
                 continue
             data = cleaned_data[f.name]
-            if not f.null and data is None:
-                data = f._get_default()
+            if data is None:
+                # We want to reset the file field value when None was passed
+                # in the input, but `FileField.save_form_data` ignores None
+                # values. In that case we manually pass False which clears
+                # the file.
+                if isinstance(f, FileField):
+                    data = False
+                if not f.null:
+                    data = f._get_default()
             f.save_form_data(instance, data)
         return instance
 
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
@@ -43,7 +43,7 @@ class Meta:
     @classmethod
     def clean_input(cls, info, instance, input, errors):
         cleaned_input = super().clean_input(info, instance, input, errors)
-        if 'slug' not in cleaned_input:
+        if 'slug' not in cleaned_input and 'name' in cleaned_input:
             cleaned_input['slug'] = slugify(cleaned_input['name'])
         parent_id = input['parent_id']
         if parent_id:
@@ -136,7 +136,7 @@ def user_is_allowed(cls, user, input):
     @classmethod
     def clean_input(cls, info, instance, input, errors):
         cleaned_input = super().clean_input(info, instance, input, errors)
-        if 'slug' not in cleaned_input:
+        if 'slug' not in cleaned_input and 'name' in cleaned_input:
             cleaned_input['slug'] = slugify(cleaned_input['name'])
         clean_seo_fields(cleaned_input)
         return cleaned_input


Test patch:
diff --git a/tests/api/test_category.py b/tests/api/test_category.py
--- a/tests/api/test_category.py
+++ b/tests/api/test_category.py
@@ -1,12 +1,13 @@
-import pytest
+from unittest.mock import Mock
 
 import graphene
+import pytest
 from django.template.defaultfilters import slugify
-from unittest.mock import Mock
+from graphql_relay import to_global_id
+from tests.api.utils import get_graphql_content, get_multipart_request_body
+from tests.utils import create_image
 
 from saleor.product.models import Category
-from tests.utils import create_image
-from tests.api.utils import get_graphql_content, get_multipart_request_body
 
 
 def test_category_query(user_api_client, product):
@@ -361,3 +362,37 @@ def test_category_image_query_without_associated_file(
     data = content['data']['category']
     assert data['name'] == category.name
     assert data['backgroundImage'] is None
+
+
+def test_update_category_mutation_remove_background_image(
+        staff_api_client, category_with_image, permission_manage_products):
+    query = """
+        mutation updateCategory($id: ID!, $backgroundImage: Upload) {
+            categoryUpdate(
+                id: $id, input: {
+                    backgroundImage: $backgroundImage
+                }
+            ) {
+                category {
+                    backgroundImage{
+                        url
+                    }
+                }
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    assert category_with_image.background_image
+    variables = {
+        'id': to_global_id('Category', category_with_image.id),
+        'backgroundImage': None}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['categoryUpdate']['category']
+    assert not data['backgroundImage']
+    category_with_image.refresh_from_db()
+    assert not category_with_image.background_image
diff --git a/tests/api/test_collection.py b/tests/api/test_collection.py
--- a/tests/api/test_collection.py
+++ b/tests/api/test_collection.py
@@ -1,11 +1,13 @@
+from unittest.mock import Mock
+
 import graphene
 import pytest
 from django.utils.text import slugify
 from graphql_relay import to_global_id
-from unittest.mock import Mock
+from tests.utils import create_image
 
 from saleor.product.models import Collection
-from tests.utils import create_image
+
 from .utils import get_graphql_content, get_multipart_request_body
 
 
@@ -173,7 +175,7 @@ def test_update_collection_with_background_image(
             collectionUpdate(
                 id: $id, input: {
                     name: $name, 
-                    slug: $slug, 
+                    slug: $slug,
                     backgroundImage: $backgroundImage, 
                     isPublished: $isPublished
                 }
@@ -371,3 +373,36 @@ def test_collection_image_query_without_associated_file(
     assert data['name'] == collection.name
     assert data['backgroundImage'] is None
 
+
+def test_update_collection_mutation_remove_background_image(
+        staff_api_client, collection_with_image, permission_manage_products):
+    query = """
+        mutation updateCollection($id: ID!, $backgroundImage: Upload) {
+            collectionUpdate(
+                id: $id, input: {
+                    backgroundImage: $backgroundImage
+                }
+            ) {
+                collection {
+                    backgroundImage{
+                        url
+                    }
+                }
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    assert collection_with_image.background_image
+    variables = {
+        'id': to_global_id('Collection', collection_with_image.id),
+        'backgroundImage': None}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['collectionUpdate']['collection']
+    assert not data['backgroundImage']
+    collection_with_image.refresh_from_db()
+    assert not collection_with_image.background_image
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -236,11 +236,25 @@ def size_attribute(db):  # pylint: disable=W0613
     return attribute
 
 
+@pytest.fixture
+def image():
+    img_data = BytesIO()
+    image = Image.new('RGB', size=(1, 1))
+    image.save(img_data, format='JPEG')
+    return SimpleUploadedFile('product.jpg', img_data.getvalue())
+
+
 @pytest.fixture
 def category(db):  # pylint: disable=W0613
     return Category.objects.create(name='Default', slug='default')
 
 
+@pytest.fixture
+def category_with_image(db, image):  # pylint: disable=W0613
+    return Category.objects.create(
+        name='Default', slug='default', background_image=image)
+
+
 @pytest.fixture
 def categories_tree(db):
     parent = Category.objects.create(name='Parent', slug='parent')
@@ -347,16 +361,8 @@ def order_list(customer_user):
 
 
 @pytest.fixture
-def product_image():
-    img_data = BytesIO()
-    image = Image.new('RGB', size=(1, 1))
-    image.save(img_data, format='JPEG')
-    return SimpleUploadedFile('product.jpg', img_data.getvalue())
-
-
-@pytest.fixture
-def product_with_image(product, product_image):
-    ProductImage.objects.create(product=product, image=product_image)
+def product_with_image(product, image):
+    ProductImage.objects.create(product=product, image=image)
     return product
 
 
@@ -598,6 +604,14 @@ def collection(db):
     return collection
 
 
+@pytest.fixture
+def collection_with_image(db, image):
+    collection = Collection.objects.create(
+        name='Collection', slug='collection', is_published=True,
+        description='Test description', background_image=image)
+    return collection
+
+
 @pytest.fixture
 def draft_collection(db):
     collection = Collection.objects.create(
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -112,11 +112,11 @@ def test_create_address(db):
     assert Address.objects.all().count() == 1
 
 
-def test_create_fake_order(db, monkeypatch, product_image):
+def test_create_fake_order(db, monkeypatch, image):
     # Tests shouldn't depend on images present in placeholder folder
     monkeypatch.setattr(
         'saleor.core.utils.random_data.get_image',
-        Mock(return_value=product_image))
+        Mock(return_value=image))
     for _ in random_data.create_shipping_zones():
         pass
     for _ in random_data.create_users(3):