Repository: wagtail/wagtail

Issue:
Adopt generic class based view for user account editing
| 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 account view (when you edit / manage your own account).

### Describe the solution you'd like

* Migrate the function view `account` in [`wagtail/admin/views/account.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/views/account.py#L211) to class based view.
* Extend the `WagtailAdminTemplateMixin` & `TemplateView` if `EditView` similar to what was dome for the dashboard view (see context below)
* The template for the view should also extend the generic model index class view version [`wagtail/admin/templates/wagtailadmin/generic/edit.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/generic/edit.html)

### 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
* Dashboard view change https://github.com/wagtail/wagtail/pull/8382

### Screenshot

<img width="1448" alt="Screen Shot 2022-06-05 at 8 11 22 pm" src="https://user-images.githubusercontent.com/1396140/172045668-32238c1c-1aa1-4202-b60c-8ff8e4a49859.png">




Discussion:
Thanks @lb-!

The form on this view is constructed a lot differently to the way our other generic edit views work. So this one might be one worth keeping separate.
@kaedroho I think it is still worthwhile making this a class based view - just using the basic Wagtail template mixin. 

I agree this does not need to use the model one - I can reword the issue accordingly. 

Class based views make it much easier to override/ extend the existing behaviour without having to copy/ paste the entire view function. 

I see this as similar to the dashboard class based view change. 
https://github.com/wagtail/wagtail/pull/8382
Update made. 
I believe this has been done in #9114, #9141, #9196, and #9204. Though I think it would be nice to wrap the views in a `ModelViewSet` subclass like the views for groups.

Resolving patch:
diff --git a/wagtail/admin/urls/__init__.py b/wagtail/admin/urls/__init__.py
--- a/wagtail/admin/urls/__init__.py
+++ b/wagtail/admin/urls/__init__.py
@@ -100,7 +100,7 @@
     path(
         "reports/", include(wagtailadmin_reports_urls, namespace="wagtailadmin_reports")
     ),
