Repository: saleor/saleor
Pull request: Update JSON menu representation in mutations
Storefront 1.0 uses `json_content` fields to render menus in its HTML templates (to optimize rendering and not fetch the whole structure from the database every time). Those fields were however not updated when using API mutations.

This PR introduces the following changes:
- adds `update_menu` and `update_menus` calls in mutations that touch menu items
- moves the utilty functions from `saleor.dashboard.menu.utils` to `saleor.menu.utils` as they're now used outside of dashboard 1.0 as well.

Also fixes #4471

### Screenshots

<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] 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. [x] 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. [ ] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Cannot assign a page to a menu item (graphql)
Cannot assign "<Page: About>": "MenuItem.category" must be a "Category" instance.

```
Traceback (most recent call last):
  File "/Users/mikail/Development/saleor-venv/lib/python3.7/site-packages/promise/promise.py", line 487, in _resolve_from_executor
    executor(resolve, reject)
  File "/Users/mikail/Development/saleor-venv/lib/python3.7/site-packages/promise/promise.py", line 754, in executor
    return resolve(f(*args, **kwargs))
  File "/Users/mikail/Development/saleor-venv/lib/python3.7/site-packages/graphql/execution/middleware.py", line 75, in make_it_promise
    return next(*args, **kwargs)
  File "/Users/mikail/Development/saleor/saleor/graphql/core/mutations.py", line 207, in mutate
    response = cls.perform_mutation(root, info, **data)
  File "/Users/mikail/Development/saleor/saleor/graphql/core/mutations.py", line 354, in perform_mutation
    instance = cls.construct_instance(instance, cleaned_input)
  File "/Users/mikail/Development/saleor/saleor/graphql/core/mutations.py", line 184, in construct_instance
    f.save_form_data(instance, data)
  File "/Users/mikail/Development/saleor-venv/lib/python3.7/site-packages/django/db/models/fields/__init__.py", line 853, in save_form_data
    setattr(instance, self.name, data)
  File "/Users/mikail/Development/saleor-venv/lib/python3.7/site-packages/django/db/models/fields/related_descriptors.py", line 211, in __set__
    self.field.remote_field.model._meta.object_name,
ValueError: Cannot assign "<Page: About>": "MenuItem.category" must be a "Category" instance.
```

Hints from issue discussion:

Commit message: Validate menu item ID in mutation

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15e79d68ec2..8851e8ba4f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,8 @@ All notable, unreleased changes to this project will be documented in this file.
 - Changed license for artwork to CC-BY 4.0
 - Fix rendering user avatar when it's null #4546 by @maarcingebala
 
+- Update JSON menu representation in mutations - #4524 by @maarcingebala
+
 ## 2.8.0
 
 ### Core
diff --git a/saleor/core/utils/random_data.py b/saleor/core/utils/random_data.py
index 203e09fb5f8..12cfe393dd3 100644
--- a/saleor/core/utils/random_data.py
+++ b/saleor/core/utils/random_data.py
@@ -21,12 +21,12 @@
 from ...checkout import AddressType
 from ...core.utils.json_serializer import object_hook
 from ...core.weight import zero_weight
-from ...dashboard.menu.utils import update_menu
 from ...discount import DiscountValueType, VoucherType
 from ...discount.models import Sale, Voucher
 from ...discount.utils import fetch_discounts
 from ...giftcard.models import GiftCard
 from ...menu.models import Menu
+from ...menu.utils import update_menu
 from ...order.models import Fulfillment, Order, OrderLine
 from ...order.utils import update_order_status
 from ...page.models import Page
diff --git a/saleor/dashboard/category/views.py b/saleor/dashboard/category/views.py
index 0afedd27568..2757e6f8ea7 100644
--- a/saleor/dashboard/category/views.py
+++ b/saleor/dashboard/category/views.py
@@ -8,8 +8,8 @@
 from django.utils.translation import pgettext_lazy
 
 from ...core.utils import get_paginator_items
+from ...menu.utils import get_menus_that_needs_update, update_menus
 from ...product.models import Category
