We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
SnippetBulkAction not respecting models definition
<!--
Found a bug? Please fill out the sections below. 👍
-->

### Issue Summary

I'm registering a bulk action for a snippet model and declared the bulk action as specific for a model with class variable `models`, but the bulk action is being showed on all snippet models and not only the one I've declared.

### Steps to Reproduce

1. Declare on `wagtail_hooks.py` the snippet:
    ```python
    class PeriodicTaskSnippetViewSet(SnippetViewSet):
        model = PeriodicTask
        icon = "tasks"
        menu_order = 100
        list_display = ("name", "enabled", "scheduler", "interval", "start_time", "last_run_at", "one_off")
        list_filter = ["enabled", "one_off", "task", "start_time", "last_run_at"]
        search_fields = ["name"]
    ```
1. Also declare the bulk action:
    ```python
    @hooks.register("register_bulk_action")
    class EnableTaskBulkAction(SnippetBulkAction):
        models = [PeriodicTask]
        display_name = _("Enable")
        aria_label = _("Enable selected tasks")
        action_type = "enable"
        template_name = "core/wagtailadmin/bulk_action_enable_tasks.html"

        @classmethod
        def execute_action(cls, objects, **kwargs):
            for obj in objects:
                obj.enabled = True
                obj.save()
            rows_updated = len(objects)
            return rows_updated, rows_updated
    ```

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

