Repository: saleor/saleor

Issue:
Add sale list manipulation mutations
### What I'm trying to achieve
To add and delete categories, collections, and products from sale. 

### Describe a proposed solution
Add `saleAssignCategories`, `saleAssignCollections`, `saleAssignProducts`, `saleUnassignCategories`, `saleUnassignCollections` and `saleUnassignProducts`, as it is done in collections.

### Other solutions I've tried and won't work
`saleUpdate` has the ability to set items in these lists, but it does not provide a safe interface to manipulate lists, because it overrides them.


Discussion:
@maarcingebala @dominik-zeglen would it better to have one mutation to add and one to remove, that would allow to set products or collections or categories or any mix of those, rather than three separate mutations to add and three to remove?


We also need very similar mutations for vouchers. In total that would be 12 very similar mutations. Instead, we can try with something like:
```
saleAddProducts(saleId: ID!, products: [ID]!, collections: [ID]!, categories: [ID]!)
saleRemoveProducts(saleId: ID!, products: [ID]!, collections: [ID]!, categories: [ID]!)
voucherAddProducts(voucherId: ID!, products: [ID]!, collections: [ID]!, categories: [ID]!)
voucherRemoveProducts(voucherId: ID!, products: [ID]!, collections: [ID]!, categories: [ID]!)
```

Resolving patch:
diff --git a/saleor/graphql/discount/mutations.py b/saleor/graphql/discount/mutations.py
--- a/saleor/graphql/discount/mutations.py
+++ b/saleor/graphql/discount/mutations.py
@@ -1,9 +1,12 @@
 import graphene
+from graphql_jwt.decorators import permission_required
 
 from ...discount import VoucherType, models
-from ..core.mutations import ModelDeleteMutation, ModelMutation
+from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
 from ..core.scalars import Decimal
+from ..product.types import Category, Collection, Product
 from .enums import DiscountValueTypeEnum, VoucherTypeEnum
+from .types import Sale, Voucher
 
 
 def validate_voucher(voucher_data):
@@ -21,6 +24,59 @@ def validate_voucher(voucher_data):
     return errors
 
 
+class CatalogueInput(graphene.InputObjectType):
+    products = graphene.List(
+        graphene.ID, description='Products related to the discount.',
+        name='products')
+    categories = graphene.List(
+        graphene.ID, description='Categories related to the discount.',
+        name='categories')
+    collections = graphene.List(
+        graphene.ID, description='Collections related to the discount.',
+        name='collections')
+
+
+class BaseDiscountCatalogueMutation(BaseMutation):
+    class Meta:
+        abstract = True
+
+    @classmethod
+    def add_catalogues_to_node(cls, node, input, errors):
+        products = input.get('products', [])
+        if products:
+            products = cls.get_nodes_or_error(
+                products, errors, 'products', only_type=Product)
+            node.products.add(*products)
+        categories = input.get('categories', [])
+        if categories:
+            categories = cls.get_nodes_or_error(
+                categories, errors, 'categories', only_type=Category)
+            node.categories.add(*categories)
+        collections = input.get('collections', [])
+        if collections:
+            collections = cls.get_nodes_or_error(
+                collections, errors, 'collections', only_type=Collection)
+            node.collections.add(*collections)
+
+    @classmethod
+    def remove_catalogues_from_node(cls, node, input, errors):
+        products = input.get('products', [])
+        if products:
+            products = cls.get_nodes_or_error(
+                products, errors, 'products', only_type=Product)
+            node.products.remove(*products)
+        categories = input.get('categories', [])
+        if categories:
+            categories = cls.get_nodes_or_error(
+                categories, errors, 'categories', only_type=Category)
+            node.categories.remove(*categories)
+        collections = input.get('collections', [])
+        if collections:
+            collections = cls.get_nodes_or_error(
+                collections, errors, 'collections', only_type=Collection)
+            node.collections.remove(*collections)
+
+
 class VoucherInput(graphene.InputObjectType):
     type = VoucherTypeEnum(
         description='Voucher type: product, category shipping or value.')
@@ -99,6 +155,54 @@ def user_is_allowed(cls, user, input):
         return user.has_perm('discount.manage_discounts')
 
 
