Repository: saleor/saleor
Pull request: Allow to delete category and leave products
Allow to create product without category. After removing category, related products become unpublished and minimal variant price is recalculated.

Closes #1928 

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [ ] Privileged views and APIs are guarded by proper permission checks.
1. [ ] All visible strings are translated with proper context.
1. [ ] All data-formatting is locale-aware (dates, numbers, and so on).
1. [ ] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Deleting a category deletes all subcategories and their products
Since there's no way to undo deleting one of the main categories is a great way to accidentally get rid of most if not all of your products. The only remedy is restoring the entire store from a recent backup.

Hints from issue discussion:
What about a simple `SET_NULL`?

```patch
Index: saleor/product/models.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- saleor/product/models.py	(date 1523632186000)
+++ saleor/product/models.py	(revision )
@@ -94,8 +94,10 @@
         ProductType, related_name='products', on_delete=models.CASCADE)
     name = models.CharField(max_length=128)
     description = models.TextField()
+    # ALTER TABLE public.product_product
+    #     ALTER COLUMN category_id DROP NOT NULL;
     category = models.ForeignKey(
-        Category, related_name='products', on_delete=models.CASCADE)
+        Category, related_name='products', on_delete=models.SET_NULL, null=True)
     price = MoneyField(
         currency=settings.DEFAULT_CURRENCY, max_digits=12,
         decimal_places=settings.DEFAULT_DECIMAL_PLACES)

```

This would set products categories to null, letting people delete/ handle the product themselves.
We would need to do a test against the whole frontstore and dashboard to see what is failing when a product category is set to null. And would also need to have a filter allowing to list products that have no category associated to them in dashboard.

![](https://i.imgur.com/o2ItfBJ.png)

![](https://i.imgur.com/qz36U8Z.png)

We could also prompt the user to move the products to a given product instead of setting to null.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

I think we could revisit this issue. As @NyanKiyoshi noticed we'd have to change the model to allow for `null` values in the `Product.category` relationship.
We would have to somehow tell the users that category needs to be assigned to start selling the product. I see two options here:
- display a warning message in the dashboard UI if no category is set
- add a check in the `Product.is_available` property to include checking if a category is set. If it's null, product would be simply unavailable for customers.
Actually both of these options could work together :)

Commit message: Merge branch 'master' into allow-to-delete-category-and-leave-products

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79ffa42fd26..4560c496feb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Add sub-categories to random data - #4949 by @IKarbowiak
 - Deprecate `localized` field in Money type - #4952 by @IKarbowiak
 - Fix for shipping api doesn't apply taxes - #4913 by @kswiatek92
+- Allow to delete category and leave products - #4970 by @IKarbowiak
 
 ## 2.9.0
 
diff --git a/saleor/core/models.py b/saleor/core/models.py
index cc349d16fff..07c4037e874 100644
--- a/saleor/core/models.py
+++ b/saleor/core/models.py
@@ -57,7 +57,7 @@ def visible_to_user(self, user):
 
 class PublishableModel(models.Model):
     publication_date = models.DateField(blank=True, null=True)
-    is_published = models.BooleanField(default=True)
+    is_published = models.BooleanField(default=False)
 
     objects = PublishedQuerySet.as_manager()
 
diff --git a/saleor/dashboard/category/views.py b/saleor/dashboard/category/views.py
index 0f733b54258..d7b1e5592e2 100644
--- a/saleor/dashboard/category/views.py
+++ b/saleor/dashboard/category/views.py
@@ -10,6 +10,8 @@
 from ...core.utils import get_paginator_items
 from ...menu.utils import get_menus_that_need_update, update_menus
 from ...product.models import Category
+from ...product.tasks import update_products_minimal_variant_prices_task
+from ...product.utils.attributes import unpublished_products_before_category_delete
 from ..views import staff_member_required
 from .filters import CategoryFilter
 from .forms import CategoryForm
@@ -110,7 +112,10 @@ def category_delete(request, pk):
     if request.method == "POST":
         descendants = category.get_descendants()
         menus = get_menus_that_need_update(categories=descendants)
+        products = unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
         category.delete()
+        update_products_minimal_variant_prices_task.delay(product_ids)
         if menus:
             update_menus(menus)
         messages.success(
diff --git a/saleor/dashboard/product/filters.py b/saleor/dashboard/product/filters.py
index 8d35141c5a6..b89151db0e3 100644
--- a/saleor/dashboard/product/filters.py
+++ b/saleor/dashboard/product/filters.py
@@ -40,6 +40,7 @@ class ProductFilter(SortedFilterSet):
         label=pgettext_lazy("Product list filter label", "Category"),
         field_name="category",
         queryset=Category.objects.all(),
+        null_label="---------",
     )
     product_type = ModelMultipleChoiceFilter(
         label=pgettext_lazy("Product list filter label", "Product type"),
diff --git a/saleor/dashboard/product/forms.py b/saleor/dashboard/product/forms.py
index 57bc62389f5..96030f69af0 100644
--- a/saleor/dashboard/product/forms.py
+++ b/saleor/dashboard/product/forms.py
@@ -1,6 +1,7 @@
 import bleach
 from django import forms
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from django.db import transaction
 from django.db.models import Count
 from django.forms.models import ModelChoiceIterator
@@ -264,7 +265,9 @@ class ProductForm(MoneyModelForm, AttributesMixin):
         ),
     )
     category = TreeNodeChoiceField(
-        queryset=Category.objects.all(), label=pgettext_lazy("Category", "Category")
+        required=False,
+        queryset=Category.objects.all(),
+        label=pgettext_lazy("Category", "Category"),
     )
     collections = forms.ModelMultipleChoiceField(
         required=False,
@@ -363,6 +366,13 @@ def clean_seo_description(self):
         )
         return seo_description
 