-    path("account/", account.account, name="wagtailadmin_account"),
+    path("account/", account.AccountView.as_view(), name="wagtailadmin_account"),
     path("logout/", account.LogoutView.as_view(), name="wagtailadmin_logout"),
     path(
         "dismissibles/",
diff --git a/wagtail/admin/views/account.py b/wagtail/admin/views/account.py
--- a/wagtail/admin/views/account.py
+++ b/wagtail/admin/views/account.py
@@ -12,9 +12,11 @@
 from django.template.loader import render_to_string
 from django.template.response import TemplateResponse
 from django.urls import reverse, reverse_lazy
+from django.utils.decorators import method_decorator
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy, override
 from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic.base import TemplateView
 
 from wagtail import hooks
 from wagtail.admin.forms.account import (
@@ -29,6 +31,7 @@
     get_available_admin_languages,
     get_available_admin_time_zones,
 )
+from wagtail.admin.views.generic import WagtailAdminTemplateMixin
 from wagtail.log_actions import log
 from wagtail.users.models import UserProfile
 from wagtail.utils.loading import get_custom_form
@@ -223,43 +226,76 @@ def get_form(self):
 # Views
 
 
-@sensitive_post_parameters()
-def account(request):
-    # Fetch the user and profile objects once and pass into each panel
-    # We need to use the same instances for all forms so they don't overwrite each other
-    user = request.user
-    profile = UserProfile.get_for_user(user)
-
-    # Panels
-    panels = [
-        NameEmailSettingsPanel(request, user, profile),
-        AvatarSettingsPanel(request, user, profile),
-        NotificationsSettingsPanel(request, user, profile),
-        LocaleSettingsPanel(request, user, profile),
-        ThemeSettingsPanel(request, user, profile),
-        ChangePasswordPanel(request, user, profile),
-    ]
-    for fn in hooks.get_hooks("register_account_settings_panel"):
-        panel = fn(request, user, profile)
-        if panel and panel.is_active():
-            panels.append(panel)
-
-    panels = [panel for panel in panels if panel.is_active()]
-
-    # Get tabs and order them
-    tabs = list({panel.tab for panel in panels})
-    tabs.sort(key=lambda tab: tab.order)
-
-    # Get dict of tabs to ordered panels
-    panels_by_tab = OrderedDict([(tab, []) for tab in tabs])
-    for panel in panels:
-        panels_by_tab[panel.tab].append(panel)
-    for tab, tab_panels in panels_by_tab.items():
-        tab_panels.sort(key=lambda panel: panel.order)
-
-    panel_forms = [panel.get_form() for panel in panels]
-
-    if request.method == "POST":
+@method_decorator(sensitive_post_parameters(), name="post")
+class AccountView(WagtailAdminTemplateMixin, TemplateView):
+    template_name = "wagtailadmin/account/account.html"
+    page_title = gettext_lazy("Account")
+    header_icon = "user"
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        panels = self.get_panels()
+        context["panels_by_tab"] = self.get_panels_by_tab(panels)
+        context["menu_items"] = self.get_menu_items()
+        context["media"] = self.get_media(panels)
+        context["user"] = self.request.user
+        return context
+
+    def get_panels(self):
+        request = self.request
+        user = self.request.user
+        profile = UserProfile.get_for_user(user)
+        # Panels
+        panels = [
+            NameEmailSettingsPanel(request, user, profile),
+            AvatarSettingsPanel(request, user, profile),
+            NotificationsSettingsPanel(request, user, profile),
+            LocaleSettingsPanel(request, user, profile),
+            ThemeSettingsPanel(request, user, profile),
+            ChangePasswordPanel(request, user, profile),
+        ]
+        for fn in hooks.get_hooks("register_account_settings_panel"):
+            panel = fn(request, user, profile)
+            if panel and panel.is_active():
+                panels.append(panel)
+
+        panels = [panel for panel in panels if panel.is_active()]
+        return panels
+
+    def get_panels_by_tab(self, panels):
+        # Get tabs and order them
+        tabs = list({panel.tab for panel in panels})
+        tabs.sort(key=lambda tab: tab.order)
+
+        # Get dict of tabs to ordered panels
+        panels_by_tab = OrderedDict([(tab, []) for tab in tabs])
+        for panel in panels:
+            panels_by_tab[panel.tab].append(panel)
+        for tab, tab_panels in panels_by_tab.items():
+            tab_panels.sort(key=lambda panel: panel.order)
+        return panels_by_tab
+
+    def get_menu_items(self):
+        # Menu items
+        menu_items = []
+        for fn in hooks.get_hooks("register_account_menu_item"):
+            item = fn(self.request)
+            if item:
+                menu_items.append(item)
+        return menu_items
+
+    def get_media(self, panels):
+        panel_forms = [panel.get_form() for panel in panels]
+
+        media = Media()
+        for form in panel_forms:
+            media += form.media
+        return media
+
+    def post(self, request):
+        panel_forms = [panel.get_form() for panel in self.get_panels()]
+        user = self.request.user
+        profile = UserProfile.get_for_user(user)
 
         if all(form.is_valid() or not form.is_bound for form in panel_forms):
             with transaction.atomic():
@@ -282,26 +318,7 @@ def account(request):
 
             return redirect("wagtailadmin_account")
 
-    media = Media()
-    for form in panel_forms:
-        media += form.media
-
-    # Menu items
-    menu_items = []
-    for fn in hooks.get_hooks("register_account_menu_item"):
-        item = fn(request)
-        if item:
-            menu_items.append(item)
-
-    return TemplateResponse(
-        request,
-        "wagtailadmin/account/account.html",
-        {
-            "panels_by_tab": panels_by_tab,
-            "menu_items": menu_items,
-            "media": media,
-        },
-    )
+        return TemplateResponse(request, self.template_name, self.get_context_data())
 
 
 class PasswordResetEnabledViewMixin:


Test patch:
diff --git a/wagtail/admin/tests/test_account_management.py b/wagtail/admin/tests/test_account_management.py
--- a/wagtail/admin/tests/test_account_management.py
+++ b/wagtail/admin/tests/test_account_management.py
@@ -16,7 +16,7 @@
     get_available_admin_languages,
     get_available_admin_time_zones,
 )
-from wagtail.admin.views.account import account, profile_tab
+from wagtail.admin.views.account import AccountView, profile_tab
 from wagtail.images.tests.utils import get_test_image_file
 from wagtail.test.utils import WagtailTestUtils
 from wagtail.users.models import UserProfile
@@ -605,7 +605,7 @@ def test_change_theme_post(self):
     def test_sensitive_post_parameters(self):
         request = RequestFactory().post("wagtailadmin_account", data={})
         request.user = self.user
-        account(request)
+        AccountView.as_view()(request)
         self.assertTrue(hasattr(request, "sensitive_post_parameters"))
         self.assertEqual(request.sensitive_post_parameters, "__ALL__")