The documentation (https://docs.wagtail.org/en/stable/extending/custom_bulk_actions.html#adding-bulk-actions-to-the-snippets-listing) says how to limit action to specific models on snippets, but that's not happening.

After checking the code, I think that because `get_models` is being overwritten on `SnippetBulkAction` which ignores if `models` is already defined by the user or not. 

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes 

### Technical details

-   Python version: 3.11.5
-   Django version: 4.2.5
-   Wagtail version: 5.1.2.
-   Browser version: Chrome 117.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `wagtail/snippets/bulk_actions/snippet_bulk_action.py`
Content:
```
1 from wagtail.admin.admin_url_finder import AdminURLFinder
2 from wagtail.admin.views.bulk_action import BulkAction
3 from wagtail.snippets.models import get_snippet_models
4 
5 
6 class SnippetBulkAction(BulkAction):
7     @classmethod
8     def get_models(cls):
9         # We used to set `models = get_snippet_models()` directly on the class,
10         # but this is problematic because it means that the list of models is
11         # evaluated at import time.
12 
13         # Bulk actions are normally registered in wagtail_hooks.py, but snippets
14         # can also be registered in wagtail_hooks.py. Evaluating
15         # get_snippet_models() at import time could result in either a circular
16         # import or an incomplete list of models.
17 
18         # Update the models list with the latest registered snippets in case
19         # there is user code that still accesses cls.models instead of calling
20         # this get_models() method.
21         cls.models = get_snippet_models()
22         return cls.models
23 
24     def object_context(self, snippet):
25         return {
26             "item": snippet,
27             "edit_url": AdminURLFinder(self.request.user).get_edit_url(snippet),
28         }
29 
30     def get_context_data(self, **kwargs):
31         kwargs.update(
32             {
33                 "model_opts": self.model._meta,
34                 "header_icon": self.model.snippet_viewset.icon,
35             }
36         )
37         return super().get_context_data(**kwargs)
38 
39     def get_execution_context(self):
40         return {**super().get_execution_context(), "self": self}
41
```

Path: `wagtail/admin/views/bulk_action/registry.py`
Content:
```
1 from wagtail import hooks
2 from wagtail.admin.views.bulk_action import BulkAction
3 
4 
5 class BulkActionRegistry:
6     def __init__(self):
7         self.actions = {}  # {app_name: {model_name: {action_name: action_class]}}
8         self.has_scanned_for_bulk_actions = False
9 
10     def _scan_for_bulk_actions(self):
11         if not self.has_scanned_for_bulk_actions:
12             for action_class in hooks.get_hooks("register_bulk_action"):
13                 if not issubclass(action_class, BulkAction):
14                     raise Exception(
15                         "{} is not a subclass of {}".format(
16                             action_class.__name__, BulkAction.__name__
17                         )
18                     )
19                 for model in action_class.get_models():
20                     self.actions.setdefault(model._meta.app_label, {})
21                     self.actions[model._meta.app_label].setdefault(
22                         model._meta.model_name, {}
23                     )
24                     self.actions[model._meta.app_label][model._meta.model_name][
25                         action_class.action_type
26                     ] = action_class
27             self.has_scanned_for_bulk_actions = True
28 
29     def get_bulk_actions_for_model(self, app_label, model_name):
30         self._scan_for_bulk_actions()
31         return self.actions.get(app_label, {}).get(model_name, {}).values()
32 
33     def get_bulk_action_class(self, app_label, model_name, action_type):
34         self._scan_for_bulk_actions()
35         return (
36             self.actions.get(app_label, {}).get(model_name, {}).get(action_type, None)
37         )
38 
39 
40 bulk_action_registry = BulkActionRegistry()
41
```

Path: `wagtail/admin/views/bulk_action/base_bulk_action.py`
Content:
```
1 from abc import ABC, abstractmethod
2 
3 from django import forms
4 from django.db import transaction
5 from django.shortcuts import get_list_or_404, redirect
6 from django.views.generic import FormView
7 
8 from wagtail import hooks
9 from wagtail.admin import messages
10 from wagtail.admin.utils import get_valid_next_url_from_request
11 
12 
13 class BulkAction(ABC, FormView):
14     @property
15     @abstractmethod
16     def display_name(self):
17         pass
18 
19     @property
20     @abstractmethod
21     def action_type(self):
22         pass
23 
24     @property
25     @abstractmethod
26     def aria_label(self):
27         pass
28 
29     extras = {}
30     action_priority = 100
31     models = []
32     classes = set()
33 
34     form_class = forms.Form
35     cleaned_form = None
36 
37     def __init__(self, request, model):
38         self.request = request
39         next_url = get_valid_next_url_from_request(request)
40         if not next_url:
41             next_url = request.path
42         self.next_url = next_url
43         self.num_parent_objects = self.num_child_objects = 0
44         if model in self.get_models():
45             self.model = model
46         else:
47             raise Exception(
48                 "model {} is not among the specified list of models".format(
49                     model.__class__.__name__
50                 )
51             )
52 
53     @classmethod
54     def get_models(cls):
55         return cls.models
56 
57     @classmethod
58     def get_queryset(cls, model, object_ids):
59         return get_list_or_404(model, pk__in=object_ids)
60 
61     def check_perm(self, obj):
62         return True
63 
64     @classmethod
65     def execute_action(cls, objects, **kwargs):
66         raise NotImplementedError("execute_action needs to be implemented")
67 
68     def get_success_message(self, num_parent_objects, num_child_objects):
69         pass
70 
71     def object_context(self, obj):
72         return {"item": obj}
73 
74     @classmethod
75     def get_default_model(cls):
76         models = cls.get_models()
77         if len(models) == 1:
78             return models[0]
79         raise Exception(
80             "Cannot get default model if number of models is greater than 1"
81         )
82 
83     def __run_before_hooks(self, action_type, request, objects):
84         for hook in hooks.get_hooks("before_bulk_action"):
85             result = hook(request, action_type, objects, self)
86             if hasattr(result, "status_code"):
87                 return result
88 
89     def __run_after_hooks(self, action_type, request, objects):
90         for hook in hooks.get_hooks("after_bulk_action"):
91             result = hook(request, action_type, objects, self)
92             if hasattr(result, "status_code"):
93                 return result
94 
95     def get_all_objects_in_listing_query(self, parent_id):
96         return self.model.objects.all().values_list("pk", flat=True)
97 
98     def get_actionable_objects(self):
99         objects = []
100         items_with_no_access = []
101         object_ids = self.request.GET.getlist("id")
102         if "all" in object_ids:
103             object_ids = self.get_all_objects_in_listing_query(
104                 self.request.GET.get("childOf")
105             )
106 
107         for obj in self.get_queryset(self.model, object_ids):
108             if not self.check_perm(obj):
109                 items_with_no_access.append(obj)
110             else:
111                 objects.append(obj)
112         return objects, {"items_with_no_access": items_with_no_access}
113 
114     def get_context_data(self, **kwargs):
115         items, items_with_no_access = self.get_actionable_objects()
116         _items = []
117         for item in items:
118             _items.append(self.object_context(item))
119         return {
120             **super().get_context_data(**kwargs),
121             "items": _items,
122             **items_with_no_access,
123             "next": self.next_url,
124             "submit_url": self.request.path + "?" + self.request.META["QUERY_STRING"],
125         }
126 
127     def prepare_action(self, objects, objects_without_access):
128         return
129 
130     def get_execution_context(self):
131         return {}
132 
133     def form_valid(self, form):
134         request = self.request
135         self.cleaned_form = form
136         objects, objects_without_access = self.get_actionable_objects()
137         self.actionable_objects = objects
138         resp = self.prepare_action(objects, objects_without_access)
139         if hasattr(resp, "status_code"):
140             return resp
141         with transaction.atomic():
142             before_hook_result = self.__run_before_hooks(
143                 self.action_type, request, objects
144             )
145             if before_hook_result is not None:
146                 return before_hook_result
147             num_parent_objects, num_child_objects = self.execute_action(
148                 objects, **self.get_execution_context()
149             )
150             after_hook_result = self.__run_after_hooks(
151                 self.action_type, request, objects
152             )
153             if after_hook_result is not None:
154                 return after_hook_result
155             success_message = self.get_success_message(
156                 num_parent_objects, num_child_objects
157             )
158             if success_message is not None:
159                 messages.success(request, success_message)
160         return redirect(self.next_url)
161 
162     def form_invalid(self, form):
163         return super().form_invalid(form)
164
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/wagtail/admin/views/bulk_action/base_bulk_action.py b/wagtail/admin/views/bulk_action/base_bulk_action.py
--- a/wagtail/admin/views/bulk_action/base_bulk_action.py
+++ b/wagtail/admin/views/bulk_action/base_bulk_action.py
@@ -3,6 +3,7 @@
 from django import forms
 from django.db import transaction
 from django.shortcuts import get_list_or_404, redirect
+from django.utils.functional import classproperty
 from django.views.generic import FormView
 
 from wagtail import hooks
@@ -28,7 +29,6 @@
 
     extras = {}
     action_priority = 100
-    models = []
     classes = set()
 
     form_class = forms.Form
@@ -41,7 +41,7 @@
             next_url = request.path
         self.next_url = next_url
         self.num_parent_objects = self.num_child_objects = 0
-        if model in self.get_models():
+        if model in self.models:
             self.model = model
         else:
             raise Exception(
@@ -50,9 +50,9 @@
                 )
             )
 
-    @classmethod
-    def get_models(cls):
-        return cls.models
+    @classproperty
+    def models(cls):
+        return []
 
     @classmethod
     def get_queryset(cls, model, object_ids):
@@ -73,7 +73,7 @@
 
     @classmethod
     def get_default_model(cls):
-        models = cls.get_models()
+        models = cls.models
         if len(models) == 1:
             return models[0]
         raise Exception(
diff --git a/wagtail/admin/views/bulk_action/registry.py b/wagtail/admin/views/bulk_action/registry.py
--- a/wagtail/admin/views/bulk_action/registry.py
+++ b/wagtail/admin/views/bulk_action/registry.py
@@ -16,7 +16,7 @@
                             action_class.__name__, BulkAction.__name__
                         )
                     )
-                for model in action_class.get_models():
+                for model in action_class.models:
                     self.actions.setdefault(model._meta.app_label, {})
                     self.actions[model._meta.app_label].setdefault(
                         model._meta.model_name, {}
diff --git a/wagtail/snippets/bulk_actions/snippet_bulk_action.py b/wagtail/snippets/bulk_actions/snippet_bulk_action.py
--- a/wagtail/snippets/bulk_actions/snippet_bulk_action.py
+++ b/wagtail/snippets/bulk_actions/snippet_bulk_action.py
@@ -1,11 +1,13 @@
+from django.utils.functional import classproperty
+
 from wagtail.admin.admin_url_finder import AdminURLFinder
 from wagtail.admin.views.bulk_action import BulkAction
 from wagtail.snippets.models import get_snippet_models
 
 
 class SnippetBulkAction(BulkAction):
-    @classmethod
-    def get_models(cls):
+    @classproperty
+    def models(cls):
         # We used to set `models = get_snippet_models()` directly on the class,
         # but this is problematic because it means that the list of models is
         # evaluated at import time.
@@ -14,12 +16,7 @@
         # can also be registered in wagtail_hooks.py. Evaluating
         # get_snippet_models() at import time could result in either a circular
         # import or an incomplete list of models.
-
-        # Update the models list with the latest registered snippets in case
-        # there is user code that still accesses cls.models instead of calling
-        # this get_models() method.
-        cls.models = get_snippet_models()
-        return cls.models
+        return get_snippet_models()
 
     def object_context(self, snippet):
         return {

{"golden_diff": "diff --git a/wagtail/admin/views/bulk_action/base_bulk_action.py b/wagtail/admin/views/bulk_action/base_bulk_action.py\n--- a/wagtail/admin/views/bulk_action/base_bulk_action.py\n+++ b/wagtail/admin/views/bulk_action/base_bulk_action.py\n@@ -3,6 +3,7 @@\n from django import forms\n from django.db import transaction\n from django.shortcuts import get_list_or_404, redirect\n+from django.utils.functional import classproperty\n from django.views.generic import FormView\n \n from wagtail import hooks\n@@ -28,7 +29,6 @@\n \n     extras = {}\n     action_priority = 100\n-    models = []\n     classes = set()\n \n     form_class = forms.Form\n@@ -41,7 +41,7 @@\n             next_url = request.path\n         self.next_url = next_url\n         self.num_parent_objects = self.num_child_objects = 0\n-        if model in self.get_models():\n+        if model in self.models:\n             self.model = model\n         else:\n             raise Exception(\n@@ -50,9 +50,9 @@\n                 )\n             )\n \n-    @classmethod\n-    def get_models(cls):\n-        return cls.models\n+    @classproperty\n+    def models(cls):\n+        return []\n \n     @classmethod\n     def get_queryset(cls, model, object_ids):\n@@ -73,7 +73,7 @@\n \n     @classmethod\n     def get_default_model(cls):\n-        models = cls.get_models()\n+        models = cls.models\n         if len(models) == 1:\n             return models[0]\n         raise Exception(\ndiff --git a/wagtail/admin/views/bulk_action/registry.py b/wagtail/admin/views/bulk_action/registry.py\n--- a/wagtail/admin/views/bulk_action/registry.py\n+++ b/wagtail/admin/views/bulk_action/registry.py\n@@ -16,7 +16,7 @@\n                             action_class.__name__, BulkAction.__name__\n                         )\n                     )\n-                for model in action_class.get_models():\n+                for model in action_class.models:\n                     self.actions.setdefault(model._meta.app_label, {})\n                     self.actions[model._meta.app_label].setdefault(\n                         model._meta.model_name, {}\ndiff --git a/wagtail/snippets/bulk_actions/snippet_bulk_action.py b/wagtail/snippets/bulk_actions/snippet_bulk_action.py\n--- a/wagtail/snippets/bulk_actions/snippet_bulk_action.py\n+++ b/wagtail/snippets/bulk_actions/snippet_bulk_action.py\n@@ -1,11 +1,13 @@\n+from django.utils.functional import classproperty\n+\n from wagtail.admin.admin_url_finder import AdminURLFinder\n from wagtail.admin.views.bulk_action import BulkAction\n from wagtail.snippets.models import get_snippet_models\n \n \n class SnippetBulkAction(BulkAction):\n-    @classmethod\n-    def get_models(cls):\n+    @classproperty\n+    def models(cls):\n         # We used to set `models = get_snippet_models()` directly on the class,\n         # but this is problematic because it means that the list of models is\n         # evaluated at import time.\n@@ -14,12 +16,7 @@\n         # can also be registered in wagtail_hooks.py. Evaluating\n         # get_snippet_models() at import time could result in either a circular\n         # import or an incomplete list of models.\n-\n-        # Update the models list with the latest registered snippets in case\n-        # there is user code that still accesses cls.models instead of calling\n-        # this get_models() method.\n-        cls.models = get_snippet_models()\n-        return cls.models\n+        return get_snippet_models()\n \n     def object_context(self, snippet):\n         return {\n", "issue": "SnippetBulkAction not respecting models definition\n<!--\r\nFound a bug? Please fill out the sections below. \ud83d\udc4d\r\n-->\r\n\r\n### Issue Summary\r\n\r\nI'm registering a bulk action for a snippet model and declared the bulk action as specific for a model with class variable `models`, but the bulk action is being showed on all snippet models and not only the one I've declared.\r\n\r\n### Steps to Reproduce\r\n\r\n1. Declare on `wagtail_hooks.py` the snippet:\r\n    ```python\r\n    class PeriodicTaskSnippetViewSet(SnippetViewSet):\r\n        model = PeriodicTask\r\n        icon = \"tasks\"\r\n        menu_order = 100\r\n        list_display = (\"name\", \"enabled\", \"scheduler\", \"interval\", \"start_time\", \"last_run_at\", \"one_off\")\r\n        list_filter = [\"enabled\", \"one_off\", \"task\", \"start_time\", \"last_run_at\"]\r\n        search_fields = [\"name\"]\r\n    ```\r\n1. Also declare the bulk action:\r\n    ```python\r\n    @hooks.register(\"register_bulk_action\")\r\n    class EnableTaskBulkAction(SnippetBulkAction):\r\n        models = [PeriodicTask]\r\n        display_name = _(\"Enable\")\r\n        aria_label = _(\"Enable selected tasks\")\r\n        action_type = \"enable\"\r\n        template_name = \"core/wagtailadmin/bulk_action_enable_tasks.html\"\r\n\r\n        @classmethod\r\n        def execute_action(cls, objects, **kwargs):\r\n            for obj in objects:\r\n                obj.enabled = True\r\n                obj.save()\r\n            rows_updated = len(objects)\r\n            return rows_updated, rows_updated\r\n    ```\r\n\r\nAny other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?\r\n\r\nThe documentation (https://docs.wagtail.org/en/stable/extending/custom_bulk_actions.html#adding-bulk-actions-to-the-snippets-listing) says how to limit action to specific models on snippets, but that's not happening.\r\n\r\nAfter checking the code, I think that because `get_models` is being overwritten on `SnippetBulkAction` which ignores if `models` is already defined by the user or not. \r\n\r\n-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes \r\n\r\n### Technical details\r\n\r\n-   Python version: 3.11.5\r\n-   Django version: 4.2.5\r\n-   Wagtail version: 5.1.2.\r\n-   Browser version: Chrome 117.\r\n\n", "before_files": [{"content": "from wagtail.admin.admin_url_finder import AdminURLFinder\nfrom wagtail.admin.views.bulk_action import BulkAction\nfrom wagtail.snippets.models import get_snippet_models\n\n\nclass SnippetBulkAction(BulkAction):\n    @classmethod\n    def get_models(cls):\n        # We used to set `models = get_snippet_models()` directly on the class,\n        # but this is problematic because it means that the list of models is\n        # evaluated at import time.\n\n        # Bulk actions are normally registered in wagtail_hooks.py, but snippets\n        # can also be registered in wagtail_hooks.py. Evaluating\n        # get_snippet_models() at import time could result in either a circular\n        # import or an incomplete list of models.\n\n        # Update the models list with the latest registered snippets in case\n        # there is user code that still accesses cls.models instead of calling\n        # this get_models() method.\n        cls.models = get_snippet_models()\n        return cls.models\n\n    def object_context(self, snippet):\n        return {\n            \"item\": snippet,\n            \"edit_url\": AdminURLFinder(self.request.user).get_edit_url(snippet),\n        }\n\n    def get_context_data(self, **kwargs):\n        kwargs.update(\n            {\n                \"model_opts\": self.model._meta,\n                \"header_icon\": self.model.snippet_viewset.icon,\n            }\n        )\n        return super().get_context_data(**kwargs)\n\n    def get_execution_context(self):\n        return {**super().get_execution_context(), \"self\": self}\n", "path": "wagtail/snippets/bulk_actions/snippet_bulk_action.py"}, {"content": "from wagtail import hooks\nfrom wagtail.admin.views.bulk_action import BulkAction\n\n\nclass BulkActionRegistry:\n    def __init__(self):\n        self.actions = {}  # {app_name: {model_name: {action_name: action_class]}}\n        self.has_scanned_for_bulk_actions = False\n\n    def _scan_for_bulk_actions(self):\n        if not self.has_scanned_for_bulk_actions:\n            for action_class in hooks.get_hooks(\"register_bulk_action\"):\n                if not issubclass(action_class, BulkAction):\n                    raise Exception(\n                        \"{} is not a subclass of {}\".format(\n                            action_class.__name__, BulkAction.__name__\n                        )\n                    )\n                for model in action_class.get_models():\n                    self.actions.setdefault(model._meta.app_label, {})\n                    self.actions[model._meta.app_label].setdefault(\n                        model._meta.model_name, {}\n                    )\n                    self.actions[model._meta.app_label][model._meta.model_name][\n                        action_class.action_type\n                    ] = action_class\n            self.has_scanned_for_bulk_actions = True\n\n    def get_bulk_actions_for_model(self, app_label, model_name):\n        self._scan_for_bulk_actions()\n        return self.actions.get(app_label, {}).get(model_name, {}).values()\n\n    def get_bulk_action_class(self, app_label, model_name, action_type):\n        self._scan_for_bulk_actions()\n        return (\n            self.actions.get(app_label, {}).get(model_name, {}).get(action_type, None)\n        )\n\n\nbulk_action_registry = BulkActionRegistry()\n", "path": "wagtail/admin/views/bulk_action/registry.py"}, {"content": "from abc import ABC, abstractmethod\n\nfrom django import forms\nfrom django.db import transaction\nfrom django.shortcuts import get_list_or_404, redirect\nfrom django.views.generic import FormView\n\nfrom wagtail import hooks\nfrom wagtail.admin import messages\nfrom wagtail.admin.utils import get_valid_next_url_from_request\n\n\nclass BulkAction(ABC, FormView):\n    @property\n    @abstractmethod\n    def display_name(self):\n        pass\n\n    @property\n    @abstractmethod\n    def action_type(self):\n        pass\n\n    @property\n    @abstractmethod\n    def aria_label(self):\n        pass\n\n    extras = {}\n    action_priority = 100\n    models = []\n    classes = set()\n\n    form_class = forms.Form\n    cleaned_form = None\n\n    def __init__(self, request, model):\n        self.request = request\n        next_url = get_valid_next_url_from_request(request)\n        if not next_url:\n            next_url = request.path\n        self.next_url = next_url\n        self.num_parent_objects = self.num_child_objects = 0\n        if model in self.get_models():\n            self.model = model\n        else:\n            raise Exception(\n                \"model {} is not among the specified list of models\".format(\n                    model.__class__.__name__\n                )\n            )\n\n    @classmethod\n    def get_models(cls):\n        return cls.models\n\n    @classmethod\n    def get_queryset(cls, model, object_ids):\n        return get_list_or_404(model, pk__in=object_ids)\n\n    def check_perm(self, obj):\n        return True\n\n    @classmethod\n    def execute_action(cls, objects, **kwargs):\n        raise NotImplementedError(\"execute_action needs to be implemented\")\n\n    def get_success_message(self, num_parent_objects, num_child_objects):\n        pass\n\n    def object_context(self, obj):\n        return {\"item\": obj}\n\n    @classmethod\n    def get_default_model(cls):\n        models = cls.get_models()\n        if len(models) == 1:\n            return models[0]\n        raise Exception(\n            \"Cannot get default model if number of models is greater than 1\"\n        )\n\n    def __run_before_hooks(self, action_type, request, objects):\n        for hook in hooks.get_hooks(\"before_bulk_action\"):\n            result = hook(request, action_type, objects, self)\n            if hasattr(result, \"status_code\"):\n                return result\n\n    def __run_after_hooks(self, action_type, request, objects):\n        for hook in hooks.get_hooks(\"after_bulk_action\"):\n            result = hook(request, action_type, objects, self)\n            if hasattr(result, \"status_code\"):\n                return result\n\n    def get_all_objects_in_listing_query(self, parent_id):\n        return self.model.objects.all().values_list(\"pk\", flat=True)\n\n    def get_actionable_objects(self):\n        objects = []\n        items_with_no_access = []\n        object_ids = self.request.GET.getlist(\"id\")\n        if \"all\" in object_ids:\n            object_ids = self.get_all_objects_in_listing_query(\n                self.request.GET.get(\"childOf\")\n            )\n\n        for obj in self.get_queryset(self.model, object_ids):\n            if not self.check_perm(obj):\n                items_with_no_access.append(obj)\n            else:\n                objects.append(obj)\n        return objects, {\"items_with_no_access\": items_with_no_access}\n\n    def get_context_data(self, **kwargs):\n        items, items_with_no_access = self.get_actionable_objects()\n        _items = []\n        for item in items:\n            _items.append(self.object_context(item))\n        return {\n            **super().get_context_data(**kwargs),\n            \"items\": _items,\n            **items_with_no_access,\n            \"next\": self.next_url,\n            \"submit_url\": self.request.path + \"?\" + self.request.META[\"QUERY_STRING\"],\n        }\n\n    def prepare_action(self, objects, objects_without_access):\n        return\n\n    def get_execution_context(self):\n        return {}\n\n    def form_valid(self, form):\n        request = self.request\n        self.cleaned_form = form\n        objects, objects_without_access = self.get_actionable_objects()\n        self.actionable_objects = objects\n        resp = self.prepare_action(objects, objects_without_access)\n        if hasattr(resp, \"status_code\"):\n            return resp\n        with transaction.atomic():\n            before_hook_result = self.__run_before_hooks(\n                self.action_type, request, objects\n            )\n            if before_hook_result is not None:\n                return before_hook_result\n            num_parent_objects, num_child_objects = self.execute_action(\n                objects, **self.get_execution_context()\n            )\n            after_hook_result = self.__run_after_hooks(\n                self.action_type, request, objects\n            )\n            if after_hook_result is not None:\n                return after_hook_result\n            success_message = self.get_success_message(\n                num_parent_objects, num_child_objects\n            )\n            if success_message is not None:\n                messages.success(request, success_message)\n        return redirect(self.next_url)\n\n    def form_invalid(self, form):\n        return super().form_invalid(form)\n", "path": "wagtail/admin/views/bulk_action/base_bulk_action.py"}], "after_files": [{"content": "from django.utils.functional import classproperty\n\nfrom wagtail.admin.admin_url_finder import AdminURLFinder\nfrom wagtail.admin.views.bulk_action import BulkAction\nfrom wagtail.snippets.models import get_snippet_models\n\n\nclass SnippetBulkAction(BulkAction):\n    @classproperty\n    def models(cls):\n        # We used to set `models = get_snippet_models()` directly on the class,\n        # but this is problematic because it means that the list of models is\n        # evaluated at import time.\n\n        # Bulk actions are normally registered in wagtail_hooks.py, but snippets\n        # can also be registered in wagtail_hooks.py. Evaluating\n        # get_snippet_models() at import time could result in either a circular\n        # import or an incomplete list of models.\n        return get_snippet_models()\n\n    def object_context(self, snippet):\n        return {\n            \"item\": snippet,\n            \"edit_url\": AdminURLFinder(self.request.user).get_edit_url(snippet),\n        }\n\n    def get_context_data(self, **kwargs):\n        kwargs.update(\n            {\n                \"model_opts\": self.model._meta,\n                \"header_icon\": self.model.snippet_viewset.icon,\n            }\n        )\n        return super().get_context_data(**kwargs)\n\n    def get_execution_context(self):\n        return {**super().get_execution_context(), \"self\": self}\n", "path": "wagtail/snippets/bulk_actions/snippet_bulk_action.py"}, {"content": "from wagtail import hooks\nfrom wagtail.admin.views.bulk_action import BulkAction\n\n\nclass BulkActionRegistry:\n    def __init__(self):\n        self.actions = {}  # {app_name: {model_name: {action_name: action_class]}}\n        self.has_scanned_for_bulk_actions = False\n\n    def _scan_for_bulk_actions(self):\n        if not self.has_scanned_for_bulk_actions:\n            for action_class in hooks.get_hooks(\"register_bulk_action\"):\n                if not issubclass(action_class, BulkAction):\n                    raise Exception(\n                        \"{} is not a subclass of {}\".format(\n                            action_class.__name__, BulkAction.__name__\n                        )\n                    )\n                for model in action_class.models:\n                    self.actions.setdefault(model._meta.app_label, {})\n                    self.actions[model._meta.app_label].setdefault(\n                        model._meta.model_name, {}\n                    )\n                    self.actions[model._meta.app_label][model._meta.model_name][\n                        action_class.action_type\n                    ] = action_class\n            self.has_scanned_for_bulk_actions = True\n\n    def get_bulk_actions_for_model(self, app_label, model_name):\n        self._scan_for_bulk_actions()\n        return self.actions.get(app_label, {}).get(model_name, {}).values()\n\n    def get_bulk_action_class(self, app_label, model_name, action_type):\n        self._scan_for_bulk_actions()\n        return (\n            self.actions.get(app_label, {}).get(model_name, {}).get(action_type, None)\n        )\n\n\nbulk_action_registry = BulkActionRegistry()\n", "path": "wagtail/admin/views/bulk_action/registry.py"}, {"content": "from abc import ABC, abstractmethod\n\nfrom django import forms\nfrom django.db import transaction\nfrom django.shortcuts import get_list_or_404, redirect\nfrom django.utils.functional import classproperty\nfrom django.views.generic import FormView\n\nfrom wagtail import hooks\nfrom wagtail.admin import messages\nfrom wagtail.admin.utils import get_valid_next_url_from_request\n\n\nclass BulkAction(ABC, FormView):\n    @property\n    @abstractmethod\n    def display_name(self):\n        pass\n\n    @property\n    @abstractmethod\n    def action_type(self):\n        pass\n\n    @property\n    @abstractmethod\n    def aria_label(self):\n        pass\n\n    extras = {}\n    action_priority = 100\n    classes = set()\n\n    form_class = forms.Form\n    cleaned_form = None\n\n    def __init__(self, request, model):\n        self.request = request\n        next_url = get_valid_next_url_from_request(request)\n        if not next_url:\n            next_url = request.path\n        self.next_url = next_url\n        self.num_parent_objects = self.num_child_objects = 0\n        if model in self.models:\n            self.model = model\n        else:\n            raise Exception(\n                \"model {} is not among the specified list of models\".format(\n                    model.__class__.__name__\n                )\n            )\n\n    @classproperty\n    def models(cls):\n        return []\n\n    @classmethod\n    def get_queryset(cls, model, object_ids):\n        return get_list_or_404(model, pk__in=object_ids)\n\n    def check_perm(self, obj):\n        return True\n\n    @classmethod\n    def execute_action(cls, objects, **kwargs):\n        raise NotImplementedError(\"execute_action needs to be implemented\")\n\n    def get_success_message(self, num_parent_objects, num_child_objects):\n        pass\n\n    def object_context(self, obj):\n        return {\"item\": obj}\n\n    @classmethod\n    def get_default_model(cls):\n        models = cls.models\n        if len(models) == 1:\n            return models[0]\n        raise Exception(\n            \"Cannot get default model if number of models is greater than 1\"\n        )\n\n    def __run_before_hooks(self, action_type, request, objects):\n        for hook in hooks.get_hooks(\"before_bulk_action\"):\n            result = hook(request, action_type, objects, self)\n            if hasattr(result, \"status_code\"):\n                return result\n\n    def __run_after_hooks(self, action_type, request, objects):\n        for hook in hooks.get_hooks(\"after_bulk_action\"):\n            result = hook(request, action_type, objects, self)\n            if hasattr(result, \"status_code\"):\n                return result\n\n    def get_all_objects_in_listing_query(self, parent_id):\n        return self.model.objects.all().values_list(\"pk\", flat=True)\n\n    def get_actionable_objects(self):\n        objects = []\n        items_with_no_access = []\n        object_ids = self.request.GET.getlist(\"id\")\n        if \"all\" in object_ids:\n            object_ids = self.get_all_objects_in_listing_query(\n                self.request.GET.get(\"childOf\")\n            )\n\n        for obj in self.get_queryset(self.model, object_ids):\n            if not self.check_perm(obj):\n                items_with_no_access.append(obj)\n            else:\n                objects.append(obj)\n        return objects, {\"items_with_no_access\": items_with_no_access}\n\n    def get_context_data(self, **kwargs):\n        items, items_with_no_access = self.get_actionable_objects()\n        _items = []\n        for item in items:\n            _items.append(self.object_context(item))\n        return {\n            **super().get_context_data(**kwargs),\n            \"items\": _items,\n            **items_with_no_access,\n            \"next\": self.next_url,\n            \"submit_url\": self.request.path + \"?\" + self.request.META[\"QUERY_STRING\"],\n        }\n\n    def prepare_action(self, objects, objects_without_access):\n        return\n\n    def get_execution_context(self):\n        return {}\n\n    def form_valid(self, form):\n        request = self.request\n        self.cleaned_form = form\n        objects, objects_without_access = self.get_actionable_objects()\n        self.actionable_objects = objects\n        resp = self.prepare_action(objects, objects_without_access)\n        if hasattr(resp, \"status_code\"):\n            return resp\n        with transaction.atomic():\n            before_hook_result = self.__run_before_hooks(\n                self.action_type, request, objects\n            )\n            if before_hook_result is not None:\n                return before_hook_result\n            num_parent_objects, num_child_objects = self.execute_action(\n                objects, **self.get_execution_context()\n            )\n            after_hook_result = self.__run_after_hooks(\n                self.action_type, request, objects\n            )\n            if after_hook_result is not None:\n                return after_hook_result\n            success_message = self.get_success_message(\n                num_parent_objects, num_child_objects\n            )\n            if success_message is not None:\n                messages.success(request, success_message)\n        return redirect(self.next_url)\n\n    def form_invalid(self, form):\n        return super().form_invalid(form)\n", "path": "wagtail/admin/views/bulk_action/base_bulk_action.py"}]}