From b006b96d76b6af37c7807a80d052f371027ebd15 Mon Sep 17 00:00:00 2001
From: Saurabh Kumar <theskumar@users.noreply.github.com>
Date: Sun, 31 Mar 2024 03:55:38 +0800
Subject: [PATCH] Upgrade Wagtail to 5.2.x LTS (#3686)

- Update requirements
- Replace classnames with classname
- update wagtail admin page of `settings -> users`

closes #3674
---
 hypha/apply/categories/admin_helpers.py       |  14 +-
 hypha/apply/funds/admin_helpers.py            |  16 +-
 hypha/apply/review/admin_helpers.py           |   4 +-
 hypha/apply/users/admin_views.py              | 229 +++++++-----------
 .../templates/wagtailusers/users/index.html   |  12 +-
 .../templates/wagtailusers/users/list.html    |  67 +++++
 .../templates/wagtailusers/users/results.html |  19 +-
 hypha/apply/users/wagtail_hooks.py            |   4 +-
 hypha/static_src/sass/wagtail_users_list.scss |  62 +++--
 requirements.txt                              |   2 +-
 10 files changed, 221 insertions(+), 208 deletions(-)
 create mode 100644 hypha/apply/users/templates/wagtailusers/users/list.html

diff --git a/hypha/apply/categories/admin_helpers.py b/hypha/apply/categories/admin_helpers.py
index 7afbcfe26..a6c9b78f0 100644
--- a/hypha/apply/categories/admin_helpers.py
+++ b/hypha/apply/categories/admin_helpers.py
@@ -10,11 +10,11 @@ class MetaTermButtonHelper(ButtonHelper):
             return
         return super().delete_button(pk, *args, **kwargs)
 
-    def prepare_classnames(self, start=None, add=None, exclude=None):
-        """Parse classname sets into final css classess list."""
-        classnames = start or []
-        classnames.extend(add or [])
-        return self.finalise_classname(classnames, exclude or [])
+    def prepare_classname(self, start=None, add=None, exclude=None):
+        """Parse classname sets into final css classes list."""
+        classname = start or []
+        classname.extend(add or [])
+        return self.finalise_classname(classname, exclude or [])
 
     def add_child_button(self, pk, child_verbose_name, **kwargs):
         """Build a add child button, to easily add a child under meta term."""
@@ -26,13 +26,13 @@ class MetaTermButtonHelper(ButtonHelper):
         ):
             return
 