+class VoucherBaseCatalogueMutation(BaseDiscountCatalogueMutation):
+    voucher = graphene.Field(
+        Voucher, description=(
+            'Voucher of which catalogue IDs will be modified.'))
+
+    class Arguments:
+        id = graphene.ID(required=True, description='ID of a voucher.')
+        input = CatalogueInput(
+            required=True, description=(
+                'Fields required to modify catalogue IDs of voucher.'))
+
+    class Meta:
+        abstract = True
+
+
+class VoucherAddCatalogues(VoucherBaseCatalogueMutation):
+
+    class Meta:
+        description = 'Adds products, categories, collections to a voucher.'
+
+    @classmethod
+    @permission_required('discount.manage_discounts')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        voucher = cls.get_node_or_error(
+            info, id, errors, 'voucherId', only_type=Voucher)
+
+        cls.add_catalogues_to_node(voucher, input, errors)
+        return VoucherAddCatalogues(voucher=voucher, errors=errors)
+
+
+class VoucherRemoveCatalogues(VoucherBaseCatalogueMutation):
+
+    class Meta:
+        description = (
+            'Removes products, categories, collections from a voucher.')
+
+    @classmethod
+    @permission_required('discount.manage_discounts')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        voucher = cls.get_node_or_error(
+            info, id, errors, 'voucherId', only_type=Voucher)
+
+        cls.remove_catalogues_from_node(voucher, input, errors)
+        return VoucherRemoveCatalogues(voucher=voucher, errors=errors)
+
+
 class SaleInput(graphene.InputObjectType):
     name = graphene.String(description='Voucher name.')
     type = DiscountValueTypeEnum(description='Fixed or percentage.')
@@ -162,3 +266,50 @@ class Meta:
     @classmethod
     def user_is_allowed(cls, user, input):
         return user.has_perm('discount.manage_discounts')
+
+
+class SaleBaseCatalogueMutation(BaseDiscountCatalogueMutation):
+    sale = graphene.Field(
+        Sale, description=(
+            'Sale of which catalogue IDs will be modified.'))
+
+    class Arguments:
+        id = graphene.ID(required=True, description='ID of a sale.')
+        input = CatalogueInput(
+            required=True, description=(
+                'Fields required to modify catalogue IDs of sale.'))
+
+    class Meta:
+        abstract = True
+
+
+class SaleAddCatalogues(SaleBaseCatalogueMutation):
+
+    class Meta:
+        description = 'Adds products, categories, collections to a voucher.'
+
+    @classmethod
+    @permission_required('discount.manage_discounts')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        sale = cls.get_node_or_error(
+            info, id, errors, 'saleId', only_type=Sale)
+
+        cls.add_catalogues_to_node(sale, input, errors)
+        return SaleAddCatalogues(sale=sale, errors=errors)
+
+
+class SaleRemoveCatalogues(SaleBaseCatalogueMutation):
+
+    class Meta:
+        description = 'Removes products, categories, collections from a sale.'
+
+    @classmethod
+    @permission_required('discount.manage_discounts')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        sale = cls.get_node_or_error(
+            info, id, errors, 'saleId', only_type=Sale)
+
+        cls.remove_catalogues_from_node(sale, input, errors)
+        return SaleRemoveCatalogues(sale=sale, errors=errors)
diff --git a/saleor/graphql/discount/schema.py b/saleor/graphql/discount/schema.py
--- a/saleor/graphql/discount/schema.py
+++ b/saleor/graphql/discount/schema.py
@@ -2,14 +2,12 @@
 from graphql_jwt.decorators import permission_required
 
 from ..core.fields import PrefetchingConnectionField
-# FIXME: Types are imported before mutations on purpose. Otherwise these types
-# are missing in Graphene's type registry and mutations cannot be created
-# properly. This happens only in the `saleor.graphql.discount` module.
-from .types import Sale, Voucher
 from .mutations import (
-    SaleCreate, SaleDelete, SaleUpdate, VoucherCreate, VoucherDelete,
-    VoucherUpdate)
+    SaleAddCatalogues, SaleCreate, SaleDelete, SaleRemoveCatalogues,
+    SaleUpdate, VoucherAddCatalogues, VoucherCreate, VoucherDelete,
+    VoucherRemoveCatalogues, VoucherUpdate)
 from .resolvers import resolve_sales, resolve_vouchers
+from .types import Sale, Voucher
 
 
 class DiscountQueries(graphene.ObjectType):
@@ -49,7 +47,11 @@ class DiscountMutations(graphene.ObjectType):
     sale_create = SaleCreate.Field()
     sale_delete = SaleDelete.Field()
     sale_update = SaleUpdate.Field()
