import re import urllib.parse from django.conf import settings from django.contrib.admin.utils import quote, unquote from django.core.exceptions import ( ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied, ) from django.core.paginator import InvalidPage, Paginator from django.db.models import Model from django.forms.models import modelform_factory from django.http import Http404 from django.template.loader import render_to_string from django.template.response import TemplateResponse from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.views.generic.base import ContextMixin, View from wagtail import hooks from wagtail.admin.admin_url_finder import AdminURLFinder from wagtail.admin.forms.choosers import ( BaseFilterForm, CollectionFilterMixin, LocaleFilterMixin, SearchFilterMixin, ) from wagtail.admin.modal_workflow import render_modal_workflow from wagtail.admin.ui.tables import Column, Table, TitleColumn from wagtail.coreutils import resolve_model_string from wagtail.models import CollectionMember, TranslatableMixin from wagtail.permission_policies import BlanketPermissionPolicy, ModelPermissionPolicy from wagtail.search.index import class_is_indexed class ModalPageFurnitureMixin(ContextMixin): """ Add icon, page title and page subtitle to the template context """ icon = None page_title = None page_subtitle = None def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( { "header_icon": self.icon, "page_title": self.page_title, "page_subtitle": self.page_subtitle, } ) return context class ModelLookupMixin: """ Allows a class to have a `model` attribute, which can be set as either a model class or a string, and then retrieve it as `model_class` to consistently get back a model class """ model = None @cached_property def model_class(self): if self.model: return resolve_model_string(self.model) class PreserveURLParametersMixin: """ Adds support for passing designated URL parameters from the current request when constructing URLs for links / form actions. """ preserve_url_parameters = ["multiple"] @cached_property def _preserved_param_string(self): params = {} for param in self.preserve_url_parameters: try: params[param] = self.request.GET[param] except KeyError: pass return urllib.parse.urlencode(params) def append_preserved_url_parameters(self, url): """ Given a base URL (which might already include URL parameters), append any URL parameters from the preserve_url_parameters list that are present in the current request URL """ if self._preserved_param_string: if "?" in url: url += "&" + self._preserved_param_string else: url += "?" + self._preserved_param_string return url class CheckboxSelectColumn(Column): cell_template_name = "wagtailadmin/generic/chooser/checkbox_select_cell.html" class BaseChooseView( ModalPageFurnitureMixin, ModelLookupMixin, PreserveURLParametersMixin, ContextMixin, View, ): """ Provides common functionality for views that present a (possibly searchable / filterable) list of objects to choose from """ per_page = 10 ordering = None chosen_url_name = None chosen_multiple_url_name = None results_url_name = None icon = "snippet" page_title = _("Choose") filter_form_class = None template_name = "wagtailadmin/generic/chooser/chooser.html" results_template_name = "wagtailadmin/generic/chooser/results.html" construct_queryset_hook_name = None url_filter_parameters = [] def get_object_list(self): return self.model_class.objects.all() def apply_object_list_ordering(self, objects): if isinstance(self.ordering, (list, tuple)): objects = objects.order_by(*self.ordering) elif self.ordering: objects = objects.order_by(self.ordering) elif objects.ordered: # Preserve the model-level ordering if specified pass else: # fall back on PK to ensure pagination is consistent objects = objects.order_by("pk") return objects def get_filter_form_class(self): if self.filter_form_class: return self.filter_form_class else: bases = [BaseFilterForm] if self.model_class: if class_is_indexed(self.model_class): bases.insert(0, SearchFilterMixin) if issubclass(self.model_class, CollectionMember): bases.insert(0, CollectionFilterMixin) i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False) if i18n_enabled and issubclass(self.model_class, TranslatableMixin): bases.insert(0, LocaleFilterMixin) return type( "FilterForm", tuple(bases), {}, ) def get_filter_form(self): FilterForm = self.get_filter_form_class() return FilterForm(self.request.GET) def filter_object_list(self, objects): filters = {} for filter in self.url_filter_parameters: try: filters[filter] = self.request.GET[filter] except KeyError: pass if filters: objects = objects.filter(**filters) if self.construct_queryset_hook_name: # allow hooks to modify the queryset for hook in hooks.get_hooks(self.construct_queryset_hook_name): objects = hook(objects, self.request) if self.filter_form.is_valid(): objects = self.filter_form.filter(objects) return objects def get_results_url(self): return self.append_preserved_url_parameters(reverse(self.results_url_name)) def get_chosen_multiple_url(self): return self.append_preserved_url_parameters( reverse(self.chosen_multiple_url_name) ) @cached_property def is_multiple_choice(self): return self.request.GET.get("multiple") @property def columns(self): return [self.title_column] @property def title_column(self): if self.is_multiple_choice: return TitleColumn( "title", label=_("Title"), accessor=str, label_prefix="chooser-modal-select", ) else: return TitleColumn( "title", label=_("Title"), accessor=str, get_url=( lambda obj: self.append_preserved_url_parameters( reverse(self.chosen_url_name, args=(quote(obj.pk),)) ) ), link_attrs={"data-chooser-modal-choice": True}, ) @property def checkbox_column(self): return CheckboxSelectColumn( "select", label=_("Select"), width="1%", accessor="pk" ) def get_results_page(self, request): objects = self.get_object_list() objects = self.apply_object_list_ordering(objects) objects = self.filter_object_list(objects) paginator = Paginator(objects, per_page=self.per_page) try: return paginator.page(request.GET.get("p", 1)) except InvalidPage: raise Http404 def get(self, request): self.filter_form = self.get_filter_form() self.results = self.get_results_page(request) columns = self.columns if self.is_multiple_choice: columns.insert(0, self.checkbox_column) self.table = Table(columns, self.results) return self.render_to_response() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) results_url = self.get_results_url() # For result pagination links, we need a version of results_url with parameters removed, # so that the pagination include can append its own parameters via the {% querystring %} template tag results_pagination_url = re.sub(r"\?.*$", "", results_url) context.update( { "results": self.results, "table": self.table, "results_url": results_url, "results_pagination_url": results_pagination_url, "is_searching": self.filter_form.is_searching, "is_filtering_by_collection": self.filter_form.is_filtering_by_collection, "is_multiple_choice": self.is_multiple_choice, "search_query": self.filter_form.search_query, "can_create": self.can_create(), } ) if self.is_multiple_choice: context["chosen_multiple_url"] = self.get_chosen_multiple_url() return context def render_to_response(self): raise NotImplementedError() class CreationFormMixin(ModelLookupMixin, PreserveURLParametersMixin): """ Provides a form class for creating new objects """ creation_form_class = None form_fields = None exclude_form_fields = None creation_form_template_name = "wagtailadmin/generic/chooser/creation_form.html" creation_tab_id = "create" create_action_label = _("Create") create_action_clicked_label = None create_url_name = None permission_policy = None def get_permission_policy(self): if self.permission_policy: return self.permission_policy elif self.model_class and issubclass(self.model_class, Model): return ModelPermissionPolicy(self.model_class) else: return BlanketPermissionPolicy(None) def can_create(self): return self.get_permission_policy().user_has_permission( self.request.user, "add" ) def get_creation_form_class(self): if self.creation_form_class: return self.creation_form_class elif self.form_fields is not None or self.exclude_form_fields is not None: return modelform_factory( self.model_class, fields=self.form_fields, exclude=self.exclude_form_fields, ) def get_creation_form_kwargs(self): kwargs = {} if self.request.method in ("POST", "PUT"): kwargs.update( { "data": self.request.POST, "files": self.request.FILES, } ) return kwargs def get_creation_form(self): form_class = self.get_creation_form_class() if not form_class: return None return form_class(**self.get_creation_form_kwargs()) def get_create_url(self): if not self.create_url_name: raise ImproperlyConfigured( "%r must provide a create_url_name attribute or a get_create_url method" % type(self) ) return self.append_preserved_url_parameters(reverse(self.create_url_name)) def get_creation_form_context_data(self, form): return { "creation_form": form, "create_action_url": self.get_create_url(), "create_action_label": self.create_action_label, "create_action_clicked_label": self.create_action_clicked_label, } class ChooseViewMixin: """ A view that renders a complete modal response for the chooser, including a tab for the object listing and (optionally) a 'create' form """ search_tab_label = _("Search") creation_tab_label = None def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( { "filter_form": self.filter_form, "search_tab_label": self.search_tab_label, "creation_tab_label": self.creation_tab_label or self.create_action_label, } ) if context["can_create"]: creation_form = self.get_creation_form() if creation_form: context.update(self.get_creation_form_context_data(creation_form)) return context def get_response_json_data(self): return { "step": "choose", } # Return the choose view as a ModalWorkflow response def render_to_response(self): return render_modal_workflow( self.request, self.template_name, None, self.get_context_data(), json_data=self.get_response_json_data(), ) class ChooseView(ChooseViewMixin, CreationFormMixin, BaseChooseView): pass class ChooseResultsViewMixin: """ A view that renders just the object listing as an HTML fragment, used to replace the listing when paginating or searching """ # Return just the HTML fragment for the results def render_to_response(self): return TemplateResponse( self.request, self.results_template_name, self.get_context_data(), ) class ChooseResultsView(ChooseResultsViewMixin, CreationFormMixin, BaseChooseView): pass class ChosenResponseMixin: """ Provides methods for returning the chosen object from the modal workflow. """ response_data_title_key = "title" chosen_response_name = "chosen" def get_object_id(self, instance): return instance.pk def get_display_title(self, instance): """ Return a string representation of the given object instance """ return str(instance) def get_edit_item_url(self, instance): return AdminURLFinder(user=self.request.user).get_edit_url(instance) def get_chosen_response_data(self, item): """ Generate the result value to be returned when an object has been chosen """ return { "id": str(self.get_object_id(item)), self.response_data_title_key: self.get_display_title(item), "edit_url": self.get_edit_item_url(item), } def _wrap_chosen_response_data(self, response_data): """ Wrap a response_data JSON payload in a modal workflow response """ return render_modal_workflow( self.request, None, None, None, json_data={"step": self.chosen_response_name, "result": response_data}, ) def get_multiple_chosen_response(self, items): response_data = [self.get_chosen_response_data(item) for item in items] return self._wrap_chosen_response_data(response_data) def get_chosen_response(self, item): """ Return the HTTP response to indicate that an object has been chosen """ response_data = self.get_chosen_response_data(item) if self.request.GET.get("multiple"): # a multiple result was requested but we're only returning one, # so wrap as a list response_data = [response_data] return self._wrap_chosen_response_data(response_data) class ChosenViewMixin(ModelLookupMixin): """ A view that takes an object ID in the URL and returns a modal workflow response indicating that object has been chosen """ def get_object(self, pk): return self.model_class.objects.get(pk=pk) def get(self, request, pk): try: item = self.get_object(unquote(pk)) except ObjectDoesNotExist: raise Http404 return self.get_chosen_response(item) class ChosenView(ChosenViewMixin, ChosenResponseMixin, View): pass class ChosenMultipleViewMixin(ModelLookupMixin): """ A view that takes a list of 'id' URL parameters and returns a modal workflow response indicating that those objects have been chosen """ def get_objects(self, pks): return self.model_class.objects.filter(pk__in=pks) def get(self, request): items = self.get_objects(request.GET.getlist("id")) return self.get_multiple_chosen_response(items) class ChosenMultipleView(ChosenMultipleViewMixin, ChosenResponseMixin, View): pass class CreateViewMixin: """ A view that handles submissions of the 'create' form """ model = None def dispatch(self, request, *args, **kwargs): if not self.can_create(): raise PermissionDenied return super().dispatch(request, *args, **kwargs) def get(self, request): self.form = self.get_creation_form() return self.get_reshow_creation_form_response() def save_form(self, form): return form.save() def post(self, request): self.form = self.get_creation_form() if self.form.is_valid(): object = self.save_form(self.form) return self.get_chosen_response(object) else: return self.get_reshow_creation_form_response() def get_reshow_creation_form_response(self): context = {"view": self} context.update(self.get_creation_form_context_data(self.form)) response_html = render_to_string( self.creation_form_template_name, context, self.request ) return render_modal_workflow( self.request, None, None, None, json_data={ "step": "reshow_creation_form", "htmlFragment": response_html, }, ) class CreateView(CreateViewMixin, CreationFormMixin, ChosenResponseMixin, View): pass