Repository: wagtail/wagtail

Issue:
Inconsistent use of model verbose_name in permissions UI
### Issue Summary

The object permissions UI doesn't show a model's [`verbose_name`](https://docs.djangoproject.com/en/4.2/ref/models/options/#verbose-name) if it differs from the model's name when it was created.

### Steps to Reproduce

Start a new project with `wagtail start myproject` and create a simple model like the following:

```py
from django.db import models

from wagtail.snippets.models import register_snippet

@register_snippet
class MyModel(models.Model):
    text = models.TextField()
```

In the group edit view, permissions render like this; note "My model" and "Can view":

![image](https://github.com/wagtail/wagtail/assets/654645/afca283f-99f6-4752-a926-6aed44365832)

Now, give the model a different `verbose_name`:

```py
@register_snippet
class MyModel(models.Model):
    text = models.TextField()

    class Meta:
        verbose_name = "Something else"
```

Now, the permissions render in a surprising way; note "Something else" and "Can view my model":

![image](https://github.com/wagtail/wagtail/assets/654645/8735d7b4-a5dc-479c-af6d-ac62aba6d6c0)

As a user, I would expect to see "Can view" the way all other objects are typically listed.

This behavior seems to come from this line that defines the permission "name":

https://github.com/wagtail/wagtail/blob/a8bc03dd8aad9d15fd4d8e84ace1bc89cbe96053/wagtail/users/templatetags/wagtailusers_tags.py#L101-L103

This code assumes that a model's content type's name (`perm.content_type.name`) will exactly match the name on the permission object (`perm.name`). This is true as long as the model's verbose name hasn't been changed, but, [per the docs](https://docs.djangoproject.com/en/4.2/ref/contrib/contenttypes/#django.contrib.contenttypes.models.ContentType.name), `content_type.name` "is taken from the `verbose_name` attribute of the model". So if a model's verbose name is changed, that change will show up when you look at its content type.

The issue is that when a model is first created, its automatically-generated permissions are given names that contain the model's  name at that time. For example, the above `MyModel` class generated permissions like this:

```
110|28|add_mymodel|Can add my model
111|28|change_mymodel|Can change my model
112|28|delete_mymodel|Can delete my model
113|28|view_mymodel|Can view my model
```

In the above example, the code assumes that since the content type reports its name as `Something else`, the permission's name will be `Can view something else`, but this isn't a valid assumption. The permission's name is still what it was when it was created, `Can view my model`. 

To fix this, the code could lookup the model objects corresponding to the permission content types and use those to do the substitution, maybe something like this:

```diff
diff --git a/wagtail/users/templatetags/wagtailusers_tags.py b/wagtail/users/templatetags/wagtailusers_tags.py
index c188425ad0..3da81fe0dd 100644
--- a/wagtail/users/templatetags/wagtailusers_tags.py
+++ b/wagtail/users/templatetags/wagtailusers_tags.py
@@ -95,11 +95,15 @@ def format_permissions(permission_bound_field):
                 }
             else:
                 extra_perms_exist["custom"] = True
+                perm_model_class = perm.content_type.model_class()
                 custom_perms.append(
                     {
                         "perm": perm,
                         "name": re.sub(
-                            f"{perm.content_type.name}$", "", perm.name, flags=re.I
+                            f"{perm_model_class._meta.model_name}$",  "", perm.name, flags=re.I
                         ).strip(),
                         "selected": checkbox.data["selected"],
                     }
```

### Technical details

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes
-   Python version: 3.11.1
-   Django version: 4.0.10
-   Wagtail version: 5.2a0


Discussion:
**Claiming Issue**

Hello,

I would like to claim this issue and work on a solution for it. I've reviewed the problem description and the proposed code changes, and I believe I can address this issue effectively.

I will start by:

1. Analyzing the existing code to understand the issue thoroughly.
2. Implementing the proposed code changes.
3. Testing the changes in a Wagtail environment to ensure they resolve the problem.

I'll keep the community updated on my progress and will seek feedback if needed. If you have any specific insights or suggestions, please feel free to share them.

Looking forward to contributing!
@FatGuyy How is the attempt at fixing the issue going? I'd love to be of help in any way I can.
@FatGuyy thanks for the update! As a first step I would recommend following the steps from @chosak on your own project, just to check that you get the same result. That’ll also be good information for us. 
I can confirm that this issue is indeed present.
Should I add screenshots?

I think this is a Django bug rather than a Wagtail bug: https://code.djangoproject.com/ticket/26756

Edit: never mind. Yes, the root cause is the Django bug, but since we added special handling for it, we should adapt that handling accordingly.
@FatGuyy no need! Are you ok to proceed with a fix? Perhaps attempting the proposed solution from @chosak?
@thibaudcolas I'll give the solution of @chosak a go.

Nice find @laymonage. I agree that since Wagtail has special handling it would be good to try to fix this here.

Thanks for willing to give this a try @FatGuyy. Unfortunately my initial fix suggested above won't work, since `model._meta.model_name` won't match the permission name if the model name has spaces (`breadtype` vs `Bread type`).

I wonder if, instead of trying to hack some kind of string-matching here, it would be better to handle the "view" permission separately (since this is one of the [built-in Django permissions](https://github.com/django/django/blob/e083f3082c71853a01bf149bda7fdbaf58d25f4d/django/contrib/auth/management/__init__.py#L24)). Wagtail could always display a "Can view" label if the model has a permission with a "view" action. All other permissions would display with whatever name they were given.

Here's a simple diff that does this:

```diff
diff --git a/wagtail/users/templatetags/wagtailusers_tags.py b/wagtail/users/templatetags/wagtailusers_tags.py
index 4e43d9d6a5..0fa900e3cc 100644
--- a/wagtail/users/templatetags/wagtailusers_tags.py
+++ b/wagtail/users/templatetags/wagtailusers_tags.py
@@ -1,5 +1,4 @@
 import itertools
-import re
 
 from django import template
 
@@ -98,9 +97,9 @@ def format_permissions(permission_bound_field):
                 custom_perms.append(
                     {
                         "perm": perm,
-                        "name": re.sub(
-                            f"{perm.content_type.name}$", "", perm.name, flags=re.I
-                        ).strip(),
+                        "name": "Can view"
+                        if permission_action == "view"
+                        else perm.name,
                         "selected": checkbox.data["selected"],
                     }
                 )
```

It looks like this, for example if BreadType's `verbose_name` is changed to "Type of bread":

|Current|Proposed|
|-|-|
|![image](https://github.com/wagtail/wagtail/assets/654645/300e1816-35a1-4f0c-b543-93e683ab3736)|![image](https://github.com/wagtail/wagtail/assets/654645/d5c67b73-a9d7-48b2-a3d9-fbd69f84239c)|

This approach would be worse than the current behavior where users haven't changed the verbose name but have defined a custom permission like "Can name person" -- the current behavior would show "Can name" but the proposed change would show "Can name person":

|Current|Proposed|
|-|-|
|![image](https://github.com/wagtail/wagtail/assets/654645/5cdf4479-28f5-41ca-8f0d-e8de9a797326)|![image](https://github.com/wagtail/wagtail/assets/654645/3d12a8a0-ef1a-4013-bd57-e23632b3cb5a)|

So this change would be better in some cases (where verbose name has been changed) and worse in others (in places where custom permissions were created with a specific naming pattern). Personally I think it would be better to remove the fragile regular expression matching in favor of more consistent behavior. What do you think @laymonage @thibaudcolas?
I'm in favour of the consistent behaviour, but I don't think using the `if` to specifically check for `view` in your example would be a good idea. It's probably better to do something more generic e.g.

```python
    "name": f"Can {permission_action}"
```

This line probably needs to use `.rsplit(maxplit=1)` in case the action name contains `_`:
https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/wagtail/users/templatetags/wagtailusers_tags.py#L88

It would look ugly if the action does contain `_` though, but that's also how Django does it: https://github.com/django/django/blob/e083f3082c71853a01bf149bda7fdbaf58d25f4d/django/contrib/auth/management/__init__.py#L29-L32

Or looking into Django's logic, maybe we can even just use `permission.name.split(maxsplit=2)` and join the first two elements. That should give us `Can {action}`.

Maybe we can use some kind of reverse-slugify trick to get rid of non-alphabet characters, so permissions e.g. `bulk_delete` can show up as `Can bulk delete` instead of `Can bulk_delete`.
Thanks @laymonage. Doesn't that approach assume that custom permission codenames will always be in the form `verb_model`?

Consider this example from the tests:

https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/wagtail/test/testapp/models.py#L419-L423

Or an example [from the Django docs](https://docs.djangoproject.com/en/4.2/ref/models/options/#permissions):

```py
permissions = [("can_deliver_pizzas", "Can deliver pizzas")]
```

This would show up as `Can can deliver`, dropping the `pizzas`.

Perhaps I'm stretching too far to come up with failure cases, and it's on the user to realize how the permission codename might show up in the Wagtail admin. But it doesn't seem like an obvious thing to consider until it shows up strangely and then you'd realize that you have to go back and normalize the codename.

[Here's](https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/wagtail/models/__init__.py#L3217) another place where Wagtail uses `codename.rsplit("_", maxsplit=1)[0]`, but in this case the codename is being compared against a specific list: `add`, `change`, `bulk_delete`, etc.

Another idea would be to use a similar hardcoded list here: if the rsplitted `permission_action` is one of `view`, `publish`, etc. then Wagtail displays `f"Can {permission_action}"` as you propose, otherwise we display the user-provided human-readable `permission.name` instead.
update - 
the bug is solved I am currently writing its tests.


hello, I've done the need full and added a test for the code too.
But the test is small, can anyone tell me how can I make the test better?
@thibaudcolas @laymonage 
@thibaudcolas @laymonage 

@FatGuyy have you raised a PR for this? Maybe that's a good next step - will let you get feedback on the proposed changes. 

Feel free to raise the PR as a draft if you're not fully confident with the approach. 
Hello, I m trying since 2 days to understand what exactly do I need to do. It got pretty confusing. Can someone brief me about what exactly is the desired output?
@lb-  @thibaudcolas @chosak 
@lb- @thibaudcolas @chosak @laymonage 

> Thanks @laymonage. Doesn't that approach assume that custom permission codenames will always be in the form `verb_model`?
> 
> Consider this example from the tests:
> 
> https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/wagtail/test/testapp/models.py#L419-L423
> 
> Or an example [from the Django docs](https://docs.djangoproject.com/en/4.2/ref/models/options/#permissions):
> 
> ```python
> permissions = [("can_deliver_pizzas", "Can deliver pizzas")]
> ```
> 
> This would show up as `Can can deliver`, dropping the `pizzas`.
> 
> Perhaps I'm stretching too far to come up with failure cases, and it's on the user to realize how the permission codename might show up in the Wagtail admin. But it doesn't seem like an obvious thing to consider until it shows up strangely and then you'd realize that you have to go back and normalize the codename.
> 
> [Here's](https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/wagtail/models/__init__.py#L3217) another place where Wagtail uses `codename.rsplit("_", maxsplit=1)[0]`, but in this case the codename is being compared against a specific list: `add`, `change`, `bulk_delete`, etc.
> 
> Another idea would be to use a similar hardcoded list here: if the rsplitted `permission_action` is one of `view`, `publish`, etc. then Wagtail displays `f"Can {permission_action}"` as you propose, otherwise we display the user-provided human-readable `permission.name` instead.

What if we strip `Can` if there's one in the `permission_action` and do it the proposed way?
Hey all, sorry I missed the conversations here and the PR. I forgot to leave a comment but I think @chosak's example about custom permissions not necessarily in the format of `verb_modelname` is valid, and in fact we already have something like this in Wagtail, i.e. the `submit_translation` permission in the simpletranslation contrib module.

I left a comment in https://github.com/wagtail/wagtail/pull/11606#pullrequestreview-1877508339 for more details, but I propose we revisit this. Thanks!
Ok. Should we be reverting the merged PR or just build on top of it?


I don't think it matters that much either way, but I'll look into this. If a revert is cleaner, then I'll add that to the PR.

Resolving patch:
diff --git a/wagtail/users/templatetags/wagtailusers_tags.py b/wagtail/users/templatetags/wagtailusers_tags.py
--- a/wagtail/users/templatetags/wagtailusers_tags.py
+++ b/wagtail/users/templatetags/wagtailusers_tags.py
@@ -2,6 +2,9 @@
 from collections import defaultdict
 
 from django import template
+from django.contrib.auth import get_permission_codename
+from django.contrib.auth.models import Permission
+from django.utils.text import camel_case_to_spaces
 
 from wagtail import hooks
 from wagtail.users.permission_order import CONTENT_TYPE_ORDER
@@ -9,6 +12,45 @@
 register = template.Library()
 
 
+def normalize_permission_label(permission: Permission):
+    """
+    Strip model name from the end of the label, e.g. "Can deliver pizza" for a
+    Pizza model becomes "Can deliver". For permissions in the model's
+    Meta.default_permissions with default labels, also replace underscores
+    with spaces.
+
+    This is used to display custom model permissions in the admin.
+
+    See https://github.com/wagtail/wagtail/issues/10982.
+    """
+    label = permission.name
+    content_type = permission.content_type
+    model = content_type.model_class()
+    verbose_name = default_verbose_name = content_type.name
+
+    if model:
+        default_verbose_name = camel_case_to_spaces(model._meta.object_name)
+
+        # If it's in default_permissions and the label matches Django's default
+        # label, remove the model name from the end of the label. Also replace
+        # underscores with spaces, as Django uses the action internal name as-is
+        # for the permission label, which means it tends to be in snake_case.
+        for action in model._meta.default_permissions:
+            default_codename = get_permission_codename(action, model._meta)
+            is_default = permission.codename == default_codename
+            if is_default and permission.name.startswith(f"Can {action}"):
+                return f"Can {action.replace('_', ' ')}"
+
+    # For all other cases (including custom permissions), try to remove the
+    # verbose name from the end of the label. This only works if the label
+    # matches the current verbose name or Django's default verbose name.
+    for name in (default_verbose_name, verbose_name):
+        if label.lower().endswith(name.lower()):
+            return label[: -len(name)].strip()
+
+    return label
+
+
 @register.inclusion_tag("wagtailusers/groups/includes/formatted_permissions.html")
 def format_permissions(permission_bound_field):
     """
@@ -92,10 +134,7 @@ def format_permissions(permission_bound_field):
             checkbox = checkboxes_by_id[perm.id]
             # identify the main categories of permission, and assign to
             # the relevant dict key, else bung in the 'custom_perms' list
-            permission_action = perm.codename.split("_", maxsplit=1)
-            permission_action = permission_action[permission_action[0].lower() == "can"]
-            permission_action = permission_action.rsplit(maxsplit=1)[0]
-
+            permission_action = perm.codename.split("_")[0]
             if permission_action in main_permission_names:
                 if permission_action in extra_perms_exist:
                     extra_perms_exist[permission_action] = True
@@ -105,10 +144,12 @@ def format_permissions(permission_bound_field):
                 }
             else:
                 extra_perms_exist["custom"] = True
+                perm_name = normalize_permission_label(perm)
+
                 custom_perms.append(
                     {
                         "perm": perm,
-                        "name": f"Can {permission_action}",
+                        "name": perm_name,
                         "selected": checkbox.data["selected"],
                     }
                 )


Test patch:
diff --git a/wagtail/test/testapp/migrations/0034_custompermissionmodel.py b/wagtail/test/testapp/migrations/0034_custompermissionmodel.py
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0034_custompermissionmodel.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.2.7 on 2024-02-22 09:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("tests", "0033_customcopyformpage"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CustomPermissionModel",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("text", models.TextField(default="Tailwag")),
+            ],
+            options={
+                "verbose_name": "ADVANCED permission model",
+                "verbose_name_plural": "ADVANCED permission models",
+                "permissions": [
+                    ("can_start_trouble", "Can start trouble"),
+                    ("cause_chaos", "Cause chaos for advanced permission model"),
+                    ("change_text", "Change text"),
+                    ("control", "Manage custom permission model"),
+                ],
+                "default_permissions": (
+                    "add",
+                    "change",
+                    "delete",
+                    "view",
+                    "bulk_update",
+                ),
+            },
+        ),
+    ]
diff --git a/wagtail/test/testapp/migrations/0034_modelwithverbosename.py b/wagtail/test/testapp/migrations/0034_modelwithverbosename.py
deleted file mode 100644
--- a/wagtail/test/testapp/migrations/0034_modelwithverbosename.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Generated by Django 4.2.7 on 2024-02-12 22:02
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ("tests", "0033_customcopyformpage"),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="ModelWithVerboseName",
-            fields=[
-                (
-                    "id",
-                    models.AutoField(
-                        auto_created=True,
-                        primary_key=True,
-                        serialize=False,
-                        verbose_name="ID",
-                    ),
-                ),
-            ],
-            options={
-                "verbose_name": "Custom verbose name",
-                "verbose_name_plural": "Custom verbose names",
-            },
-        ),
-    ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -1921,15 +1921,6 @@ class TableBlockStreamPage(Page):
     content_panels = [FieldPanel("table")]
 
 
-class ModelWithVerboseName(ClusterableModel):
-    class Meta:
-        verbose_name = "Custom verbose name"
-        verbose_name_plural = "Custom verbose names"
-
-
-register_snippet(ModelWithVerboseName)
-
-
 class UserProfile(models.Model):
     # Wagtail's schema must be able to coexist alongside a custom UserProfile model
     user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -2265,3 +2256,35 @@ def can_view_revisions(self):
 class CustomPermissionPage(Page):
     def permissions_for_user(self, user):
         return CustomPermissionTester(user, self)
+
+
+class CustomPermissionModel(models.Model):
+    text = models.TextField(default="Tailwag")
+
+    class Meta:
+        verbose_name = "ADVANCED permission model"
+        verbose_name_plural = "ADVANCED permission models"
+
+        # Django's default_permissions are ("add", "change", "delete", "view").
+        # Django will generate permissions for each of these actions with the
+        # format f"{action}_{model_name}" and the label "Can {action} {verbose_name}".
+        # This means if the action is "bulk_update", the codename will be
+        # "bulk_update_custompermissionmodel" and the label will be
+        # "Can bulk_update ADVANCED permission model".
+        # See https://github.com/django/django/blob/stable/5.0.x/django/contrib/auth/management/__init__.py#L22-L35
+        default_permissions = ("add", "change", "delete", "view", "bulk_update")
+
+        # Permissions with completely custom codenames and labels
+        permissions = [
+            # Starts with can_ and "Can "
+            ("can_start_trouble", "Can start trouble"),
+            # Doesn't start with can_ and "Can "
+            ("cause_chaos", "Cause chaos for advanced permission model"),
+            # Starts with an action similar to a built-in permission "change_"
+            ("change_text", "Change text"),
+            # Without any _ and the label ends with the default verbose_name
+            ("control", "Manage custom permission model"),
+        ]
+
+
+register_snippet(CustomPermissionModel)
diff --git a/wagtail/users/tests/test_admin_views.py b/wagtail/users/tests/test_admin_views.py
--- a/wagtail/users/tests/test_admin_views.py
+++ b/wagtail/users/tests/test_admin_views.py
@@ -1573,34 +1573,23 @@ def test_hide_publish_permissions(self):
         # Should not show inputs for publish permissions on models without DraftStateMixin
         self.assertNotInHTML("Can publish advert", html)
 
-    def test_view_permission_if_model_has_verbose(self):
+    def test_strip_model_name_from_custom_permissions(self):
         """
-        Tests for bug #10982
         https://github.com/wagtail/wagtail/issues/10982
-
-        Ensures Model Name or Verbose Name is stripped from Custom Permissions before being displayed
-        A Model "ModelWithVerboseName" is added to wagtail.test.testapp.models with verbose_name = "Custom Verbose Name"
-
+        Ensure model name or verbose name is stripped from permissions' labels
+        for consistency with built-in permissions.
         """
         response = self.get()
 
-        self.assertContains(response, "Can view", msg_prefix="No Can view permission")
-        self.assertNotContains(
-            response,
-            "Can view model with verbose name",
-            msg_prefix=" Model Name not stripped from Can view permission",
-        )
-        self.assertNotContains(
-            response,
-            "Can view custom verbose name",
-            msg_prefix="Verbose Name of ModelWithVerboseName not stripped from Can view permission",
-        )
-        pattern = r'Can\sview\s\[.*?"'
-        self.assertNotRegex(
-            response.content.decode(),
-            pattern,
-            msg="Model Name not stripped from custom permissions",
-        )
+        self.assertContains(response, "Can bulk update")
+        self.assertContains(response, "Can start trouble")
+        self.assertContains(response, "Cause chaos for")
+        self.assertContains(response, "Change text")
+        self.assertContains(response, "Manage")
+        self.assertNotContains(response, "Can bulk_update")
+        self.assertNotContains(response, "Can bulk update ADVANCED permission model")
+        self.assertNotContains(response, "Cause chaos for advanced permission model")
+        self.assertNotContains(response, "Manage custom permission model")
 
 
 class TestGroupEditView(AdminTemplateTestUtils, WagtailTestUtils, TestCase):
@@ -2070,7 +2059,13 @@ def flatten(perm_set):
                     perm.content_type.model,
                 )
                 for perm_set in object_perms
-                for perm in [next(v for v in flatten(perm_set) if "perm" in v)["perm"]]
+                for perm in [
+                    next(
+                        v
+                        for v in flatten(perm_set)
+                        if isinstance(v, dict) and "perm" in v
+                    )["perm"]
+                ]
             ]
 
         # Set order on two objects, should appear first and second