from collections import namedtuple from django.contrib.admin.utils import quote, unquote from django.core.exceptions import ImproperlyConfigured from django.db import models from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic.base import ContextMixin, TemplateResponseMixin from django.views.generic.list import BaseListView from django_filters.filters import ( ChoiceFilter, DateFromToRangeFilter, ModelChoiceFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, ) from wagtail.admin import messages from wagtail.admin.ui.tables import Column, Table from wagtail.admin.utils import get_valid_next_url_from_request from wagtail.admin.widgets.button import ButtonWithDropdown from wagtail.utils.utils import flatten_choices class WagtailAdminTemplateMixin(TemplateResponseMixin, ContextMixin): """ Mixin for views that render a template response using the standard Wagtail admin page furniture. Provides accessors for page title, subtitle and header icon. """ page_title = "" page_subtitle = "" header_icon = "" # Breadcrumbs are opt-in until we have a design that can be consistently applied _show_breadcrumbs = False breadcrumbs_items = [{"url": reverse_lazy("wagtailadmin_home"), "label": _("Home")}] template_name = "wagtailadmin/generic/base.html" header_buttons = [] header_more_buttons = [] def get_page_title(self): return self.page_title def get_page_subtitle(self): return self.page_subtitle def get_header_title(self): title = self.get_page_title() subtitle = self.get_page_subtitle() if subtitle: title = f"{title}: {subtitle}" return title def get_header_icon(self): return self.header_icon def get_breadcrumbs_items(self): return self.breadcrumbs_items def get_header_buttons(self): buttons = sorted(self.header_buttons) more_buttons = self.get_header_more_buttons() if more_buttons: buttons.append( ButtonWithDropdown( buttons=more_buttons, icon_name="dots-horizontal", attrs={"aria-label": _("Actions")}, classname="w-h-slim-header", ) ) return buttons def get_header_more_buttons(self): return sorted(self.header_more_buttons) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # These are only used for legacy header.html # and view templates that don't use "wagtailadmin/generic/base.html" context["page_title"] = self.get_page_title() context["page_subtitle"] = self.get_page_subtitle() context["header_icon"] = self.get_header_icon() # Once all appropriate views use "wagtailadmin/generic/base.html" and # the slim_header.html, _show_breadcrumbs can be removed context["header_title"] = self.get_header_title() context["breadcrumbs_items"] = None if self._show_breadcrumbs: context["breadcrumbs_items"] = self.get_breadcrumbs_items() context["header_buttons"] = self.get_header_buttons() return context def get_template_names(self): # Instead of always wrapping self.template_name in a list like # TemplateResponseMixin does, only do so if it's not already a list/tuple. # This allows us to use a list of template names in self.template_name. if isinstance(self.template_name, (list, tuple)): return self.template_name return super().get_template_names() class BaseObjectMixin: """Mixin for views that make use of a model instance.""" model = None pk_url_kwarg = "pk" def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.pk = self.get_pk() self.object = self.get_object() self.model_opts = self.object._meta def get_pk(self): return unquote(str(self.kwargs[self.pk_url_kwarg])) def get_base_object_queryset(self): return self.model._default_manager.all() def get_object(self): if not self.model: raise ImproperlyConfigured( "Subclasses of wagtail.admin.views.generic.base.BaseObjectMixin must provide a " "model attribute or a get_object method" ) return get_object_or_404(self.get_base_object_queryset(), pk=self.pk) class BaseOperationView(BaseObjectMixin, View): """Base view to perform an operation on a model instance using a POST request.""" success_message = None success_message_extra_tags = "" success_url_name = None def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.next_url = get_valid_next_url_from_request(request) def perform_operation(self): raise NotImplementedError def get_success_message(self): return self.success_message def add_success_message(self): success_message = self.get_success_message() if success_message: messages.success( self.request, success_message, extra_tags=self.success_message_extra_tags, ) def get_success_url(self): if not self.success_url_name: raise ImproperlyConfigured( "Subclasses of wagtail.admin.views.generic.base.BaseOperationView must provide a " "success_url_name attribute or a get_success_url method" ) if self.next_url: return self.next_url return reverse(self.success_url_name, args=[quote(self.object.pk)]) def post(self, request, *args, **kwargs): self.perform_operation() self.add_success_message() return redirect(self.get_success_url()) # Represents a django-filters filter that is currently in force on a listing queryset ActiveFilter = namedtuple( "ActiveFilter", ["auto_id", "field_label", "value", "removed_filter_url"] ) class BaseListingView(WagtailAdminTemplateMixin, BaseListView): template_name = "wagtailadmin/generic/listing.html" results_template_name = "wagtailadmin/generic/listing_results.html" results_only = False # If true, just render the results as an HTML fragment table_class = Table table_classname = None columns = [Column("__str__", label=_("Title"))] index_url_name = None index_results_url_name = None page_kwarg = "p" default_ordering = None filterset_class = None def get_template_names(self): if self.results_only: if isinstance(self.results_template_name, (list, tuple)): return self.results_template_name return [self.results_template_name] else: return super().get_template_names() @cached_property def filters(self): if self.filterset_class: filterset = self.filterset_class(**self.get_filterset_kwargs()) # Don't use the filterset if it has no fields if filterset.form.fields: return filterset @cached_property def is_filtering(self): # we are filtering if the filter form has changed from its default state return ( self.filters and self.filters.is_valid() and self.filters.form.has_changed() ) def get_filterset_kwargs(self): return { "data": self.request.GET, "request": self.request, } def filter_queryset(self, queryset): if self.filters and self.filters.is_valid(): queryset = self.filters.filter_queryset(queryset) return queryset def get_url_without_filter_param(self, param): """ Return the index URL with the given filter parameter removed from the query string """ base_url = self.index_results_url.split("?")[0] query_dict = self.request.GET.copy() query_dict.pop(self.page_kwarg, None) # reset pagination to first page if isinstance(param, (list, tuple)): for p in param: query_dict.pop(p, None) else: query_dict.pop(param, None) query_dict["_w_filter_fragment"] = 1 return base_url + "?" + query_dict.urlencode() def get_url_without_filter_param_value(self, param, value): """ Return the index URL where the filter parameter with the given value has been removed from the query string, preserving all other values for that parameter """ base_url = self.index_results_url.split("?")[0] query_dict = self.request.GET.copy() query_dict.pop(self.page_kwarg, None) # reset pagination to first page query_dict.setlist( param, [v for v in query_dict.getlist(param) if v != str(value)] ) query_dict["_w_filter_fragment"] = 1 return base_url + "?" + query_dict.urlencode() @cached_property def active_filters(self): filters = [] if not self.filters: return filters for field_name in self.filters.form.changed_data: filter_def = self.filters.filters[field_name] bound_field = self.filters.form[field_name] try: value = self.filters.form.cleaned_data[field_name] except KeyError: continue # invalid filter value if value == bound_field.initial: continue # filter value is the same as the default if isinstance(filter_def, ModelMultipleChoiceFilter): field = filter_def.field for item in value: filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, field.label_from_instance(item), self.get_url_without_filter_param_value( field_name, item.pk ), ) ) elif isinstance(filter_def, MultipleChoiceFilter): choices = flatten_choices(filter_def.field.choices) for item in value: filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, choices.get(str(item), str(item)), self.get_url_without_filter_param_value(field_name, item), ) ) elif isinstance(filter_def, ModelChoiceFilter): field = filter_def.field filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, field.label_from_instance(value), self.get_url_without_filter_param(field_name), ) ) elif isinstance(filter_def, DateFromToRangeFilter): start_date_display = date_format(value.start) if value.start else "" end_date_display = date_format(value.stop) if value.stop else "" widget = filter_def.field.widget filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, "%s - %s" % (start_date_display, end_date_display), self.get_url_without_filter_param( [ widget.suffixed(field_name, suffix) for suffix in widget.suffixes ] ), ) ) elif isinstance(filter_def, ChoiceFilter): choices = flatten_choices(filter_def.field.choices) filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, choices.get(str(value), str(value)), self.get_url_without_filter_param(field_name), ) ) else: filters.append( ActiveFilter( bound_field.auto_id, filter_def.label, str(value), self.get_url_without_filter_param(field_name), ) ) return filters def get_valid_orderings(self): orderings = [] for col in self.columns: if col.sort_key: orderings.append(col.sort_key) orderings.append("-%s" % col.sort_key) return orderings @cached_property def is_explicitly_ordered(self): return "ordering" in self.request.GET def get_ordering(self): ordering = self.request.GET.get("ordering", self.default_ordering) if ordering not in self.get_valid_orderings(): ordering = self.default_ordering return ordering @cached_property def ordering(self): return self.get_ordering() def order_queryset(self, queryset): if not self.ordering: return queryset ordering = self.ordering if not isinstance(ordering, (list, tuple)): ordering = (ordering,) return queryset.order_by(*ordering) def get_base_queryset(self): if self.queryset is not None: queryset = self.queryset if isinstance(queryset, models.QuerySet): queryset = queryset.all() elif self.model is not None: queryset = self.model._default_manager.all() else: raise ImproperlyConfigured( "%(cls)s is missing a QuerySet. Define " "%(cls)s.model, %(cls)s.queryset, or override " "%(cls)s.get_queryset()." % {"cls": self.__class__.__name__} ) return queryset def get_queryset(self): # Instead of calling super().get_queryset(), we copy the initial logic from Django's # MultipleObjectMixin into get_base_queryset(). This allows us to perform additional steps # before the ordering step (such as annotations), and funnel the call to get_ordering() # through the cached property self.ordering so that we don't have to worry about calling # get_ordering() multiple times. # https://github.com/django/django/blob/stable/4.1.x/django/views/generic/list.py#L22-L47 queryset = self.get_base_queryset() queryset = self.order_queryset(queryset) queryset = self.filter_queryset(queryset) return queryset def get_table_kwargs(self): return { "ordering": self.ordering, "classname": self.table_classname, "base_url": self.index_url, } def get_table(self, object_list): return self.table_class( self.columns, object_list, **self.get_table_kwargs(), ) @cached_property def index_url(self): return self.get_index_url() def get_index_url(self): if self.index_url_name: return reverse(self.index_url_name) @cached_property def index_results_url(self): return self.get_index_results_url() def get_index_results_url(self): if self.index_results_url_name: return reverse(self.index_results_url_name) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) table = self.get_table(context["object_list"]) context["index_url"] = self.index_url context["index_results_url"] = self.index_results_url context["table"] = table context["media"] = table.media # On Django's BaseListView, a listing where pagination is applied, but the results # only run to a single page, is considered is_paginated=False. Override this to # always consider a listing to be paginated if pagination is applied. This ensures # that we output "Page 1 of 1" as is standard in Wagtail. context["is_paginated"] = context["page_obj"] is not None if context["is_paginated"]: context["items_count"] = context["paginator"].count else: context["items_count"] = len(context["object_list"]) if self.filters: context["filters"] = self.filters context["is_filtering"] = self.is_filtering context["media"] += self.filters.form.media # If we're rendering the results as an HTML fragment, the caller can pass a _w_filter_fragment=1 # URL parameter to indicate that the filters should be rendered as a