+    def clean_is_published(self):
+        is_published = self.cleaned_data["is_published"]
+        category = self.data["category"]
+        if not category and is_published:
+            raise ValidationError("You must add cateogry to publish product")
+        return is_published
+
     def save(self, commit=True):
         assert commit is True, "Commit is required to build the M2M structure"
 
diff --git a/saleor/graphql/product/bulk_mutations/products.py b/saleor/graphql/product/bulk_mutations/products.py
index ca4feafd76b..3903a13fcde 100644
--- a/saleor/graphql/product/bulk_mutations/products.py
+++ b/saleor/graphql/product/bulk_mutations/products.py
@@ -6,8 +6,14 @@
 
 from ....product import models
 from ....product.error_codes import ProductErrorCode
-from ....product.tasks import update_product_minimal_variant_price_task
-from ....product.utils.attributes import generate_name_for_variant
+from ....product.tasks import (
+    update_product_minimal_variant_price_task,
+    update_products_minimal_variant_prices_task,
+)
+from ....product.utils.attributes import (
+    generate_name_for_variant,
+    unpublished_products_before_category_delete,
+)
 from ...core.mutations import (
     BaseBulkMutation,
     BaseMutation,
@@ -38,6 +44,15 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def bulk_action(cls, queryset):
+        products = models.Product.objects.none()
+        for category in queryset:
+            products = products | unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
+        super().bulk_action(queryset)
+        update_products_minimal_variant_prices_task.delay(product_ids=product_ids)
+
 
 class CollectionBulkDelete(ModelBulkDeleteMutation):
     class Arguments:
diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py
index fccea172703..359813e4d1f 100644
--- a/saleor/graphql/product/filters.py
+++ b/saleor/graphql/product/filters.py
@@ -121,6 +121,10 @@ def filter_categories(qs, _, value):
     return qs
 
 
+def filter_has_category(qs, _, value):
+    return qs.filter(category__isnull=not value)
+
+
 def filter_collections(qs, _, value):
     if value:
         collections = get_nodes(value, types.Collection)
@@ -214,6 +218,7 @@ class ProductFilter(django_filters.FilterSet):
     is_published = django_filters.BooleanFilter()
     collections = GlobalIDMultipleChoiceFilter(method=filter_collections)
     categories = GlobalIDMultipleChoiceFilter(method=filter_categories)
+    has_category = django_filters.BooleanFilter(method=filter_has_category)
     price = ObjectTypeFilter(
         input_class=PriceRangeInput, method=filter_price, field_name="price_amount"
     )
@@ -237,6 +242,7 @@ class Meta:
             "is_published",
             "collections",
             "categories",
+            "has_category",
             "price",
             "attributes",
             "stock_availability",
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
index 9380c1324b9..5164e5b4302 100644
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -14,6 +14,7 @@
 from ....product.tasks import (
     update_product_minimal_variant_price_task,
     update_products_minimal_variant_prices_of_catalogues_task,
+    update_products_minimal_variant_prices_task,
     update_variants_names,
 )
 from ....product.thumbnails import (
@@ -24,6 +25,7 @@
 from ....product.utils.attributes import (
     associate_attribute_values_to_instance,
     generate_name_for_variant,
+    unpublished_products_before_category_delete,
 )
 from ...core.mutations import (
     BaseMutation,
@@ -143,6 +145,16 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        node_id = data.get("id")
+        instance = cls.get_node_or_error(info, node_id, only_type=Category)
+        products = unpublished_products_before_category_delete(instance)
+        product_ids = [product.pk for product in products]
+        response = super().perform_mutation(_root, info, **data)
+        update_products_minimal_variant_prices_task.delay(product_ids=product_ids)
+        return response
+
 
 class CollectionInput(graphene.InputObjectType):
     is_published = graphene.Boolean(
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 67503ff6002..2a9ca8fde40 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -2862,7 +2862,7 @@ type Product implements Node {
   name: String!
   description: String!
   descriptionJson: JSONString!
-  category: Category!
+  category: Category
   updatedAt: DateTime
   chargeTaxes: Boolean!
   weight: Weight
@@ -2979,6 +2979,7 @@ input ProductFilterInput {
   isPublished: Boolean
   collections: [ID]
   categories: [ID]
+  hasCategory: Boolean
   price: PriceRangeInput
   attributes: [AttributeInput]
   stockAvailability: StockAvailability
diff --git a/saleor/page/migrations/0009_auto_20191108_0402.py b/saleor/page/migrations/0009_auto_20191108_0402.py
new file mode 100644
index 00000000000..1149f6f0e0d
--- /dev/null
+++ b/saleor/page/migrations/0009_auto_20191108_0402.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.2.6 on 2019-11-08 10:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("page", "0008_raw_html_to_json")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="page",
+            name="is_published",
+            field=models.BooleanField(default=False),
+        )
+    ]
diff --git a/saleor/product/migrations/0110_auto_20191108_0340.py b/saleor/product/migrations/0110_auto_20191108_0340.py
new file mode 100644
index 00000000000..3d9b63d0118
--- /dev/null
+++ b/saleor/product/migrations/0110_auto_20191108_0340.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.2.6 on 2019-11-08 09:40
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0109_auto_20191006_1433")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="collection",
+            name="is_published",
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name="product",
+            name="category",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="products",
+                to="product.Category",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="product",
+            name="is_published",
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/saleor/product/models.py b/saleor/product/models.py
index 1f712dc0ce0..e3e3e1f6d88 100644
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -253,7 +253,11 @@ class Product(SeoModel, ModelWithMetadata, PublishableModel):
         blank=True, default=dict, sanitizer=clean_draft_js
     )
     category = models.ForeignKey(
-        Category, related_name="products", on_delete=models.CASCADE
+        Category,
+        related_name="products",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
     )
 
     currency = models.CharField(
@@ -315,6 +319,10 @@ def save(
         # Make sure the "minimal_variant_price_amount" is set
         if self.minimal_variant_price_amount is None:
             self.minimal_variant_price_amount = self.price_amount
+
+        # Product can't be published if doesn't have category
+        if not self.category and self.is_published:
+            self.is_published = False
         return super().save(force_insert, force_update, using, update_fields)
 
     @property
diff --git a/saleor/product/tasks.py b/saleor/product/tasks.py
index 9ad2e7c35cc..dbe79d82d27 100644
--- a/saleor/product/tasks.py
+++ b/saleor/product/tasks.py
@@ -60,6 +60,12 @@ def update_products_minimal_variant_prices_of_discount_task(discount_pk):
     update_products_minimal_variant_prices_of_discount(discount)
 
 
+@app.task
+def update_products_minimal_variant_prices_task(product_ids):
+    products = Product.objects.filter(pk__in=product_ids)
+    update_products_minimal_variant_prices(products)
+
+
 @app.task
 def update_all_products_minimal_variant_prices_task():
     products = Product.objects.iterator()
diff --git a/saleor/product/utils/attributes.py b/saleor/product/utils/attributes.py
index 9a881530708..6e2baf19d49 100644
--- a/saleor/product/utils/attributes.py
+++ b/saleor/product/utils/attributes.py
@@ -81,3 +81,12 @@ def associate_attribute_values_to_instance(
     assignment = _associate_attribute_to_instance(instance, attribute.pk)
     assignment.values.set(values)
     return assignment
+
+
+def unpublished_products_before_category_delete(category):
+    products = category.products.all()
+    descendants = category.get_descendants()
+    for descendant in descendants:
+        products = products | descendant.products.all()
+    products.update(is_published=False, publication_date=None)
+    return products
diff --git a/templates/dashboard/category/modal/confirm_delete.html b/templates/dashboard/category/modal/confirm_delete.html
index 0d576e64d0f..adc6d217ae5 100644
--- a/templates/dashboard/category/modal/confirm_delete.html
+++ b/templates/dashboard/category/modal/confirm_delete.html
@@ -36,9 +36,9 @@
     {% if products_count %}
       <p>
         {% blocktrans count counter=products_count trimmed context "Remove category form text about products" %}
-          There is one product in this category that will be removed.
+          There is one product in this category that will be set as unavailable.
           {% plural %}
-          There are {{ counter }} products in this category that will be removed.
+          There are {{ counter }} products in this category that will be set as unavailable.
         {% endblocktrans %}
       </p>
     {% endif %}
diff --git a/tests/api/test_bulk_delete.py b/tests/api/test_bulk_delete.py
index 5a3f1c9bbde..49113c8d633 100644
--- a/tests/api/test_bulk_delete.py
+++ b/tests/api/test_bulk_delete.py
@@ -35,6 +35,15 @@
     """
 
 
+MUTATION_CATEGORY_BULK_DELETE = """
+    mutation categoryBulkDelete($ids: [ID]!) {
+        categoryBulkDelete(ids: $ids) {
+            count
+        }
+    }
+"""
+
+
 @pytest.fixture
 def attribute_value_list(color_attribute):
     value_1 = AttributeValue.objects.create(
@@ -167,13 +176,48 @@ def test_delete_attribute_values(
 
 
 def test_delete_categories(staff_api_client, category_list, permission_manage_products):
-    query = """
-    mutation categoryBulkDelete($ids: [ID]!) {
-        categoryBulkDelete(ids: $ids) {
-            count
-        }
+    variables = {
+        "ids": [
+            graphene.Node.to_global_id("Category", category.id)
+            for category in category_list
+        ]
     }
-    """
+    response = staff_api_client.post_graphql(
+        MUTATION_CATEGORY_BULK_DELETE,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+
+    assert content["data"]["categoryBulkDelete"]["count"] == 3
+    assert not Category.objects.filter(
+        id__in=[category.id for category in category_list]
+    ).exists()
+
+
+@patch(
+    "saleor.graphql.product.bulk_mutations.products"
+    ".update_products_minimal_variant_prices_task"
+)
+def test_delete_categories_with_subcategories_and_products(
+    mock_update_products_minimal_variant_prices_task,
+    staff_api_client,
+    category_list,
+    permission_manage_products,
+    product,
+    category,
+):
+    product.category = category
+    category.parent = category_list[0]
+    product.save()
+    category.save()
+
+    parent_product = Product.objects.get(pk=product.pk)
+    parent_product.id = None
+    parent_product.category = category_list[0]
+    parent_product.save()
+
+    product_list = [product, parent_product]
 
     variables = {
         "ids": [
@@ -182,7 +226,9 @@ def test_delete_categories(staff_api_client, category_list, permission_manage_pr
         ]
     }
     response = staff_api_client.post_graphql(
-        query, variables, permissions=[permission_manage_products]
+        MUTATION_CATEGORY_BULK_DELETE,
+        variables,
+        permissions=[permission_manage_products],
     )
     content = get_graphql_content(response)
 
@@ -191,6 +237,16 @@ def test_delete_categories(staff_api_client, category_list, permission_manage_pr
         id__in=[category.id for category in category_list]
     ).exists()
 
+    mock_update_products_minimal_variant_prices_task.delay.assert_called_once_with(
+        product_ids=[p.pk for p in product_list]
+    )
+
+    for product in product_list:
+        product.refresh_from_db()
+        assert not product.category
+        assert not product.is_published
+        assert not product.publication_date
+
 
 def test_delete_collections(
     staff_api_client, collection_list, permission_manage_products
diff --git a/tests/api/test_category.py b/tests/api/test_category.py
index c255d6035da..ac98b7a1bc2 100644
--- a/tests/api/test_category.py
+++ b/tests/api/test_category.py
@@ -350,25 +350,27 @@ def test_category_update_mutation_without_background_image(
     assert mock_create_thumbnails.call_count == 0
 
 
+MUTATION_CATEGORY_DELETE = """
+    mutation($id: ID!) {
+        categoryDelete(id: $id) {
+            category {
+                name
+            }
+            errors {
+                field
+                message
+            }
+        }
+    }
+"""
+
+
 def test_category_delete_mutation(
     staff_api_client, category, permission_manage_products
 ):
-    query = """
-        mutation($id: ID!) {
-            categoryDelete(id: $id) {
-                category {
-                    name
-                }
-                errors {
-                    field
-                    message
-                }
-            }
-        }
-    """
     variables = {"id": graphene.Node.to_global_id("Category", category.id)}
     response = staff_api_client.post_graphql(
-        query, variables, permissions=[permission_manage_products]
+        MUTATION_CATEGORY_DELETE, variables, permissions=[permission_manage_products]
     )
     content = get_graphql_content(response)
     data = content["data"]["categoryDelete"]
@@ -377,6 +379,62 @@ def test_category_delete_mutation(
         category.refresh_from_db()
 
 
+def test_category_delete_mutation_for_categories_tree(
+    staff_api_client,
+    categories_tree_with_published_products,
+    permission_manage_products,
+):
+    parent = categories_tree_with_published_products
+    parent_product = parent.products.first()
+    child_product = parent.children.first().products.first()
+
+    variables = {"id": graphene.Node.to_global_id("Category", parent.id)}
+    response = staff_api_client.post_graphql(
+        MUTATION_CATEGORY_DELETE, variables, permissions=[permission_manage_products]
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["categoryDelete"]
+    assert data["category"]["name"] == parent.name
+    with pytest.raises(parent._meta.model.DoesNotExist):
+        parent.refresh_from_db()
+
+    for product in [child_product, parent_product]:
+        product.refresh_from_db()
+        assert not product.is_published
+        assert not product.publication_date
+
+
+def test_category_delete_mutation_for_children_from_categories_tree(
+    staff_api_client,
+    categories_tree_with_published_products,
+    permission_manage_products,
+):
+    parent = categories_tree_with_published_products
+    child = parent.children.first()
+    parent_product = parent.products.first()
+    child_product = child.products.first()
+
+    variables = {"id": graphene.Node.to_global_id("Category", child.id)}
+    response = staff_api_client.post_graphql(
+        MUTATION_CATEGORY_DELETE, variables, permissions=[permission_manage_products]
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["categoryDelete"]
+    assert data["category"]["name"] == child.name
+    with pytest.raises(child._meta.model.DoesNotExist):
+        child.refresh_from_db()
+
+    parent_product.refresh_from_db()
+    assert parent_product.category
+    assert parent_product.is_published
+    assert parent_product.publication_date
+
+    child_product.refresh_from_db()
+    assert not child_product.category
+    assert not child_product.is_published
+    assert not child_product.publication_date
+
+
 LEVELED_CATEGORIES_QUERY = """
     query leveled_categories($level: Int) {
         categories(level: $level, first: 20) {
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
index 132a9eaab0b..1f5acdd5a09 100644
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -321,6 +321,50 @@ def test_products_query_with_filter_category(
     assert products[0]["node"]["name"] == second_product.name
 
 
+def test_products_query_with_filter_has_category_false(
+    query_products_with_filter, staff_api_client, product, permission_manage_products
+):
+    second_product = product
+    second_product.category = None
+    second_product.id = None
+    second_product.save()
+
+    variables = {"filter": {"hasCategory": False}}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(query_products_with_filter, variables)
+    content = get_graphql_content(response)
+    second_product_id = graphene.Node.to_global_id("Product", second_product.id)
+    products = content["data"]["products"]["edges"]
+
+    assert len(products) == 1
+    assert products[0]["node"]["id"] == second_product_id
+    assert products[0]["node"]["name"] == second_product.name
+
+
+def test_products_query_with_filter_has_category_true(
+    query_products_with_filter,
+    staff_api_client,
+    product_without_category,
+    permission_manage_products,
+):
+    category = Category.objects.create(name="Custom", slug="custom")
+    second_product = product_without_category
+    second_product.category = category
+    second_product.id = None
+    second_product.save()
+
+    variables = {"filter": {"hasCategory": True}}
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(query_products_with_filter, variables)
+    content = get_graphql_content(response)
+    second_product_id = graphene.Node.to_global_id("Product", second_product.id)
+    products = content["data"]["products"]["edges"]
+
+    assert len(products) == 1
+    assert products[0]["node"]["id"] == second_product_id
+    assert products[0]["node"]["name"] == second_product.name
+
+
 def test_products_query_with_filter_collection(
     query_products_with_filter,
     staff_api_client,
@@ -393,12 +437,14 @@ def test_product_query_search(user_api_client, product_type, category):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     Product.objects.create(
         name="Red Paint",
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
 
     query = """
diff --git a/tests/api/test_product_minimal_variant_price.py b/tests/api/test_product_minimal_variant_price.py
index 7659698c310..492f3444386 100644
--- a/tests/api/test_product_minimal_variant_price.py
+++ b/tests/api/test_product_minimal_variant_price.py
@@ -1,7 +1,6 @@
 from unittest.mock import patch
 
 import graphene
-import pytest
 from freezegun import freeze_time
 from graphql_relay import from_global_id, to_global_id
 from prices import Money
@@ -263,12 +262,19 @@ def test_product_variant_delete_updates_minimal_variant_price(
     )
 
 
-def test_category_delete_also_deletes_products(
-    staff_api_client, product, category, permission_manage_products
+@patch(
+    "saleor.graphql.product.mutations.products"
+    ".update_products_minimal_variant_prices_task"
+)
+def test_category_delete_updates_minimal_variant_price(
+    mock_update_products_minimal_variant_prices_task,
+    staff_api_client,
+    categories_tree_with_published_products,
+    permission_manage_products,
 ):
-    # Making sure the products associated with the category are deleted otherwise
-    # we need to update their "minimal_variant_price" if the category was on sale.
-    category.products.add(product)
+    parent = categories_tree_with_published_products
+    product_list = [parent.children.first().products.first(), parent.products.first()]
+
     query = """
         mutation CategoryDelete($id: ID!) {
             categoryDelete(id: $id) {
@@ -282,7 +288,7 @@ def test_category_delete_also_deletes_products(
             }
         }
     """
-    variables = {"id": to_global_id("Category", category.pk)}
+    variables = {"id": to_global_id("Category", parent.pk)}
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_products]
     )
@@ -291,8 +297,14 @@ def test_category_delete_also_deletes_products(
     content = get_graphql_content(response)
     data = content["data"]["categoryDelete"]
     assert data["errors"] == []
-    with pytest.raises(product._meta.model.DoesNotExist):
+
+    mock_update_products_minimal_variant_prices_task.delay.assert_called_once_with(
+        product_ids=[p.pk for p in product_list]
+    )
+
+    for product in product_list:
         product.refresh_from_db()
+        assert not product.category
 
 
 @patch(
diff --git a/tests/conftest.py b/tests/conftest.py
index 26bcd52a6ba..61ad33ef2d6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+import datetime
 import uuid
 from decimal import Decimal
 from io import BytesIO
@@ -419,12 +420,29 @@ def categories_tree(db, product_type):  # pylint: disable=W0613
         price=Money(10, "USD"),
         product_type=product_type,
         category=child,
+        is_published=True,
     )
 
     associate_attribute_values_to_instance(product, product_attr, attr_value)
     return parent
 
 
+@pytest.fixture
+def categories_tree_with_published_products(categories_tree, product):
+    parent = categories_tree
+    parent_product = product
+    parent_product.category = parent
+
+    child = parent.children.first()
+    child_product = child.products.first()
+
+    for product in [child_product, parent_product]:
+        product.publication_date = datetime.date.today()
+        product.is_published = True
+        product.save()
+    return parent
+
+
 @pytest.fixture
 def non_default_category(db):  # pylint: disable=W0613
     return Category.objects.create(name="Not default", slug="not-default")
@@ -483,6 +501,7 @@ def product(product_type, category):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
 
     associate_attribute_values_to_instance(product, product_attr, product_attr_value)
@@ -515,6 +534,7 @@ def product_with_two_variants(color_attribute, size_attribute, category):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
 
     variant = ProductVariant.objects.create(
@@ -563,6 +583,7 @@ def product_with_default_variant(product_type_without_variant, category):
         price=Money(10, "USD"),
         product_type=product_type_without_variant,
         category=category,
+        is_published=True,
     )
     ProductVariant.objects.create(
         product=product, sku="1234", track_inventory=True, quantity=100
@@ -605,11 +626,19 @@ def product_without_shipping(category):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     ProductVariant.objects.create(product=product, sku="SKU_B")
     return product
 
 
+@pytest.fixture
+def product_without_category(product):
+    product.category = None
+    product.save()
+    return product
+
+
 @pytest.fixture
 def product_list(product_type, category):
     product_attr = product_type.product_attributes.first()
@@ -753,6 +782,7 @@ def product_with_images(product_type, category, media_root):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     file_mock_0 = MagicMock(spec=File, name="FileMock0")
     file_mock_0.name = "image0.jpg"
@@ -863,6 +893,7 @@ def order_with_lines(order, product_type, category, shipping_zone):
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     variant = ProductVariant.objects.create(
         product=product,
@@ -889,6 +920,7 @@ def order_with_lines(order, product_type, category, shipping_zone):
         price=Money("20.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     variant = ProductVariant.objects.create(
         product=product,
@@ -1146,6 +1178,7 @@ def collection_with_image(db, image, media_root):
         slug="collection",
         description="Test description",
         background_image=image,
+        is_published=True,
     )
     return collection
 
@@ -1154,9 +1187,9 @@ def collection_with_image(db, image, media_root):
 def collection_list(db):
     collections = Collection.objects.bulk_create(
         [
-            Collection(name="Collection 1"),
-            Collection(name="Collection 2"),
-            Collection(name="Collection 3"),
+            Collection(name="Collection 1", is_published="True"),
+            Collection(name="Collection 2", is_published="True"),
+            Collection(name="Collection 3", is_published="True"),
         ]
     )
     return collections
@@ -1189,15 +1222,30 @@ def unpublished_collection():
 
 @pytest.fixture
 def page(db):
-    data = {"slug": "test-url", "title": "Test page", "content": "test content"}
+    data = {
+        "slug": "test-url",
+        "title": "Test page",
+        "content": "test content",
+        "is_published": True,
+    }
     page = Page.objects.create(**data)
     return page
 
 
 @pytest.fixture
 def page_list(db):
-    data_1 = {"slug": "test-url", "title": "Test page", "content": "test content"}
-    data_2 = {"slug": "test-url-2", "title": "Test page", "content": "test content"}
+    data_1 = {
+        "slug": "test-url",
+        "title": "Test page",
+        "content": "test content",
+        "is_published": True,
+    }
+    data_2 = {
+        "slug": "test-url-2",
+        "title": "Test page",
+        "content": "test content",
+        "is_published": True,
+    }
     pages = Page.objects.bulk_create([Page(**data_1), Page(**data_2)])
     return pages
 
@@ -1326,6 +1374,7 @@ def digital_content(category, media_root) -> DigitalContent:
         price=Money("10.00", "USD"),
         product_type=product_type,
         category=category,
+        is_published=True,
     )
     product_variant = ProductVariant.objects.create(
         product=product,
diff --git a/tests/dashboard/test_product.py b/tests/dashboard/test_product.py
index 96a305c2010..c4d56867d73 100644
--- a/tests/dashboard/test_product.py
+++ b/tests/dashboard/test_product.py
@@ -107,6 +107,28 @@ def test_view_product_list_with_filters_no_results(admin_client, product_list):
     assert list(response.context["filter_set"].qs) == []
 
 
+def test_view_product_list_with_filters_no_category(admin_client, product_list):
+    product_without_category = product_list[0]
+    product_without_category.category = None
+    product_without_category.save()
+
+    url = reverse("dashboard:product-list")
+    data = {
+        "price_max": [""],
+        "price_min": [""],
+        "is_featured": [""],
+        "name": [""],
+        "sort_by": [""],
+        "category": ["null"],
+        "is_published": [""],
+    }
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context["filter_set"].qs) == [product_without_category]
+
+
 def test_view_product_list_pagination(admin_client, product_list, settings):
     settings.DASHBOARD_PAGINATE_BY = 1
     url = reverse("dashboard:product-list")
@@ -1297,3 +1319,43 @@ def test_product_form_seo_description_too_long(unavailable_product):
         "form sixth. Image moving earth without"
     )
     assert new_seo_description.endswith("...") or new_seo_description[-1] == "…"
+
+
+def test_product_form_publish_product_with_category(product):
+    data = model_to_dict(product)
+    data["price_0"] = 20
+    data["price_1"] = "USD"
+    data["description"] = "Test description"
+
+    form = ProductForm(data, instance=product)
+    assert form.is_valid()
+    assert form.errors == {}
+
+    form.save()
+    assert product.is_published
+
+
+def test_product_form_without_category(product_without_category):
+    data = model_to_dict(product_without_category)
+    data["price_0"] = 20
+    data["price_1"] = "USD"
+    data["description"] = "Test description"
+
+    form = ProductForm(data, instance=product_without_category)
+    assert form.is_valid()
+    assert form.errors == {}
+
+    form.save()
+    assert not product_without_category.is_published
+
+
+def test_product_form_publish_product_without_category(product_without_category):
+    product_without_category.is_published = True
+    data = model_to_dict(product_without_category)
+    data["price_0"] = 20
+    data["price_1"] = "USD"
+    data["description"] = "Test description"
+
+    form = ProductForm(data, instance=product_without_category)
+    assert not form.is_valid()
+    assert form.errors == {"is_published": ["You must add cateogry to publish product"]}
diff --git a/tests/test_elasticsearch.py b/tests/test_elasticsearch.py
index 3718559b712..9ea2399942b 100644
--- a/tests/test_elasticsearch.py
+++ b/tests/test_elasticsearch.py
@@ -55,6 +55,7 @@ def gen_product_with_id(object_id):
             price=Money(10, "USD"),
             product_type=product_type,
             category=category,
+            is_published=True,
         )
         return product
 
diff --git a/tests/test_postgresql_search.py b/tests/test_postgresql_search.py
index 4941c14cb01..e37f502c742 100644
--- a/tests/test_postgresql_search.py
+++ b/tests/test_postgresql_search.py
@@ -31,6 +31,7 @@ def gen_product(name, description):
             price=Money(Decimal(6.6), "USD"),
             product_type=product_type,
             category=category,
+            is_published=True,
         )
         return product
 
diff --git a/tests/test_product.py b/tests/test_product.py
index 7877c44d74d..b581521fc28 100644
--- a/tests/test_product.py
+++ b/tests/test_product.py
@@ -1005,3 +1005,52 @@ def test_costs_get_margin_for_variant(variant, price, cost):
     variant.cost_price = cost
     variant.price_override = price
     assert not get_margin_for_variant(variant)
+
+
+def test_save_product_without_category_and_true_is_published_value(product_type):
+    product = Product(
+        name="Test product",
+        product_type=product_type,
+        price=Money("15.00", "USD"),
+        is_published=True,
+    )
+
+    product.save()
+
+    assert not product.is_published
+
+
+def test_save_product_with_category_and_true_is_published_value(product_type, category):
+    product = Product(
+        name="Test product",
+        product_type=product_type,
+        category=category,
+        price=Money("15.00", "USD"),
+        is_published=True,
+    )
+
+    product.save()
+    assert product.is_published
+
+
+def test_publish_product_without_category(product_without_category):
+    product_without_category.is_published = True
+
+    product_without_category.save()
+
+    assert not product_without_category.is_published
+
+
+def test_publish_product_with_category(product_type, category):
+    product = Product.objects.create(
+        name="Test product",
+        product_type=product_type,
+        category=category,
+        price=Money("15.00", "USD"),
+        is_published=False,
+    )
+    product.is_published = True
+
+    product.save()
+
+    assert product.is_published
diff --git a/tests/test_product_attributes.py b/tests/test_product_attributes.py
index 2eaaa054c49..6a71fbc86ca 100644
--- a/tests/test_product_attributes.py
+++ b/tests/test_product_attributes.py
@@ -9,6 +9,7 @@
 from saleor.product.utils.attributes import (
     associate_attribute_values_to_instance,
     generate_name_for_variant,
+    unpublished_products_before_category_delete,
 )
 
 
@@ -136,3 +137,18 @@ def test_associate_attribute_to_product_instance_without_values(product):
     # Ensure the values were cleared and no new assignment entry was created
     assert new_assignment.pk == old_assignment.pk
     assert new_assignment.values.count() == 0
+
+
+def test_unpublished_products_before_category_delete_for_category_tree(
+    categories_tree_with_published_products
+):
+    parent = categories_tree_with_published_products
+    parent_product = parent.products.first()
+    child_product = parent.children.first().products.first()
+
+    unpublished_products_before_category_delete(parent)
+
+    for product in [child_product, parent_product]:
+        product.refresh_from_db()
+        assert not product.is_published
+        assert not product.publication_date
diff --git a/tests/test_product_minimal_variant_price.py b/tests/test_product_minimal_variant_price.py
index 3976e37a8c8..1a44cd7b40d 100644
--- a/tests/test_product_minimal_variant_price.py
+++ b/tests/test_product_minimal_variant_price.py
@@ -10,6 +10,7 @@
 from saleor.product.tasks import (
     update_all_products_minimal_variant_prices_task,
     update_products_minimal_variant_prices_of_catalogues,
+    update_products_minimal_variant_prices_task,
 )
 from saleor.product.utils.variant_prices import update_product_minimal_variant_price
 
@@ -89,6 +90,24 @@ def test_update_all_products_minimal_variant_prices_task(product_list):
         assert product.minimal_variant_price == price_override
 
 
+def test_update_products_minimal_variant_prices_task(product_list):
+    price_override = Money("0.01", "USD")
+    for product in product_list:
+        assert product.minimal_variant_price > price_override
+        variant = product.variants.first()
+        variant.price_override = price_override
+        variant.save()
+        # Check that "variant.save()" doesn't update the "minimal_variant_price"
+        assert product.minimal_variant_price > price_override
+
+    update_products_minimal_variant_prices_task.apply(
+        kwargs={"product_ids": [product.pk for product in product_list]}
+    )
+    for product in product_list:
+        product.refresh_from_db()
+        assert product.minimal_variant_price == price_override
+
+
 def test_product_objects_create_sets_default_minimal_variant_price(
     product_type, category
 ):


Code review comments:
diff_hunk: @@ -363,6 +366,13 @@ def clean_seo_description(self):
         )
         return seo_description
 
+    def clean_is_published(self):
+        is_published = self.cleaned_data["is_published"]
path: saleor/dashboard/product/forms.py
text: @user1:
```suggestion
        is_published = self.cleaned_data.get("is_published")
```

diff_hunk: @@ -38,6 +44,15 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def bulk_action(cls, queryset):
+        products = models.Product.objects.none()
+        for category in queryset:
+            products = products | unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
path: saleor/graphql/product/bulk_mutations/products.py
text: @user1:
```suggestion
        product_ids = list(products.values_list("id", flat=True))
```

diff_hunk: @@ -38,6 +44,15 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def bulk_action(cls, queryset):
+        products = models.Product.objects.none()
+        for category in queryset:
+            products = products | unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
+        super().bulk_action(queryset)
path: saleor/graphql/product/bulk_mutations/products.py
text: @user1:
Should be inside a transaction

@user2:
Not sure if this is part of this PR - it's `BaseBulkMutation`'s responsibility to ensure that there is a transaction.

diff_hunk: @@ -110,7 +112,10 @@ def category_delete(request, pk):
     if request.method == "POST":
         descendants = category.get_descendants()
         menus = get_menus_that_need_update(categories=descendants)
+        products = unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
         category.delete()
path: saleor/dashboard/category/views.py
text: @user1:
We need a transaction here

diff_hunk: @@ -363,6 +366,13 @@ def clean_seo_description(self):
         )
         return seo_description
 
+    def clean_is_published(self):
+        is_published = self.cleaned_data["is_published"]
+        category = self.data["category"]
+        if not category and is_published:
+            raise ValidationError("You must add cateogry to publish product")
path: saleor/dashboard/product/forms.py
text: @user1:
```suggestion
            raise ValidationError("You must select a category to be able to publish")
```

@user2:
@user1 While there is a typo in "cateogry" and it should say "a category", let's not comment on minor language changes which doesn't really change the meaning or fix any real problem. There is no difference between wording "add" vs "select" or "publish" vs "be able to publish" - both are fine.

diff_hunk: @@ -315,6 +319,10 @@ def save(
         # Make sure the "minimal_variant_price_amount" is set
         if self.minimal_variant_price_amount is None:
             self.minimal_variant_price_amount = self.price_amount
+
+        # Product can't be published if doesn't have category
+        if not self.category and self.is_published:
path: saleor/product/models.py
text: @user1:
If we have the right checks in mutations and views that modify the category model, this logic here won't be necessary. We usually don't put much constraints in the `save` method in models.

diff_hunk: @@ -363,6 +366,13 @@ def clean_seo_description(self):
         )
         return seo_description
 
+    def clean_is_published(self):
+        is_published = self.cleaned_data["is_published"]
+        category = self.data["category"]
+        if not category and is_published:
+            raise ValidationError("You must add cateogry to publish product")
path: saleor/dashboard/product/forms.py
text: @user1:
We also need to add this rule in API. Two inputs that use this fields are `ProductCreateInput` and `ProductInput` and mutations that use them should behave similarly.

diff_hunk: @@ -110,7 +112,10 @@ def category_delete(request, pk):
     if request.method == "POST":
         descendants = category.get_descendants()
         menus = get_menus_that_need_update(categories=descendants)
+        products = unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
         category.delete()
+        update_products_minimal_variant_prices_task.delay(product_ids)
path: saleor/dashboard/category/views.py
text: @user1:
This four lines are executed every time when we delete a category. The ideal solution would be to put them into one function. I actually think that `unpublished_products_before_category_delete` could become the `delete_category` function that does all of that. It should also have a docstring which would tell you that this function removes a category plus it performs some additional logic related to unpublishing the products.

@author:
New function: `delete_categories` in saleor/product/utils/\_\_init\__.py

diff_hunk: @@ -81,3 +81,12 @@ def associate_attribute_values_to_instance(
     assignment = _associate_attribute_to_instance(instance, attribute.pk)
     assignment.values.set(values)
     return assignment
+
+
+def unpublished_products_before_category_delete(category):
+    products = category.products.all()
+    descendants = category.get_descendants()
+    for descendant in descendants:
+        products = products | descendant.products.all()
+    products.update(is_published=False, publication_date=None)
+    return products
path: saleor/product/utils/attributes.py
text: @user1:
That's not a utility for attributes (cf. the filename).

Also, you could add it as a manager method on the category model, would be cleaner 😉 

@user2:
It'd fine to just move it to `saleor/product/__init__.py` which contains some general utility functions related to products.

@user1:
Better to avoid `__init__` it is not supposed to contain >10% of the logic, we expect mostly imports in it.

@user2:
But it does at the moment. It's better to put the function there now and do the refactoring in a separate PR, since we would need to probably move more functions.

diff_hunk: @@ -143,6 +145,16 @@ class Meta:
         error_type_class = ProductError
         error_type_field = "product_errors"
 
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        node_id = data.get("id")
+        instance = cls.get_node_or_error(info, node_id, only_type=Category)
+        products = unpublished_products_before_category_delete(instance)
+        product_ids = [product.pk for product in products]
path: saleor/graphql/product/mutations/products.py
text: @user1:
same, use `values_list("pk", flat=True)`

diff_hunk: @@ -110,7 +112,10 @@ def category_delete(request, pk):
     if request.method == "POST":
         descendants = category.get_descendants()
         menus = get_menus_that_need_update(categories=descendants)
+        products = unpublished_products_before_category_delete(category)
+        product_ids = [product.pk for product in products]
path: saleor/dashboard/category/views.py
text: @user1:
```suggestion
        product_ids = list(products.values_list("id", flat=True))
```

Otherwise it is very heavy.

diff_hunk: @@ -363,6 +366,13 @@ def clean_seo_description(self):
         )
         return seo_description
 
+    def clean_is_published(self):
+        is_published = self.cleaned_data["is_published"]
+        category = self.data["category"]
path: saleor/dashboard/product/forms.py
text: @user1:
```suggestion
        category = self.data.get("category")
```