Repository: wagtail/wagtail

Issue:
Adopt generic class based views for User management
| Part of the Epic https://github.com/wagtail/wagtail/issues/8365 |
|-----------------------------------------------|

### Is your proposal related to a problem?

* Wagtail 2.12 added generic class based views for use across Wagtail's admin.
* Many parts of the Wagtail admin are not yet migrated to this new approach, which makes it harder to share UI logic and styling/layouts.
* This issue specifically covers the user management (listing users, editing/deleting/creating users) views.

### Describe the solution you'd like

* Migrate the function views in [`wagtail/users/views/users.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/users/views/users.py) to class based views.
* Ideally this would adopt the [Wagtail Model Viewset approach](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/viewsets/model.py) as this would make the most sense, if not possible using discrete model generic class based views would be ok.
* The templates for the views should also extend their generic model class view counterparts.
* See a reference implementation already adopted in the Groups (listing/editing etc) views. [`wagtail/users/views/groups.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/users/views/groups.py)

### Describe alternatives you've considered

* Leave as is, however this will make it harder to share UI elements and layout between similar listing views.

### Additional context

* This is part of a larger epic work to adopt class based views throughout wagtail - https://github.com/wagtail/wagtail/issues/8365
* Generic class based model views can be found in [`wagtail/admin/views/generic/models.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/generic/models.py)
* Generic class based model view templates can be found in [`wagtail/admin/templates/wagtailadmin/generic`](https://github.com/wagtail/wagtail/tree/main/wagtail/admin/templates/wagtailadmin/generic)
* A more recent set up of locales using the model viewset can be found in 7c86c4e14f8a1be15c519b88935c19cd563e43d9

#### Screenshots

##### Users Index

<img width="1448" alt="Screen Shot 2022-06-05 at 7 59 47 pm" src="https://user-images.githubusercontent.com/1396140/172045236-f5847f9c-794a-4e3e-b2ad-6ca85ccbefe0.png">

##### Create user

<img width="1172" alt="Screen Shot 2022-06-05 at 8 01 26 pm" src="https://user-images.githubusercontent.com/1396140/172045261-6b0af777-23c2-41d9-b7b2-c425b01f3ea4.png">

##### Delete user

<img width="1448" alt="Screen Shot 2022-06-05 at 8 00 07 pm" src="https://user-images.githubusercontent.com/1396140/172045295-db494e2d-6744-492a-b29e-e9d16b53571d.png">

##### Bulk Actions pages (note: may not be in scope)

<img width="1448" alt="Screen Shot 2022-06-05 at 8 00 27 pm" src="https://user-images.githubusercontent.com/1396140/172045327-9474d4bc-d65e-4f4f-aa55-beb9d1ef16bc.png">

<img width="1450" alt="Screen Shot 2022-06-05 at 8 00 16 pm" src="https://user-images.githubusercontent.com/1396140/172045330-b2fc7cd4-56aa-4538-9e18-767837ed4758.png">

<img width="1448" alt="Screen Shot 2022-06-05 at 8 00 44 pm" src="https://user-images.githubusercontent.com/1396140/172045323-113f842b-a52f-4ff3-a123-5228a7658a19.png">




Discussion:
Can you please elaborate using example or Why you think so, especially use case example?
Thanks @faltunik - I will aim to flesh this out soon. 

Just preparing some issues relating to the GSoC UX Unification project and the upcoming Wagtail space sprints. 

See umbrella issue for context as to what this is about. https://github.com/wagtail/wagtail/issues/8365
Updated.

Resolving patch:
diff --git a/wagtail/users/urls/users.py b/wagtail/users/urls/users.py
--- a/wagtail/users/urls/users.py
+++ b/wagtail/users/urls/users.py
@@ -4,7 +4,7 @@
 
 app_name = "wagtailusers_users"
 urlpatterns = [
-    path("", users.index, name="index"),
+    path("", users.Index.as_view(), name="index"),
     path("add/", users.create, name="add"),
     path("<str:user_id>/", users.edit, name="edit"),
     path("<str:user_id>/delete/", users.delete, name="delete"),
diff --git a/wagtail/users/views/groups.py b/wagtail/users/views/groups.py
--- a/wagtail/users/views/groups.py
+++ b/wagtail/users/views/groups.py
@@ -7,7 +7,7 @@
 from wagtail.admin.views import generic
 from wagtail.admin.viewsets.model import ModelViewSet
 from wagtail.users.forms import GroupForm, GroupPagePermissionFormSet
-from wagtail.users.views.users import index
+from wagtail.users.views.users import Index
 
 _permission_panel_classes = None
 
@@ -171,7 +171,7 @@ class GroupViewSet(ModelViewSet):
 
     @property
     def users_view(self):
-        return index
+        return Index.as_view()
 
     def get_form_class(self, for_update=False):
         return GroupForm
diff --git a/wagtail/users/views/users.py b/wagtail/users/views/users.py
--- a/wagtail/users/views/users.py
+++ b/wagtail/users/views/users.py
@@ -2,21 +2,21 @@
 from django.contrib.auth import get_user_model, update_session_auth_hash
 from django.contrib.auth.models import Group
 from django.core.exceptions import PermissionDenied
-from django.core.paginator import Paginator
 from django.db import transaction
 from django.db.models import Q
 from django.shortcuts import get_object_or_404, redirect
 from django.template.response import TemplateResponse
 from django.urls import reverse
 from django.utils.translation import gettext as _
-from django.views.decorators.vary import vary_on_headers
+from django.utils.translation import gettext_lazy
 
 from wagtail import hooks
 from wagtail.admin import messages
-from wagtail.admin.auth import any_permission_required, permission_required
-from wagtail.admin.forms.search import SearchForm
+from wagtail.admin.auth import permission_required
+from wagtail.admin.views.generic import IndexView
 from wagtail.compat import AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME
 from wagtail.log_actions import log
+from wagtail.permission_policies import ModelPermissionPolicy
 from wagtail.users.forms import UserCreationForm, UserEditForm
 from wagtail.users.utils import user_can_delete_user
 from wagtail.utils.loading import get_custom_form
@@ -70,74 +70,74 @@ def get_users_filter_query(q, model_fields):
     return conditions
 
 
-@any_permission_required(add_user_perm, change_user_perm, delete_user_perm)
-@vary_on_headers("X-Requested-With")
-def index(request, *args):
-    q = None
-    is_searching = False
-
-    group = None
-    group_filter = Q()
-    if args:
-        group = get_object_or_404(Group, id=args[0])
-        group_filter = Q(groups=group) if args else Q()
-
-    model_fields = [f.name for f in User._meta.get_fields()]
-
-    if "q" in request.GET:
-        form = SearchForm(request.GET, placeholder=_("Search users"))
-        if form.is_valid():
-            q = form.cleaned_data["q"]
-            is_searching = True
-            conditions = get_users_filter_query(q, model_fields)
-
-            users = User.objects.filter(group_filter & conditions)
-    else:
-        form = SearchForm(placeholder=_("Search users"))
-
-    if not is_searching:
-        users = User.objects.filter(group_filter)
+class Index(IndexView):
+    """
+    Lists the users for management within the admin.
+    """
+
+    any_permission_required = ["add", "change", "delete"]
+    permission_policy = ModelPermissionPolicy(User)
+    model = User
+    context_object_name = "users"
+    index_url_name = "wagtailusers_users:index"
+    add_url_name = "wagtailusers_users:add"
+    edit_url_name = "wagtailusers_users:edit"
+    default_ordering = "name"
+    paginate_by = 20
+    template_name = None
+    is_searchable = True
+    page_title = gettext_lazy("Users")
+
+    def setup(self, request, *args, **kwargs):
+        super().setup(request, *args, **kwargs)
+        setattr(self, "template_name", self.get_template())
+        self.group = get_object_or_404(Group, id=args[0]) if args else None
+        self.group_filter = Q(groups=self.group) if self.group else Q()
+        self.model_fields = [f.name for f in User._meta.get_fields()]
+
+    def get_valid_orderings(self):
+        return ["name", "username"]
+
+    def get_queryset(self):
+        if self.is_searching:
+            conditions = get_users_filter_query(self.search_query, self.model_fields)
+            users = User.objects.filter(self.group_filter & conditions)
+        else:
+            users = User.objects.filter(self.group_filter)
 
-    if "last_name" in model_fields and "first_name" in model_fields:
-        users = users.order_by("last_name", "first_name")
+        if self.locale:
+            users = users.filter(locale=self.locale)
 
-    if "ordering" in request.GET:
-        ordering = request.GET["ordering"]
+        if "last_name" in self.model_fields and "first_name" in self.model_fields:
+            users = users.order_by("last_name", "first_name")
 
-        if ordering == "username":
+        if self.get_ordering() == "username":
             users = users.order_by(User.USERNAME_FIELD)
-    else:
-        ordering = "name"
 
-    paginator = Paginator(users.select_related("wagtail_userprofile"), per_page=20)
-    users = paginator.get_page(request.GET.get("p"))
+        return users
 
-    if request.headers.get("x-requested-with") == "XMLHttpRequest":
-        return TemplateResponse(
-            request,
-            "wagtailusers/users/results.html",
-            {
-                "users": users,
-                "is_searching": is_searching,
-                "query_string": q,
-                "ordering": ordering,
-            },
+    def get_template(self):
+        if self.request.headers.get("x-requested-with") == "XMLHttpRequest":
+            return "wagtailusers/users/results.html"
+        else:
+            return "wagtailusers/users/index.html"
+
+    def get_context_data(self, *args, object_list=None, **kwargs):
+        context_data = super().get_context_data(
+            *args, object_list=object_list, **kwargs
         )
-    else:
-        return TemplateResponse(
-            request,
-            "wagtailusers/users/index.html",
+        context_data["ordering"] = self.get_ordering()
+        context_data["group"] = self.group
+        if self.request.headers.get("x-requested-with") == "XMLHttpRequest":
+            return context_data
+
+        context_data.update(
             {
-                "group": group,
-                "search_form": form,
-                "users": users,
-                "is_searching": is_searching,
-                "ordering": ordering,
-                "query_string": q,
                 "app_label": User._meta.app_label,
                 "model_name": User._meta.model_name,
-            },
+            }
         )
+        return context_data
 
 
 @permission_required(add_user_perm)


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
@@ -124,13 +124,13 @@ def test_search(self):
     def test_search_query_one_field(self):
         response = self.get({"q": "first name"})
         self.assertEqual(response.status_code, 200)
-        results = response.context["users"].object_list
+        results = response.context["users"]
         self.assertIn(self.test_user, results)
 
     def test_search_query_multiple_fields(self):
         response = self.get({"q": "first name last name"})
         self.assertEqual(response.status_code, 200)
-        results = response.context["users"].object_list
+        results = response.context["users"]
         self.assertIn(self.test_user, results)
 
     def test_pagination(self):
@@ -180,13 +180,13 @@ def test_search(self):
     def test_search_query_one_field(self):
         response = self.get({"q": "first name"})
         self.assertEqual(response.status_code, 200)
-        results = response.context["users"].object_list
+        results = response.context["users"]
         self.assertIn(self.test_user, results)
 
     def test_search_query_multiple_fields(self):
         response = self.get({"q": "first name last name"})
         self.assertEqual(response.status_code, 200)
-        results = response.context["users"].object_list
+        results = response.context["users"]
         self.assertIn(self.test_user, results)
 
     def test_pagination(self):
@@ -1817,31 +1817,27 @@ def setUp(self):
     def get(self, params={}):
         return self.client.get(reverse("wagtailusers_users:index"))
 
-    def gain_permissions(self):
-        self._user.user_permissions.add(
-            *Permission.objects.filter(
-                content_type__app_label=AUTH_USER_APP_LABEL,
-                codename__in=(
-                    "add_{}".format(AUTH_USER_MODEL_NAME.lower()),
-                    "change_{}".format(AUTH_USER_MODEL_NAME.lower()),
-                    "delete_{}".format(AUTH_USER_MODEL_NAME.lower()),
-                ),
-            )
-        )
-
     def test_simple(self):
         response = self.get()
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
         )
 
     def test_authorised(self):
-        self.gain_permissions()
-        response = self.get()
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, "wagtailusers/users/index.html")
+        for permission in ("add", "change", "delete"):
+            permission_name = "{}_{}".format(permission, AUTH_USER_MODEL_NAME.lower())
+            permission_object = Permission.objects.get(codename=permission_name)
+            self._user.user_permissions.add(permission_object)
+
+            response = self.get()
+            self.assertEqual(response.status_code, 200)
+            self.assertTemplateUsed(response, "wagtailusers/users/index.html")
+            self.assertContains(response, "auth_user")
+
+            self._user.user_permissions.remove(permission_object)
 
 
 class TestAuthorisationCreateView(TestCase, WagtailTestUtils):
@@ -1866,7 +1862,8 @@ def gain_permissions(self):
 
     def test_simple(self):
         response = self.get()
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
@@ -1889,13 +1886,14 @@ def test_unauthorised_post(self):
                 "password2": "password",
             }
         )
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
         )
-        users = get_user_model().objects.filter(email="test@user.com")
-        self.assertEqual(users.count(), 0)
+        user = get_user_model().objects.filter(email="test@user.com")
+        self.assertFalse(user.exists())
 
     def test_authorised_post(self):
         self.gain_permissions()
@@ -1910,8 +1908,8 @@ def test_authorised_post(self):
             }
         )
         self.assertRedirects(response, reverse("wagtailusers_users:index"))
-        users = get_user_model().objects.filter(email="test@user.com")
-        self.assertEqual(users.count(), 1)
+        user = get_user_model().objects.filter(email="test@user.com")
+        self.assertTrue(user.exists())
 
 
 class TestAuthorisationEditView(TestCase, WagtailTestUtils):
@@ -1949,7 +1947,8 @@ def gain_permissions(self):
 
     def test_simple(self):
         response = self.get()
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
@@ -1973,7 +1972,8 @@ def test_unauthorised_post(self):
                 "is_active": "on",
             }
         )
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
@@ -2032,7 +2032,8 @@ def gain_permissions(self):
 
     def test_simple(self):
         response = self.get()
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
@@ -2046,17 +2047,18 @@ def test_authorised_get(self):
 
     def test_unauthorised_post(self):
         response = self.post()
-        self.assertEqual(response.status_code, 302)
+        # Should redirect to admin index (permission denied)
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
         self.assertEqual(
             response.context["message"],
             "Sorry, you do not have permission to access this area.",
         )
-        users = get_user_model().objects.filter(email="test_user@email.com")
-        self.assertEqual(users.count(), 1)
+        user = get_user_model().objects.filter(email="test_user@email.com")
+        self.assertTrue(user.exists())
 
     def test_authorised_post(self):
         self.gain_permissions()
         response = self.post()
         self.assertRedirects(response, reverse("wagtailusers_users:index"))
-        users = get_user_model().objects.filter(email="test_user@email.com")
-        self.assertEqual(users.count(), 0)
+        user = get_user_model().objects.filter(email="test_user@email.com")
+        self.assertFalse(user.exists())