-from ..menu.utils import get_menus_that_needs_update, update_menus
 from ..views import staff_member_required
 from .filters import CategoryFilter
 from .forms import CategoryForm
diff --git a/saleor/dashboard/collection/views.py b/saleor/dashboard/collection/views.py
index 889f8865edc..743b19aa968 100644
--- a/saleor/dashboard/collection/views.py
+++ b/saleor/dashboard/collection/views.py
@@ -9,8 +9,8 @@
 from django.views.decorators.http import require_POST
 
 from ...core.utils import get_paginator_items
+from ...menu.utils import get_menus_that_needs_update, update_menus
 from ...product.models import Collection
-from ..menu.utils import get_menus_that_needs_update, update_menus
 from ..views import staff_member_required
 from .filters import CollectionFilter
 from .forms import AssignHomepageCollectionForm, CollectionForm
diff --git a/saleor/dashboard/menu/forms.py b/saleor/dashboard/menu/forms.py
index 0b5eef65bed..43c48e8670e 100644
--- a/saleor/dashboard/menu/forms.py
+++ b/saleor/dashboard/menu/forms.py
@@ -3,11 +3,11 @@
 from django.utils.translation import pgettext_lazy
 
 from ...menu.models import Menu, MenuItem
+from ...menu.utils import update_menu_item_linked_object
 from ...page.models import Page
 from ...product.models import Category, Collection
 from ...site.models import SiteSettings
 from ..forms import AjaxSelect2CombinedChoiceField, OrderedModelMultipleChoiceField
-from .utils import update_menu_item_linked_object
 
 
 class AssignMenuForm(forms.ModelForm):
diff --git a/saleor/dashboard/menu/utils.py b/saleor/dashboard/menu/utils.py
index 8bf74010e4c..12ab9331d8e 100644
--- a/saleor/dashboard/menu/utils.py
+++ b/saleor/dashboard/menu/utils.py
@@ -1,106 +1,7 @@
-from django.db.models import Q
 from django.utils.formats import localize
 from django.utils.translation import pgettext
 
-from ...menu.models import Menu, MenuItem
 from ...page.models import Page
-from ...product.models import Category, Collection
-
-
-def update_menu_item_linked_object(menu_item, linked_object):
-    """Assign new linked object to a menu item. Clear other links."""
-    menu_item.category = None
-    menu_item.collection = None
-    menu_item.page = None
-
-    if isinstance(linked_object, Category):
-        menu_item.category = linked_object
-    elif isinstance(linked_object, Collection):
-        menu_item.collection = linked_object
-    elif isinstance(linked_object, Page):
-        menu_item.page = linked_object
-
-    return menu_item.save()
-
-
-def get_menu_item_as_dict(menu_item):
-    data = {}
-    if menu_item.linked_object:
-        data["url"] = menu_item.linked_object.get_absolute_url()
-    else:
-        data["url"] = menu_item.url
-    data["name"] = menu_item.name
-    data["translations"] = {
-        translated.language_code: {"name": translated.name}
-        for translated in menu_item.translations.all()
-    }
-    return data
-
-
-def get_menu_as_json(menu):
-    """Builds Tree-like structure from top menu items,
-    its children and its grandchildren.
-    """
-    top_items = menu.items.filter(parent=None).prefetch_related(
-        "category",
-        "page",
-        "collection",
-        "children__category",
-        "children__page",
-        "children__collection",
-        "children__children__category",
-        "children__children__page",
-        "children__children__collection",
-        "translations",
-        "children__translations",
-        "children__children__translations",
-    )
-    menu_data = []
-    for item in top_items:
-        top_item_data = get_menu_item_as_dict(item)
-        top_item_data["child_items"] = []
-        children = item.children.all()
-        for child in children:
-            child_data = get_menu_item_as_dict(child)
-            grand_children = child.children.all()
-            grand_children_data = [
-                get_menu_item_as_dict(grand_child) for grand_child in grand_children
-            ]
-            child_data["child_items"] = grand_children_data
-            top_item_data["child_items"].append(child_data)
-        menu_data.append(top_item_data)
-    return menu_data
-
-
-def update_menus(menus_pk):
-    menus = Menu.objects.filter(pk__in=menus_pk)
-    for menu in menus:
-        update_menu(menu)
-
-
-def update_menu(menu):
-    menu.json_content = get_menu_as_json(menu)
-    menu.save(update_fields=["json_content"])
-
-
-def get_menus_that_needs_update(collection=None, categories=None, page=None):
-    """Returns PrimaryKeys of Menu instances that will be affected by
-    deleting one of the listed objects, therefore needs to be updated
-    afterwards.
-    """
-    if not any([page, collection, categories]):
-        return []
-    q = Q()
-    if collection is not None:
-        q |= Q(collection=collection)
-    if categories is not None:
-        q |= Q(category__in=categories)
-    if page is not None:
-        q |= Q(page=page)
-    menus_to_be_updated = (
-        MenuItem.objects.filter(q).distinct().values_list("menu", flat=True)
-    )
-    return menus_to_be_updated
 
 
 def get_menu_obj_text(obj):
