Files
old-saburly-wagtail-web/env/lib/python3.10/site-packages/wagtail/admin/views/generic/base.py
2024-08-27 20:33:44 +02:00

478 lines
17 KiB
Python

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 <template> block so that
# we can replace the existing filters.
context["render_filters_fragment"] = (
self.request.GET.get("_w_filter_fragment")
and self.filters
and self.results_only
)
context["render_buttons_fragment"] = (
context.get("header_buttons") and self.results_only
)
return context