diff --git a/hypha/apply/categories/admin_helpers.py b/hypha/apply/categories/admin_helpers.py index 7afbcfe26d20dc5746586bb9e21802ca47adb46d..a6c9b78f0616c5612de8fa2f8713be4a0227605e 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 be23858d82acba3d2b4ac34c29790fcc482c2e9f..1e00c06b4b6f54d02e8590d029699a80296c51ed 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 e36f31b6925072c52fd05b26b68eb1363ac72f1a..750d24735e369fcfea751e188eddddc3dd533fa1 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 f9a433c7bf5c1438e3cd8e6a1b46cf268e8b2593..4f0fa993131f15e5c5e6f114739c5ad0a1b03a66 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 1cbca30a30afab55df5027f8682ba9dd2699172a..4cc0acf95a262209728d0d57eeb539a2f6e0dd25 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 0000000000000000000000000000000000000000..b776d4ac93218fc2061d0db4acd125b3ba9c9d70 --- /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 255e85b59a185d3818f8d4b5230becb6078f638a..742fa0b7bcd5075828d8bbb05134d1cc635ab070 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 9c7c1c70d54539982cfc68805520f8bff06b16ee..aa2ffb2e8158c314693d91e21e99fe15c83ae54b 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 756cb4a20d951cbeb514c2c5a63d57ef86c4bd3f..7c0559bdbb1957a9562c17aeb4ca51b3827f59fb 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 d1232e0537892b432c9affb9ec24bbda1eca8f6b..28a15c9ba4422a88b044aaa59e77f1a31c25ec03 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