diff --git a/saleor/dashboard/menu/views.py b/saleor/dashboard/menu/views.py
index 815ca3ccceb..04975d58501 100644
--- a/saleor/dashboard/menu/views.py
+++ b/saleor/dashboard/menu/views.py
@@ -10,12 +10,13 @@
 
 from ...core.utils import get_paginator_items
 from ...menu.models import Menu, MenuItem
+from ...menu.utils import update_menu
 from ...page.models import Page
 from ...product.models import Category, Collection
 from ..views import staff_member_required
 from .filters import MenuFilter, MenuItemFilter
 from .forms import AssignMenuForm, MenuForm, MenuItemForm, ReorderMenuItemsForm
-from .utils import get_menu_obj_text, update_menu
+from .utils import get_menu_obj_text
 
 
 @staff_member_required
diff --git a/saleor/dashboard/page/views.py b/saleor/dashboard/page/views.py
index cb840a927e0..1db89c6e71a 100644
--- a/saleor/dashboard/page/views.py
+++ b/saleor/dashboard/page/views.py
@@ -6,8 +6,8 @@
 from django.utils.translation import pgettext_lazy
 
 from ...core.utils import get_paginator_items
+from ...menu.utils import get_menus_that_needs_update, update_menus
 from ...page.models import Page
-from ..menu.utils import get_menus_that_needs_update, update_menus
 from ..views import staff_member_required
 from .filters import PageFilter
 from .forms import PageForm
diff --git a/saleor/graphql/menu/bulk_mutations.py b/saleor/graphql/menu/bulk_mutations.py
index ecd820d40f5..f4736c21b0b 100644
--- a/saleor/graphql/menu/bulk_mutations.py
+++ b/saleor/graphql/menu/bulk_mutations.py
@@ -1,6 +1,7 @@
 import graphene
 
 from ...menu import models
+from ...menu.utils import update_menus
 from ..core.mutations import ModelBulkDeleteMutation
 
 
@@ -26,3 +27,9 @@ class Meta:
         description = "Deletes menu items."
         model = models.MenuItem
         permissions = ("menu.manage_menus",)
+
+    @classmethod
+    def bulk_action(cls, queryset):
+        menu_ids = set([item.menu_id for item in queryset])
+        queryset.delete()
+        update_menus(menu_ids)
diff --git a/saleor/graphql/menu/mutations.py b/saleor/graphql/menu/mutations.py
index 71ecf43ebdd..fe23fcc46e2 100644
--- a/saleor/graphql/menu/mutations.py
+++ b/saleor/graphql/menu/mutations.py
@@ -3,9 +3,13 @@
 
 import graphene
 from django.core.exceptions import ValidationError
+from django.db.models import Model
 from graphql_relay import from_global_id
 
 from ...menu import models
+from ...menu.utils import update_menu
+from ...page import models as page_models
+from ...product import models as product_models
 from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
 from ..page.types import Page
 from ..product.types import Category, Collection