-        classnames = self.prepare_classnames(
+        classname = self.prepare_classname(
             start=self.edit_button_classnames + ["icon", "icon-plus"],
             add=kwargs.get("classnames_add"),
             exclude=kwargs.get("classnames_exclude"),
         )
         return {
-            "classname": classnames,
+            "classname": classname,
             "label": "Add %s %s" % (child_verbose_name, self.verbose_name),
             "title": "Add %s %s under this one"
             % (child_verbose_name, self.verbose_name),
diff --git a/hypha/apply/funds/admin_helpers.py b/hypha/apply/funds/admin_helpers.py
index be23858d8..1e00c06b4 100644
--- a/hypha/apply/funds/admin_helpers.py
+++ b/hypha/apply/funds/admin_helpers.py
@@ -40,8 +40,8 @@ class RoundFundChooserView(ChooseParentView):
 
 class ButtonsWithPreview(PageButtonHelper):
     def preview_button(self, obj, classnames_add, classnames_exclude):
-        classnames = self.copy_button_classnames + classnames_add
-        cn = self.finalise_classname(classnames, classnames_exclude)
+        classname = self.copy_button_classnames + classnames_add
+        cn = self.finalise_classname(classname, classnames_exclude)
         return {
             "url": reverse("wagtailadmin_pages:view_draft", args=(obj.id,)),
             "label": "Preview",
@@ -102,19 +102,19 @@ class RoundStateListFilter(admin.SimpleListFilter):
 
 class ApplicationFormButtonHelper(ButtonHelper):
     def prepare_classnames(self, start=None, add=None, exclude=None):
-        """Parse classname sets into final css classess list."""
-        classnames = start or []
-        classnames.extend(add or [])
-        return self.finalise_classname(classnames, exclude or [])
+        """Parse classname sets into final css classes list."""
+        classname = start or []
+        classname.extend(add or [])
+        return self.finalise_classname(classname, exclude or [])
 
     def copy_form_button(self, pk, form_name, **kwargs):
-        classnames = self.prepare_classnames(
+        classname = self.prepare_classnames(
             start=self.edit_button_classnames,
             add=kwargs.get("classnames_add"),
             exclude=kwargs.get("classnames_exclude"),
         )
         return {
-            "classname": classnames,
+            "classname": classname,
             "label": "Copy",
             "title": f"Copy {form_name}",
             "url": self.url_helper.get_action_url("copy_form", admin.utils.quote(pk)),
diff --git a/hypha/apply/review/admin_helpers.py b/hypha/apply/review/admin_helpers.py
index e36f31b69..750d24735 100644
--- a/hypha/apply/review/admin_helpers.py
+++ b/hypha/apply/review/admin_helpers.py
@@ -3,8 +3,8 @@ from wagtail.contrib.modeladmin.helpers import PageButtonHelper
 
 class ButtonsWithClone(PageButtonHelper):
     def clone_button(self, obj, classnames_add, classnames_exclude):
-        classnames = self.copy_button_classnames + classnames_add
-        cn = self.finalise_classname(classnames, classnames_exclude)
+        classname = self.copy_button_classnames + classnames_add
+        cn = self.finalise_classname(classname, classnames_exclude)
         return {
             "url": self.url_helper.get_action_url("clone", instance_pk=obj.pk),
             "label": "Clone",
diff --git a/hypha/apply/users/admin_views.py b/hypha/apply/users/admin_views.py
index f9a433c7b..4f0fa9931 100644
--- a/hypha/apply/users/admin_views.py
+++ b/hypha/apply/users/admin_views.py
@@ -1,23 +1,15 @@
-import csv
-import os
-
 import django_filters
-from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
-from django.core.paginator import Paginator
-from django.db.models import Q
-from django.http import HttpResponse
-from django.shortcuts import get_object_or_404
+from django.db.models import CharField, Q, Value
+from django.db.models.functions import Coalesce, Lower, NullIf
 from django.template.defaultfilters import mark_safe
-from django.template.response import TemplateResponse
-from django.utils.translation import gettext as _
-from django.views.decorators.vary import vary_on_headers
-from wagtail.admin.auth import any_permission_required
 from wagtail.admin.filters import WagtailFilterSet
-from wagtail.admin.forms.search import SearchForm
 from wagtail.compat import AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME
-from wagtail.users.views.groups import GroupViewSet, IndexView
+from wagtail.users.views.groups import GroupViewSet
+from wagtail.users.views.groups import IndexView as GroupIndexView
+from wagtail.users.views.users import Index as UserIndexView
+from wagtail.users.views.users import get_users_filter_query
 
 from .models import GroupDesc
 
@@ -35,40 +27,6 @@ delete_user_perm = "{0}.delete_{1}".format(
 )
 
 
-def create_csv(users_list):
-    base_path = os.path.join(settings.PROJECT_DIR, "../media")
-    filename = "users.csv"
-    with open(os.path.join(base_path + "/" + filename), "w+") as file:
-        fieldnames = ["full_name", "email", "status", "role"]
-        writer = csv.DictWriter(file, fieldnames=fieldnames)
-        writer.writeheader()
-        for user in users_list:
-            writer.writerow(user)
-    return base_path + "/" + filename
-
-
-def export(users):
-    users_list = []
-    for user in users:
-        if user.is_superuser:
-            user.roles.insert(0, "Admin")
-        roles = ",".join(user.roles)
-        user_data = {
-            "full_name": user.full_name,
-            "email": user.email,
-            "status": "Active" if user.is_active else "Inactive",
-            "role": roles,
-        }
-        users_list.append(user_data)
-    filepath = create_csv(users_list)
-    with open(filepath, "rb") as file:
-        response = HttpResponse(file.read(), content_type="text/csv")
-        response["Content-Disposition"] = "inline; filename=" + os.path.basename(
-            filepath
-        )
-        return response
-
-
 class UserFilterSet(WagtailFilterSet):
     STATUS_CHOICES = (
         ("inactive", "INACTIVE"),
@@ -83,7 +41,7 @@ class UserFilterSet(WagtailFilterSet):
 
     class Meta:
         model = User
-        fields = ["roles", "status"]
+        fields = ["roles", "status", "is_superuser"]
 
     def filter_by_roles(self, queryset, name, value):
         queryset = queryset.filter(groups__name=value)
@@ -97,117 +55,96 @@ class UserFilterSet(WagtailFilterSet):
         return queryset
 
 
-@any_permission_required(add_user_perm, change_user_perm, delete_user_perm)
-@vary_on_headers("X-Requested-With")
-def index(request, *args):
+class CustomUserIndexView(UserIndexView):
     """
-    Override wagtail's users index view to filter by full_name
-    https://github.com/wagtail/wagtail/blob/af69cb4a544a1b9be1339546be62ff54b389730e/wagtail/users/views/users.py#L47
+    Override wagtail's users index view to filter by full_name. This view
+    also allows for the addition of custom fields to the list_export
+    and list filtering.
     """
-    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 = Q()
 
-            for term in q.split():
-                if "username" in model_fields:
-                    conditions |= Q(username__icontains=term)
-
-                if "first_name" in model_fields:
-                    conditions |= Q(first_name__icontains=term)
-
-                if "last_name" in model_fields:
-                    conditions |= Q(last_name__icontains=term)
+    list_export = [
+        "email",
+        "full_name",
+        "slack",
+        "roles",
+        "is_superuser",
+        "is_active",
+        "date_joined",
+        "last_login",
+    ]
+
+    default_ordering = "name"
+    list_filter = ("is_active",)
+
+    filterset_class = UserFilterSet
+
+    def get_context_data(self, *args, object_list=None, **kwargs):
+        ctx = super().get_context_data(*args, object_list=object_list, **kwargs)
+        ctx["filters"] = self.get_filterset_class()(
+            self.request.GET, queryset=self.get_queryset(), request=self.request
+        )
+        return ctx
 
-                if "email" in model_fields:
-                    conditions |= Q(email__icontains=term)
+    def get_queryset(self):
+        """
+        Override the original queryset to filter by full_name, mostly copied from
+        super().get_queryset() with the addition of the custom code
+        """
+        model_fields = set(self.model_fields)
+        if self.is_searching:
+            conditions = get_users_filter_query(self.search_query, model_fields)
 
-                # filter by full_name
+            # == custom code
+            for term in self.search_query.split():
                 if "full_name" in model_fields:
                     conditions |= Q(full_name__icontains=term)
+            # == custom code end
 
-            users = User.objects.filter(group_filter & conditions)
-    else:
-        form = SearchForm(placeholder=_("Search users"))
+            users = User.objects.filter(self.group_filter & conditions)
+        else:
+            users = User.objects.filter(self.group_filter)
 
-    if not is_searching:
-        users = User.objects.filter(group_filter).order_by("-is_active", "full_name")
+        if self.locale:
+            users = users.filter(locale=self.locale)
 
-    filters = None
-    if not group:
-        filters = UserFilterSet(request.GET, queryset=users, request=request)
-        users = filters.qs
+        users = users.annotate(
+            display_name=Coalesce(
+                NullIf("full_name", Value("")), "email", output_field=CharField()
+            ),
+        )
 
-    if "export" in request.GET:
-        file = export(users)
-        return file
+        if "wagtail_userprofile" in model_fields:
+            users = users.select_related("wagtail_userprofile")
 
-    if "ordering" in request.GET:
-        ordering = request.GET["ordering"]
+        # == custom code
+        if "full_name" in model_fields:
+            users = users.order_by(Lower("display_name"))
+        # == custom code end
 
-        if ordering == "username":
+        if self.get_ordering() == "username":
             users = users.order_by(User.USERNAME_FIELD)
-        elif ordering == "status":
-            users = users.order_by("is_active")
-    else:
-        ordering = "name"
-
-    user_count = users.count()
-    paginator = Paginator(users.select_related("wagtail_userprofile"), per_page=20)
-    users = paginator.get_page(request.GET.get("p"))
-
-    if request.headers.get("x-requested-with") == "XMLHttpRequest":
-        return TemplateResponse(
-            request,
-            "wagtailusers/users/results.html",
-            {
-                "users": users,
-                "user_count": user_count,
-                "is_searching": is_searching,
-                "query_string": q,
-                "filters": filters,
-                "ordering": ordering,
-                "app_label": User._meta.app_label,
-                "model_name": User._meta.model_name,
-            },
-        )
-    else:
-        return TemplateResponse(
-            request,
-            "wagtailusers/users/index.html",
-            {
-                "group": group,
-                "search_form": form,
-                "users": users,
-                "user_count": user_count,
-                "is_searching": is_searching,
-                "ordering": ordering,
-                "query_string": q,
-                "filters": filters,
-                "app_label": User._meta.app_label,
-                "model_name": User._meta.model_name,
-            },
-        )
+
+        if self.get_ordering() == "name":
+            users = users.order_by(Lower("display_name"))
+
+        # == custom code
+        if not self.group:
+            filterset_class = self.get_filterset_class()
+            users = filterset_class(
+                self.request.GET, queryset=users, request=self.request
+            ).qs
+        # == end custom code
+
+        return users
 
 
-class CustomGroupIndexView(IndexView):
+class CustomGroupIndexView(GroupIndexView):
     """
     Overriding of wagtail.users.views.groups.IndexView to allow for the addition of help text to the displayed group names. This is done utilizing the get_queryset method
     """
 
+    model = Group
+
     def get_queryset(self):
         """
         Overriding the normal queryset that would return all Group objects, this returnd an iterable of groups with custom names containing HTML help text.
@@ -238,13 +175,11 @@ class CustomGroupViewSet(GroupViewSet):
 
     @property
     def users_view(self):
-        return index
+        return CustomUserIndexView.as_view()
+
+    @property
+    def users_results_view(self):
+        return CustomUserIndexView.as_view(results_only=True)
 
     def __init__(self, name, **kwargs):
         super().__init__(name, **kwargs)
-
-    @property
-    def index_view(self):
-        return self.index_view_class.as_view(
-            **self.get_index_view_kwargs(),
-        )
diff --git a/hypha/apply/users/templates/wagtailusers/users/index.html b/hypha/apply/users/templates/wagtailusers/users/index.html
index 1cbca30a3..4cc0acf95 100644
--- a/hypha/apply/users/templates/wagtailusers/users/index.html
+++ b/hypha/apply/users/templates/wagtailusers/users/index.html
@@ -1,6 +1,16 @@
 {% extends "wagtailusers/users/index.html" %}
-{% load static %}
+{% load i18n static wagtailadmin_tags %}
 
 {% block extra_css %}
     <link rel="stylesheet" href="{% static 'css/wagtail_users_list.css' %}">
 {% endblock %}
+
+{% block listing %}
+    <div class="">
+        <div id="listing-results" class="users">
+            {% include "wagtailusers/users/results.html" %}
+        </div>
+        {% trans "Select all users in listing" as select_all_text %}
+        {% include 'wagtailadmin/bulk_actions/footer.html' with select_all_obj_text=select_all_text app_label=app_label model_name=model_name objects=page_obj %}
+    </div>
+{% endblock %}
diff --git a/hypha/apply/users/templates/wagtailusers/users/list.html b/hypha/apply/users/templates/wagtailusers/users/list.html
new file mode 100644
index 000000000..b776d4ac9
--- /dev/null
+++ b/hypha/apply/users/templates/wagtailusers/users/list.html
@@ -0,0 +1,67 @@
+{% load i18n l10n wagtailusers_tags wagtailadmin_tags %}
+<table class="listing">
+    <thead>
+        <tr>
+            {% include 'wagtailadmin/bulk_actions/select_all_checkbox_cell.html' %}
+            <th class="name">
+                {% if ordering == "name" %}
+                    <span class="icon icon-arrow-down-after">
+                        {% trans "Display Name" %}
+                    </span>
+                {% else %}
+                    <a href="{% url 'wagtailusers_users:index' %}?ordering=name" class="icon icon-arrow-down-after">
+                        {% trans "Display Name" %}
+                    </a>
+                {% endif %}
+            </th>
+            <th class="username">
+                {% if ordering == "username" %}
+                    <a href="{% url 'wagtailusers_users:index' %}" class="icon icon-arrow-down-after teal">
+                        {% trans "Email" %}
+                    </a>
+                {% else %}
+                    <a href="{% url 'wagtailusers_users:index' %}?ordering=username" class="icon icon-arrow-down-after">
+                        {% trans "Email" %}
+                    </a>
+                {% endif %}
+            </th>
+            <th class="level">{% trans "Roles" %}</th>
+            <th class="status">{% trans "Status" %}</th>
+            <th class="last-login">{% trans "Last Login" %}</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for user in users %}
+            <tr>
+                {% include "wagtailadmin/bulk_actions/listing_checkbox_cell.html" with obj_type="user" obj=user aria_labelledby_prefix="user_" aria_labelledby=user.pk|unlocalize aria_labelledby_suffix="_title" %}
+                <td id="user_{{ user.pk|unlocalize }}_title" class="title" valign="top">
+                    <div class="title-wrapper">
+                        {% comment %} {% avatar user=user size="small" %} {% endcomment %}
+                        <a href="{% url 'wagtailusers_users:edit' user.pk %}">{{ user|user_display_name }}</a>
+                    </div>
+                    <ul class="actions">
+                        {% user_listing_buttons user %}
+                    </ul>
+                </td>
+                <td class="username" valign="top">{{ user.get_username }}</td>
+                {% comment %} <td class="level" valign="top">{% if user.is_superuser %}{% trans "Admin" %}{% endif %}</td> {% endcomment %}
+                <td class="level" valign="top">
+                    {% if user.is_superuser %}
+                        {% trans "Admin" %}{% if user.roles %}, {% endif %}
+                    {% endif %}
+                    {% for role in user.roles %}{{ role }}{% if not forloop.last %}, {% endif %} {% endfor %}
+                </td>
+                <td class="status" valign="top">
+                    {% if user.is_active %}
+                        {% trans "Active" as status_label %}
+                        {% status status_label classname="w-status--primary" %}
+                    {% else %}
+                        {% trans "Inactive" as status_label %}
+                        {% status status_label %}
+                    {% endif %}
+                </td>
+                <td>{% if user.last_login %}{% human_readable_date user.last_login %}{% endif %}</td>
+            </tr>
+        {% endfor %}
+    </tbody>
+</table>
diff --git a/hypha/apply/users/templates/wagtailusers/users/results.html b/hypha/apply/users/templates/wagtailusers/users/results.html
index 255e85b59..742fa0b7b 100644
--- a/hypha/apply/users/templates/wagtailusers/users/results.html
+++ b/hypha/apply/users/templates/wagtailusers/users/results.html
@@ -17,7 +17,7 @@
             {% include "wagtailusers/users/list.html" %}
 
             {# call pagination_nav with no linkurl, to generate general-purpose links like <a href="?p=2"> #}
-            {% include "wagtailadmin/shared/pagination_nav.html" with items=users %}
+            {% include "wagtailadmin/shared/pagination_nav.html" with items=page_obj %}
         {% else %}
             {% if is_searching %}
                 <h2 role="alert">{% blocktrans trimmed %}Sorry, no users match "<em>{{ query_string }}</em>"{% endblocktrans %}</h2>
@@ -36,21 +36,6 @@
         {% endif %}
     </div>
     {% if filters %}
-        <div class="users-list__filters">
-            <h2>{% trans 'Filter' %}</h2>
-            <form method="get">
-                {% for filter in filters.form %}
-                    {{ filter.label_tag }}
-                    {{ filter }}
-                    {{ filter.errors }}
-                {% endfor %}
-                <button class="button button-longrunning" type="submit">{% icon name="spinner" %}{% trans 'Apply filters' %}</button>
-            </form>
-            {%with request.get_full_path as path%}
-                <div class="users-list__export">
-                    <button class="button"><a href="{{path}}{% if '?' in path %}&{%else%}?{% endif %}export=true">{% trans 'Export Users' %}</a></button>
-                </div>
-            {%endwith%}
-        </div>
+        {% include "wagtailadmin/shared/filters.html" %}
     {% endif %}
 </div>
diff --git a/hypha/apply/users/wagtail_hooks.py b/hypha/apply/users/wagtail_hooks.py
index 9c7c1c70d..aa2ffb2e8 100644
--- a/hypha/apply/users/wagtail_hooks.py
+++ b/hypha/apply/users/wagtail_hooks.py
@@ -4,14 +4,14 @@ from wagtail.models import Site
 
 from hypha.apply.activity.messaging import MESSAGES, messenger
 
-from .admin_views import CustomGroupViewSet, index
+from .admin_views import CustomGroupViewSet, CustomUserIndexView
 from .utils import send_activation_email
 
 
 @hooks.register("register_admin_urls")
 def register_admin_urls():
     return [
-        re_path(r"^users/$", index, name="index"),
+        re_path(r"^users/$", CustomUserIndexView.as_view(), name="index"),
     ]
 
 
diff --git a/hypha/static_src/sass/wagtail_users_list.scss b/hypha/static_src/sass/wagtail_users_list.scss
index 756cb4a20..7c0559bdb 100644
--- a/hypha/static_src/sass/wagtail_users_list.scss
+++ b/hypha/static_src/sass/wagtail_users_list.scss
@@ -1,38 +1,54 @@
-// Abstracts
-@import "abstracts/functions";
-@import "abstracts/mixins";
-@import "abstracts/variables";
-
 .users-list {
-    display: grid;
-    grid-template-columns: auto;
-    grid-column-gap: 50px;
+    padding-left: 1rem;
+    padding-right: 1rem;
 
-    &--has-filters {
-        grid-template-columns: auto 250px;
+    li:has(input[type="radio"]) {
+        display: flex;
+        align-items: center;
+        padding-top: 0.25rem;
+        padding-bottom: 0.25rem;
     }
 
     &__results {
-        grid-column-start: col-start 1 col-end 2;
+        flex: 1;
     }
 
-    &__filters {
-        grid-column-start: col-start -2 col-end -1;
+    &--has-filters {
+        display: flex;
+        flex-direction: column-reverse;
 
-        button[type="submit"] {
-            display: block;
-            margin-block-start: 20px;
+        @media screen and (min-width: 1000px) {
+            gap: 2rem;
+            flex-direction: row;
         }
     }
 
-    &__export {
-        button {
-            display: block;
-            margin-block-start: 20px;
-        }
+    .filterable {
+        &__filters {
+            h2 {
+                display: none;
+            }
+
+            form {
+                display: flex;
+                flex-direction: row-reverse;
+                gap: 1rem;
+                align-items: center;
+                flex-wrap: wrap;
+                justify-content: flex-end;
+            }
+
+            @media screen and (min-width: 1000px) {
+                h2 {
+                    display: block;
+                }
 
-        a {
-            color: $color--white;
+                form {
+                    flex-direction: column-reverse;
+                    align-items: flex-start;
+                    gap: 0;
+                }
+            }
         }
     }
 }
diff --git a/requirements.txt b/requirements.txt
index d1232e053..28a15c9ba 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -55,7 +55,7 @@ tablib==3.5.0
 tomd==0.1.3
 wagtail-cache==2.4.0
 wagtail-purge==0.3.0
-wagtail==5.1.3
+wagtail==5.2.3
 whitenoise==6.6.0
 xhtml2pdf==0.2.15
 xmltodict==0.13.0
-- 
GitLab