+    sale_catalogues_add = SaleAddCatalogues.Field()
+    sale_catalogues_remove = SaleRemoveCatalogues.Field()
 
     voucher_create = VoucherCreate.Field()
     voucher_delete = VoucherDelete.Field()
     voucher_update = VoucherUpdate.Field()
+    voucher_catalogues_add = VoucherAddCatalogues.Field()
+    voucher_catalogues_remove = VoucherRemoveCatalogues.Field()


Test patch:
diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py
--- a/tests/api/test_discount.py
+++ b/tests/api/test_discount.py
@@ -1,11 +1,12 @@
 import graphene
 import pytest
 from django_countries import countries
+from tests.api.utils import get_graphql_content
 
 from saleor.discount import DiscountValueType, VoucherType
+from saleor.discount.models import Sale, Voucher
 from saleor.graphql.discount.enums import (
     DiscountValueTypeEnum, VoucherTypeEnum)
-from tests.api.utils import get_graphql_content
 
 
 @pytest.fixture
@@ -15,6 +16,16 @@ def voucher_countries(voucher):
     return voucher
 
 
+@pytest.fixture
+def sale():
+    return Sale.objects.create(name='Sale', value=123)
+
+
+@pytest.fixture
+def voucher():
+    return Voucher.objects.create(name='Voucher', discount_value=123)
+
+
 def test_voucher_query(
         staff_api_client, voucher_countries, permission_manage_discounts):
     query = """
@@ -90,9 +101,10 @@ def test_create_voucher(staff_api_client, permission_manage_discounts):
         $discountValueType: DiscountValueTypeEnum,
         $discountValue: Decimal, $minAmountSpent: Decimal) {
             voucherCreate(input: {
-            name: $name, type: $type, code: $code,
-            discountValueType: $discountValueType, discountValue: $discountValue,
-            minAmountSpent: $minAmountSpent}) {
+                    name: $name, type: $type, code: $code,
+                    discountValueType: $discountValueType,
+                    discountValue: $discountValue,
+                    minAmountSpent: $minAmountSpent}) {
                 errors {
                     field
                     message
@@ -190,6 +202,141 @@ def test_voucher_delete_mutation(
         voucher.refresh_from_db()
 
 
+def test_voucher_add_catalogues(
+        staff_api_client, voucher, category,
+        product, collection, permission_manage_discounts):
+    query = """
+        mutation voucherCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+            voucherCataloguesAdd(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    product_id = graphene.Node.to_global_id('Product', product.id)
+    collection_id = graphene.Node.to_global_id('Collection', collection.id)
+    category_id = graphene.Node.to_global_id('Category', category.id)
+    variables = {
+        'id': graphene.Node.to_global_id('Voucher', voucher.id),
+        'input': {
+            'products': [product_id],
+            'collections': [collection_id],
+            'categories': [category_id]}}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['voucherCataloguesAdd']
+
+    assert not data['errors']
+    assert product in voucher.products.all()
+    assert category in voucher.categories.all()
+    assert collection in voucher.collections.all()
+
+
+def test_voucher_remove_catalogues(
+        staff_api_client, voucher, category,
+        product, collection, permission_manage_discounts):
+    voucher.products.add(product)
+    voucher.collections.add(collection)
+    voucher.categories.add(category)
+
+    query = """
+        mutation voucherCataloguesRemove($id: ID!, $input: CatalogueInput!) {
+            voucherCataloguesRemove(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    product_id = graphene.Node.to_global_id('Product', product.id)
+    collection_id = graphene.Node.to_global_id('Collection', collection.id)
+    category_id = graphene.Node.to_global_id('Category', category.id)
+    variables = {
+        'id': graphene.Node.to_global_id('Voucher', voucher.id),
+        'input': {
+            'products': [product_id],
+            'collections': [collection_id],
+            'categories': [category_id]}}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['voucherCataloguesRemove']
+
+    assert not data['errors']
+    assert product not in voucher.products.all()
+    assert category not in voucher.categories.all()
+    assert collection not in voucher.collections.all()
+
+
+def test_voucher_add_no_catalogues(
+        staff_api_client, voucher, permission_manage_discounts):
+    query = """
+        mutation voucherCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+            voucherCataloguesAdd(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    variables = {
+        'id': graphene.Node.to_global_id('Voucher', voucher.id),
+        'input': {
+            'products': [],
+            'collections': [],
+            'categories': []}}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['voucherCataloguesAdd']
+
+    assert not data['errors']
+    assert not voucher.products.exists()
+    assert not voucher.categories.exists()
+    assert not voucher.collections.exists()
+
+
+def test_voucher_remove_no_catalogues(
+        staff_api_client, voucher, category,
+        product, collection, permission_manage_discounts):
+    voucher.products.add(product)
+    voucher.collections.add(collection)
+    voucher.categories.add(category)
+
+    query = """
+            mutation voucherCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+                voucherCataloguesAdd(id: $id, input: $input) {
+                    errors {
+                        field
+                        message
+                    }
+                }
+            }
+        """
+    variables = {
+        'id': graphene.Node.to_global_id('Voucher', voucher.id),
+        'input': {
+            'products': [],
+            'collections': [],
+            'categories': []}}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['voucherCataloguesAdd']
+
+    assert not data['errors']
+    assert voucher.products.exists()
+    assert voucher.categories.exists()
+    assert voucher.collections.exists()
+
+
 def test_create_sale(staff_api_client, permission_manage_discounts):
     query = """
     mutation  saleCreate(
@@ -275,6 +422,141 @@ def test_sale_delete_mutation(
         sale.refresh_from_db()
 
 
+def test_sale_add_catalogues(
+        staff_api_client, sale, category,
+        product, collection, permission_manage_discounts):
+    query = """
+        mutation saleCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+            saleCataloguesAdd(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    product_id = graphene.Node.to_global_id('Product', product.id)
+    collection_id = graphene.Node.to_global_id('Collection', collection.id)
+    category_id = graphene.Node.to_global_id('Category', category.id)
+    variables = {
+        'id': graphene.Node.to_global_id('Sale', sale.id),
+        'input': {
+            'products': [product_id],
+            'collections': [collection_id],
+            'categories': [category_id]}}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['saleCataloguesAdd']
+
+    assert not data['errors']
+    assert product in sale.products.all()
+    assert category in sale.categories.all()
+    assert collection in sale.collections.all()
+
+
+def test_sale_remove_catalogues(
+        staff_api_client, sale, category,
+        product, collection, permission_manage_discounts):
+    sale.products.add(product)
+    sale.collections.add(collection)
+    sale.categories.add(category)
+
+    query = """
+        mutation saleCataloguesRemove($id: ID!, $input: CatalogueInput!) {
+            saleCataloguesRemove(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    product_id = graphene.Node.to_global_id('Product', product.id)
+    collection_id = graphene.Node.to_global_id('Collection', collection.id)
+    category_id = graphene.Node.to_global_id('Category', category.id)
+    variables = {
+        'id': graphene.Node.to_global_id('Sale', sale.id),
+        'input': {
+            'products': [product_id],
+            'collections': [collection_id],
+            'categories': [category_id]}}
+
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['saleCataloguesRemove']
+
+    assert not data['errors']
+    assert product not in sale.products.all()
+    assert category not in sale.categories.all()
+    assert collection not in sale.collections.all()
+
+
+def test_sale_add_no_catalogues(
+        staff_api_client, sale, permission_manage_discounts):
+    query = """
+        mutation saleCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+            saleCataloguesAdd(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    variables = {
+        'id': graphene.Node.to_global_id('Sale', sale.id),
+        'input': {
+            'products': [],
+            'collections': [],
+            'categories': []}}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['saleCataloguesAdd']
+
+    assert not data['errors']
+    assert not sale.products.exists()
+    assert not sale.categories.exists()
+    assert not sale.collections.exists()
+
+
+def test_sale_remove_no_catalogues(
+        staff_api_client, sale, category,
+        product, collection, permission_manage_discounts):
+    sale.products.add(product)
+    sale.collections.add(collection)
+    sale.categories.add(category)
+
+    query = """
+        mutation saleCataloguesAdd($id: ID!, $input: CatalogueInput!) {
+            saleCataloguesAdd(id: $id, input: $input) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+    """
+    variables = {
+        'id': graphene.Node.to_global_id('Sale', sale.id),
+        'input': {
+            'products': [],
+            'collections': [],
+            'categories': []}}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_discounts])
+    content = get_graphql_content(response)
+    data = content['data']['saleCataloguesAdd']
+
+    assert not data['errors']
+    assert sale.products.exists()
+    assert sale.categories.exists()
+    assert sale.collections.exists()
+
+
 @pytest.mark.parametrize('voucher_type,field_name', (
     (VoucherTypeEnum.CATEGORY, 'categories'),
     (VoucherTypeEnum.PRODUCT, 'products'),