@@ -26,6 +30,7 @@ class MenuItemInput(graphene.InputObjectType):
 
 
 class MenuItemCreateInput(MenuItemInput):
+    name = graphene.String(description="Name of the menu item.", required=True)
     menu = graphene.ID(
         description="Menu to which item belongs to.", name="menu", required=True
     )
@@ -41,7 +46,8 @@ class MenuInput(graphene.InputObjectType):
     name = graphene.String(description="Name of the menu.")
 
 
-class MenuCreateInput(MenuInput):
+class MenuCreateInput(graphene.InputObjectType):
+    name = graphene.String(description="Name of the menu.", required=True)
     items = graphene.List(MenuItemInput, description="List of menu items.")
 
 
@@ -119,6 +125,20 @@ class Meta:
         permissions = ("menu.manage_menus",)
 
 
+def _validate_menu_item_instance(
+    cleaned_input: dict, field: str, expected_model: Model
+):
+    """Check if the value to assign as a menu item matches the expected model."""
+    if field in cleaned_input:
+        item = cleaned_input[field]
+        if not isinstance(item, expected_model):
+            msg = (
+                f"Enter a valid {expected_model._meta.verbose_name} ID "
+                f"(got {item._meta.verbose_name} ID)."
+            )
+            raise ValidationError({field: msg})
+
+
 class MenuItemCreate(ModelMutation):
     class Arguments:
         input = MenuItemCreateInput(
@@ -129,13 +149,20 @@ class Arguments:
         )
 
     class Meta:
-        description = "Creates a new Menu"
+        description = "Creates a new menu item."
         model = models.MenuItem
         permissions = ("menu.manage_menus",)
 
     @classmethod
     def clean_input(cls, info, instance, data):
         cleaned_input = super().clean_input(info, instance, data)
+
+        _validate_menu_item_instance(cleaned_input, "page", page_models.Page)
+        _validate_menu_item_instance(
+            cleaned_input, "collection", product_models.Collection
+        )
+        _validate_menu_item_instance(cleaned_input, "category", product_models.Category)
+
         items = [
             cleaned_input.get("page"),
             cleaned_input.get("collection"),
@@ -144,9 +171,14 @@ def clean_input(cls, info, instance, data):
         ]
         items = [item for item in items if item is not None]
         if len(items) > 1:
-            raise ValidationError({"items": "More than one item provided."})
+            raise ValidationError("More than one item provided.")
         return cleaned_input
 
+    @classmethod
+    def save(cls, info, instance, cleaned_input):
+        instance.save()
+        update_menu(instance.menu)
+
 
 class MenuItemUpdate(MenuItemCreate):
     class Arguments:
@@ -182,6 +214,12 @@ class Meta:
         model = models.MenuItem
         permissions = ("menu.manage_menus",)
 
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        response = super().perform_mutation(_root, info, **data)
+        update_menu(response.menuItem.menu)
+        return response
+
 
 _MenuMoveOperation = namedtuple(
     "_MenuMoveOperation", ("menu_item", "parent", "sort_order")
@@ -281,7 +319,9 @@ def perform_mutation(cls, _root, info, menu, moves):
         for operation in operations:
             cls.perform_operation(operation)
 
-        return cls(menu=models.Menu.objects.get(pk=menu_id))
+        menu = models.Menu.objects.get(pk=menu_id)
+        update_menu(menu)
+        return cls(menu=menu)
 
 
 class AssignNavigation(BaseMutation):
diff --git a/saleor/menu/utils.py b/saleor/menu/utils.py
new file mode 100644
index 00000000000..365c22aa5b9
--- /dev/null
+++ b/saleor/menu/utils.py
@@ -0,0 +1,101 @@
+from django.db.models import Q
+
+from ..menu.models import Menu, MenuItem
+from ..page.models import Page
+from ..product.models import Category, Collection
+
+
+def update_menu_item_linked_object(menu_item, linked_object):
+    """Assign new linked object to a menu item. Clear other links."""
+    menu_item.category = None
+    menu_item.collection = None
+    menu_item.page = None
+
+    if isinstance(linked_object, Category):
+        menu_item.category = linked_object
+    elif isinstance(linked_object, Collection):
+        menu_item.collection = linked_object
+    elif isinstance(linked_object, Page):
+        menu_item.page = linked_object
+
+    return menu_item.save()
+
+
+def get_menus_that_needs_update(collection=None, categories=None, page=None):
+    """Returns PrimaryKeys of Menu instances that will be affected by
+    deleting one of the listed objects, therefore needs to be updated
+    afterwards.
+    """
+    if not any([page, collection, categories]):
+        return []
+    q = Q()
+    if collection is not None:
+        q |= Q(collection=collection)
+    if categories is not None:
+        q |= Q(category__in=categories)
+    if page is not None:
+        q |= Q(page=page)
+    menus_to_be_updated = (
+        MenuItem.objects.filter(q).distinct().values_list("menu", flat=True)
+    )
+    return menus_to_be_updated
+
+
+def get_menu_item_as_dict(menu_item):
+    data = {}
+    if menu_item.linked_object:
+        data["url"] = menu_item.linked_object.get_absolute_url()
+    else:
+        data["url"] = menu_item.url
+    data["name"] = menu_item.name
+    data["translations"] = {
+        translated.language_code: {"name": translated.name}
+        for translated in menu_item.translations.all()
+    }
+    return data
+
+
+def get_menu_as_json(menu):
+    """Builds Tree-like structure from top menu items,
+    its children and its grandchildren.
+    """
+    top_items = menu.items.filter(parent=None).prefetch_related(
+        "category",
+        "page",
+        "collection",
+        "children__category",
+        "children__page",
+        "children__collection",
+        "children__children__category",
+        "children__children__page",
+        "children__children__collection",
+        "translations",
+        "children__translations",
+        "children__children__translations",
+    )
+    menu_data = []
+    for item in top_items:
+        top_item_data = get_menu_item_as_dict(item)
+        top_item_data["child_items"] = []
+        children = item.children.all()
+        for child in children:
+            child_data = get_menu_item_as_dict(child)
+            grand_children = child.children.all()
+            grand_children_data = [
+                get_menu_item_as_dict(grand_child) for grand_child in grand_children
+            ]
+            child_data["child_items"] = grand_children_data
+            top_item_data["child_items"].append(child_data)
+        menu_data.append(top_item_data)
+    return menu_data
+
+
+def update_menus(menus_pk):
+    menus = Menu.objects.filter(pk__in=menus_pk)
+    for menu in menus:
+        update_menu(menu)
+
+
+def update_menu(menu):
+    menu.json_content = get_menu_as_json(menu)
+    menu.save(update_fields=["json_content"])
diff --git a/tests/api/test_bulk_delete.py b/tests/api/test_bulk_delete.py
index 0c192b49fa8..93452760c2c 100644
--- a/tests/api/test_bulk_delete.py
+++ b/tests/api/test_bulk_delete.py
@@ -20,7 +20,7 @@
 )
 from saleor.shipping.models import ShippingMethod, ShippingZone
 
-from .utils import get_graphql_content
+from .utils import get_graphql_content, menu_item_to_json
 
 MUTATION_DELETE_ORDER_LINES = """
     mutation draftOrderLinesBulkDelete($ids: [ID]!) {
@@ -392,6 +392,9 @@ def test_delete_menu_items(staff_api_client, menu_item_list, permission_manage_m
         }
     }
     """
