Repository: wagtail/wagtail

Issue:
Order Object Permissions in the Group Editor
### Is your proposal related to a problem?

The Group Editor - at menu->Settings->Groups or /admin/groups/<<int:group>>/ - allows an administrator to control the permissions each member of the group receives. The "Object Permissions" section, which allows add, change, delete and other model permissions to be edited, are listed in an unmanaged order. The order appears to depend on the migration order, and depending on the development and deployment workflow, results in that ordering being different between development, staging and production deployments.

### Describe the solution you'd like

I would like a solution where the developer can control the order in which each Object type appears. Object permissions can then be placed in a logical order, such as placing related objects together. That order will then be consistent between deployments.

The solution should address objects defined by the developer and within Wagtail itself.

### Describe alternatives you've considered

There appears to be no practical solution to this problem.

### Additional context

I've developed a PR that addresses this, which I'll post shortly.
Order Object Permissions in the Group Editor
### Is your proposal related to a problem?

The Group Editor - at menu->Settings->Groups or /admin/groups/<<int:group>>/ - allows an administrator to control the permissions each member of the group receives. The "Object Permissions" section, which allows add, change, delete and other model permissions to be edited, are listed in an unmanaged order. The order appears to depend on the migration order, and depending on the development and deployment workflow, results in that ordering being different between development, staging and production deployments.

### Describe the solution you'd like

I would like a solution where the developer can control the order in which each Object type appears. Object permissions can then be placed in a logical order, such as placing related objects together. That order will then be consistent between deployments.

The solution should address objects defined by the developer and within Wagtail itself.

### Describe alternatives you've considered

There appears to be no practical solution to this problem.

### Additional context

I've developed a PR that addresses this, which I'll post shortly.


Discussion:

Resolving patch:
diff --git a/wagtail/users/permission_order.py b/wagtail/users/permission_order.py
new file mode 100644
--- /dev/null
+++ b/wagtail/users/permission_order.py
@@ -0,0 +1,17 @@
+from django.contrib.contenttypes.models import ContentType
+
+from wagtail.coreutils import resolve_model_string
+
+CONTENT_TYPE_ORDER = {}
+
+
+def register(model, **kwargs):
+    """
+    Registers order against the model content_type, used to
+    control the order the models and its permissions appear
+    in the groups object permission editor
+    """
+    order = kwargs.pop("order", None)
+    if order is not None:
+        content_type = ContentType.objects.get_for_model(resolve_model_string(model))
+        CONTENT_TYPE_ORDER[content_type.id] = order
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
@@ -4,6 +4,7 @@
 from django import template
 
 from wagtail import hooks
+from wagtail.users.permission_order import CONTENT_TYPE_ORDER
 
 register = template.Library()
 
@@ -38,8 +39,13 @@ def format_permissions(permission_bound_field):
 
     """
     permissions = permission_bound_field.field._queryset
-    # get a distinct list of the content types that these permissions relate to
-    content_type_ids = set(permissions.values_list("content_type_id", flat=True))
+    # get a distinct and ordered list of the content types that these permissions relate to.
+    # relies on Permission model default ordering, dict.fromkeys() retaining that order
+    # from the queryset, and the stability of sorted().
+    content_type_ids = sorted(
+        dict.fromkeys(permissions.values_list("content_type_id", flat=True)),
+        key=lambda ct: CONTENT_TYPE_ORDER.get(ct, float("inf")),
+    )
 
     # iterate over permission_bound_field to build a lookup of individual renderable
     # checkbox objects


Test patch:
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
@@ -24,6 +24,7 @@
 from wagtail.test.utils import WagtailTestUtils
 from wagtail.users.forms import UserCreationForm, UserEditForm
 from wagtail.users.models import UserProfile
+from wagtail.users.permission_order import register as register_permission_order
 from wagtail.users.views.groups import GroupViewSet
 from wagtail.users.views.users import get_user_creation_form, get_user_edit_form
 from wagtail.users.wagtail_hooks import get_group_viewset_cls
@@ -1947,6 +1948,71 @@ 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_group_edit_loads_with_django_permissions_in_order(self):
+        # ensure objects are ordered as registered, followed by the default ordering
+
+        def object_position(object_perms):
+            # returns the list of objects in the object permsissions
+            # as provided by the format_permissions tag
+
+            def flatten(perm_set):
+                # iterates through perm_set dict, flattens the list if present
+                for v in perm_set.values():
+                    if isinstance(v, list):
+                        for e in v:
+                            yield e
+                    else:
+                        yield v
+
+            return [
+                (
+                    perm.content_type.app_label,
+                    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"]]
+            ]
+
+        # Set order on two objects, should appear first and second
+        register_permission_order("snippetstests.fancysnippet", order=100)
+        register_permission_order("snippetstests.standardsnippet", order=110)
+
+        response = self.get()
+        object_positions = object_position(response.context["object_perms"])
+        self.assertEqual(
+            object_positions[0],
+            ("snippetstests", "fancysnippet"),
+            msg="Configured object permission order is incorrect",
+        )
+        self.assertEqual(
+            object_positions[1],
+            ("snippetstests", "standardsnippet"),
+            msg="Configured object permission order is incorrect",
+        )
+
+        # Swap order of the objects
+        register_permission_order("snippetstests.standardsnippet", order=90)
+        response = self.get()
+        object_positions = object_position(response.context["object_perms"])
+
+        self.assertEqual(
+            object_positions[0],
+            ("snippetstests", "standardsnippet"),
+            msg="Configured object permission order is incorrect",
+        )
+        self.assertEqual(
+            object_positions[1],
+            ("snippetstests", "fancysnippet"),
+            msg="Configured object permission order is incorrect",
+        )
+
+        # Test remainder of objects are sorted
+        self.assertEqual(
+            object_positions[2:],
+            sorted(object_positions[2:]),
+            msg="Default object permission order is incorrect",
+        )
+
 
 class TestGroupViewSet(TestCase):
     def setUp(self):