Repository: wagtail/wagtail

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.



Discussion:
Thanks, this might be a regression in c1a4528b8a91ebdb5018837c37ffd54ef34f0aba.

In the meantime you could override `get_models()`, though looking back I think we should've made use of the `classproperty` decorator from `django.utils` instead: https://docs.djangoproject.com/en/4.2/ref/utils/#django.utils.functional.classproperty
@laymonage What would you suggest solving this regression? I want to contribute. 

I read the last paragraph of the comment in the `get_models()`, and it's an important concern, but I don't know how to solve without breaking possible direct accesses to `cls.models`. 
> @laymonage What would you suggest solving this regression? I want to contribute. 
> 
> 
> 
> I read the last paragraph of the comment in the `get_models()`, and it's an important concern, but I don't know how to solve without breaking possible direct accesses to `cls.models`. 

Could we perhaps start off by reverting the changes in the commit I linked, and instead of adding a get_models method, turn it into a classproperty as I mentioned above? Not sure if that'd work, but I guess it's worth trying

Resolving patch:
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 @@ def aria_label(self):
 
     extras = {}
     action_priority = 100
-    models = []
     classes = set()
 
     form_class = forms.Form
@@ -41,7 +41,7 @@ def __init__(self, request, model):
             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 @@ def __init__(self, request, model):
                 )
             )
 
-    @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 @@ def object_context(self, obj):
 
     @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 @@ def _scan_for_bulk_actions(self):
                             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 @@ def get_models(cls):
         # 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 {


Test patch:
diff --git a/wagtail/snippets/tests/test_bulk_actions/test_custom_models.py b/wagtail/snippets/tests/test_bulk_actions/test_custom_models.py
new file mode 100644
--- /dev/null
+++ b/wagtail/snippets/tests/test_bulk_actions/test_custom_models.py
@@ -0,0 +1,56 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from wagtail.test.testapp.models import Advert, FullFeaturedSnippet
+from wagtail.test.utils.wagtail_tests import WagtailTestUtils
+
+
+class TestCustomModels(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.user = self.login()
+
+    def create_snippets(self, model):
+        return [model.objects.create(text=f"Title-{i}") for i in range(1, 6)]
+
+    def get_action_url(self, model, snippets):
+        return (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    model._meta.app_label,
+                    model._meta.model_name,
+                    "disable",
+                ),
+            )
+            + "?"
+            + "&".join(f"id={item.pk}" for item in snippets)
+        )
+
+    def get_list_url(self, model):
+        return reverse(model.snippet_viewset.get_url_name("list"))
+
+    def test_action_shown_for_custom_models(self):
+        self.create_snippets(FullFeaturedSnippet)
+        response = self.client.get(self.get_list_url(FullFeaturedSnippet))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Disable selected full-featured snippets")
+
+    def test_action_confirmation_accessible_for_custom_models(self):
+        snippets = self.create_snippets(FullFeaturedSnippet)
+        response = self.client.get(self.get_action_url(FullFeaturedSnippet, snippets))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(
+            response,
+            "wagtailadmin/bulk_actions/confirmation/base.html",
+        )
+
+    def test_action_not_shown_for_other_models(self):
+        self.create_snippets(Advert)
+        response = self.client.get(self.get_list_url(Advert))
+        self.assertEqual(response.status_code, 200)
+        self.assertNotContains(response, "Disable selected full-featured snippets")
+
+    def test_action_confirmation_inaccessible_for_other_models(self):
+        snippets = self.create_snippets(Advert)
+        response = self.client.get(self.get_action_url(Advert, snippets))
+        self.assertEqual(response.status_code, 404)
diff --git a/wagtail/test/testapp/wagtail_hooks.py b/wagtail/test/testapp/wagtail_hooks.py
--- a/wagtail/test/testapp/wagtail_hooks.py
+++ b/wagtail/test/testapp/wagtail_hooks.py
@@ -20,6 +20,7 @@
 from wagtail.admin.ui.tables import BooleanColumn, UpdatedAtColumn
 from wagtail.admin.views.account import BaseSettingsPanel
 from wagtail.admin.widgets import Button
+from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
 from wagtail.snippets.models import register_snippet
 from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup
 from wagtail.test.testapp.models import (
@@ -386,6 +387,15 @@ class VariousOnDeleteModelViewSet(SnippetViewSet):
 register_snippet(VariousOnDeleteModelViewSet)
 
 
+@hooks.register("register_bulk_action")
+class DisableBulkAction(SnippetBulkAction):
+    template_name = "wagtailadmin/bulk_actions/confirmation/base.html"
+    models = [FullFeaturedSnippet]
+    display_name = "Disable"
+    aria_label = "Disable selected full-featured snippets"
+    action_type = "disable"
+
+
 @hooks.register("register_admin_viewset")
 def register_animated_advert_chooser_viewset():
     return animated_advert_chooser_viewset