+    menu = menu_item_list[0].menu
+    items_json = [menu_item_to_json(item) for item in menu_item_list]
+
     variables = {
         "ids": [
             graphene.Node.to_global_id("MenuItem", menu_item.id)
@@ -408,6 +411,10 @@ def test_delete_menu_items(staff_api_client, menu_item_list, permission_manage_m
         id__in=[menu_item.id for menu_item in menu_item_list]
     ).exists()
 
+    menu.refresh_from_db()
+    for item_json in items_json:
+        assert item_json not in menu.json_content
+
 
 def test_delete_empty_list_of_ids(staff_api_client, permission_manage_menus):
     query = """
diff --git a/tests/api/test_menu.py b/tests/api/test_menu.py
index 68047da2748..311415a7652 100644
--- a/tests/api/test_menu.py
+++ b/tests/api/test_menu.py
@@ -4,13 +4,21 @@
 
 import graphene
 import pytest
+from django.core.exceptions import ValidationError
 from graphql_relay import from_global_id
 
-from saleor.graphql.menu.mutations import NavigationType
+from saleor.graphql.menu.mutations import NavigationType, _validate_menu_item_instance
 from saleor.menu.models import Menu, MenuItem
+from saleor.product.models import Category
 from tests.api.utils import get_graphql_content
 
-from .utils import assert_no_permission
+from .utils import assert_no_permission, menu_item_to_json
+
+
+def test_validate_menu_item_instance(category, page):
+    _validate_menu_item_instance({"category": category}, "category", Category)
+    with pytest.raises(ValidationError):
+        _validate_menu_item_instance({"category": page}, "category", Category)
 
 
 def test_menu_query(user_api_client, menu):
@@ -257,6 +265,11 @@ def test_create_menu_item(staff_api_client, menu, permission_manage_menus):
     assert data["url"] == url
     assert data["menu"]["name"] == menu.name
 
+    menu.refresh_from_db()
+    item = menu.items.get(name=name)
+    item_json = menu_item_to_json(item)
+    assert item_json in menu.json_content
+
 
 def test_update_menu_item(
     staff_api_client, menu, menu_item, page, permission_manage_menus
@@ -285,6 +298,11 @@ def test_update_menu_item(
     data = content["data"]["menuItemUpdate"]["menuItem"]
     assert data["page"]["id"] == page_id
 
+    menu_item.refresh_from_db()
+    menu.refresh_from_db()
+    item_json = menu_item_to_json(menu_item)
+    assert item_json in menu.json_content
+
 
 def test_delete_menu_item(staff_api_client, menu_item, permission_manage_menus):
     query = """
@@ -296,6 +314,11 @@ def test_delete_menu_item(staff_api_client, menu_item, permission_manage_menus):
             }
         }
         """
+    menu = menu_item.menu
+    item_json = menu_item_to_json(menu_item)
+    menu_json = menu.json_content
+    assert item_json in menu_json
+
     menu_item_id = graphene.Node.to_global_id("MenuItem", menu_item.pk)
     variables = {"id": menu_item_id}
     response = staff_api_client.post_graphql(
@@ -307,6 +330,9 @@ def test_delete_menu_item(staff_api_client, menu_item, permission_manage_menus):
     with pytest.raises(menu_item._meta.model.DoesNotExist):
         menu_item.refresh_from_db()
 
+    menu.refresh_from_db()
+    assert item_json not in menu.json_content
+
 
 def test_add_more_than_one_item(
     staff_api_client, menu, menu_item, page, permission_manage_menus
diff --git a/tests/api/utils.py b/tests/api/utils.py
index 5a75608d505..dff86a18d4a 100644
--- a/tests/api/utils.py
+++ b/tests/api/utils.py
@@ -3,6 +3,7 @@
 from django.core.serializers.json import DjangoJSONEncoder
 
 from saleor.graphql.core.utils import snake_to_camel_case
+from saleor.menu.utils import get_menu_item_as_dict
 
 
 def _get_graphql_content_from_response(response):
@@ -53,3 +54,10 @@ def convert_dict_keys_to_camel_case(d):
         new_key = snake_to_camel_case(k)
         data[new_key] = d[k]
     return data
+
+
+def menu_item_to_json(menu_item):
+    """Transforms a menu item to a JSON representation as used in the storefront."""
+    item_json = get_menu_item_as_dict(menu_item)
+    item_json["child_items"] = []
+    return item_json
diff --git a/tests/conftest.py b/tests/conftest.py
index 925e6adcd9f..f1dc98b237e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -20,11 +20,11 @@
 from saleor.checkout import utils
 from saleor.checkout.models import Checkout
 from saleor.checkout.utils import add_variant_to_checkout
-from saleor.dashboard.menu.utils import update_menu
 from saleor.discount import DiscountInfo, DiscountValueType, VoucherType
 from saleor.discount.models import Sale, Voucher, VoucherCustomer, VoucherTranslation
 from saleor.giftcard.models import GiftCard
 from saleor.menu.models import Menu, MenuItem
+from saleor.menu.utils import update_menu
 from saleor.order import OrderStatus
 from saleor.order.events import OrderEvents
 from saleor.order.models import FulfillmentStatus, Order, OrderEvent
@@ -1111,7 +1111,9 @@ def menu(db):
 
 @pytest.fixture
 def menu_item(menu):
-    return MenuItem.objects.create(menu=menu, name="Link 1", url="http://example.com/")
+    item = MenuItem.objects.create(menu=menu, name="Link 1", url="http://example.com/")
+    update_menu(menu)
+    return item
 
 
 @pytest.fixture
@@ -1119,6 +1121,7 @@ def menu_item_list(menu):
     menu_item_1 = MenuItem.objects.create(menu=menu, name="Link 1")
     menu_item_2 = MenuItem.objects.create(menu=menu, name="Link 2")
     menu_item_3 = MenuItem.objects.create(menu=menu, name="Link 3")
+    update_menu(menu)
     return menu_item_1, menu_item_2, menu_item_3
 
 
diff --git a/tests/dashboard/test_menu.py b/tests/dashboard/test_menu.py
index f746fc514b7..b6084453d07 100644
--- a/tests/dashboard/test_menu.py
+++ b/tests/dashboard/test_menu.py
@@ -7,7 +7,8 @@
 from django.utils.formats import localize
 
 from saleor.dashboard.menu.forms import AssignMenuForm
-from saleor.dashboard.menu.utils import (
+from saleor.menu.models import Menu, MenuItem, MenuItemTranslation
+from saleor.menu.utils import (
     get_menu_as_json,
     get_menu_item_as_dict,
     get_menus_that_needs_update,
@@ -15,7 +16,6 @@
     update_menu_item_linked_object,
     update_menus,
 )
-from saleor.menu.models import Menu, MenuItem, MenuItemTranslation
 
 from ..utils import get_redirect_location
 
@@ -357,13 +357,13 @@ def test_get_menu_as_json(menu):
     assert proper_data == get_menu_as_json(menu)
 
 
-@mock.patch("saleor.dashboard.menu.utils.update_menu")
+@mock.patch("saleor.menu.utils.update_menu")
 def test_update_menus(mock_update_menu, menu):
     update_menus([menu.pk])
     mock_update_menu.assert_called_once_with(menu)
 
 
-@mock.patch("saleor.dashboard.menu.utils.get_menu_as_json")
+@mock.patch("saleor.menu.utils.get_menu_as_json")
 def test_update_menu(mock_json_menu, menu):
     mock_json_menu.return_value = "Return value"
     update_menu(menu)
diff --git a/tests/test_product.py b/tests/test_product.py
index 73530cc0dd7..00a5b35bbeb 100644
--- a/tests/test_product.py
+++ b/tests/test_product.py
@@ -17,8 +17,8 @@
 from saleor.checkout import utils
 from saleor.checkout.models import Checkout
 from saleor.checkout.utils import add_variant_to_checkout
-from saleor.dashboard.menu.utils import update_menu
 from saleor.menu.models import MenuItemTranslation
+from saleor.menu.utils import update_menu
 from saleor.product import ProductAvailabilityStatus, models
 from saleor.product.models import DigitalContentUrl
 from saleor.product.thumbnails import create_product_thumbnails


Code review comments:
diff_hunk: @@ -26,3 +27,9 @@ class Meta:
         description = "Deletes menu items."
         model = models.MenuItem
         permissions = ("menu.manage_menus",)
+
+    @classmethod
+    def bulk_action(cls, queryset):
+        menu_ids = set([item.menu_id for item in queryset])
path: saleor/graphql/menu/bulk_mutations.py
text: @user1:
What about directly creating a set?

```suggestion
        menu_ids = {item.menu_id for item in queryset}
```