wagtail-generic-chooser
wagtail-generic-chooser
provides base classes for building chooser popups and form widgets for the Wagtail admin, matching the look and feel of Wagtail's built-in choosers for pages, documents, snippets and images.
It differs from existing model chooser add-ons (Naeka/wagtailmodelchooser, neon-jungle/wagtailmodelchooser, springload/wagtailmodelchoosers) in that it is designed to be fully configurable through subclassing - in particular, it can be used on data sources other than Django models, such as REST API endpoints.
It is intended that wagtail-generic-chooser
will be expanded to cover all the functionality of Wagtail's built-in choosers, such as inline object creation forms, and will then be incorporated into Wagtail as the new base implementation of those built-in choosers - this will reduce code duplication and greatly simplify the process of building new admin apps.
Requirements
Wagtail 4.1 or higher
Installation
Run: pip install wagtail-generic-chooser
Then add generic_chooser
to your project's INSTALLED_APPS
.
Usage
wagtail-generic-chooser
's functionality is split into two distinct components: chooser views (the URL endpoints that implement the modal interface for choosing an item) and chooser widgets (form elements that display the currently selected item, with a button that opens up the modal interface to choose a new one). Chooser views can be used independently of chooser widgets; they are used by rich text editors, for example.
Chooser views (model-based)
The generic_chooser.views
module provides a viewset class ModelChooserViewSet
, which can be used to build a modal interface for choosing a Django model instance. Viewsets are Wagtail's way of grouping several related views into a single unit along with their URL configuration; this makes it possible to configure the overall behaviour of a workflow within Wagtail without having to know how that workflow breaks down into individual views.
At minimum, a chooser can be implemented by subclassing ModelChooserViewSet
and setting a model
attribute. Other attributes can be specified to customise the look and feel of the chooser, such as the heading icon and number of items per page. For example, to implement a chooser for bakerydemo's Person
model:
# myapp/views.py
from django.utils.translation import gettext_lazy as _
from generic_chooser.views import ModelChooserViewSet
from bakerydemo.base.models import Person
class PersonChooserViewSet(ModelChooserViewSet):
icon = 'user'
model = Person
page_title = _("Choose a person")
per_page = 10
order_by = 'first_name'
fields = ['first_name', 'last_name', 'job_title']
The viewset can then be registered through Wagtail's register_admin_viewset
hook:
# myapp/wagtail_hooks.py
from wagtail import hooks
from myapp.views import PersonChooserViewSet
@hooks.register('register_admin_viewset')
def register_person_chooser_viewset():
return PersonChooserViewSet('person_chooser', url_prefix='person-chooser')
Chooser views (Django REST Framework-based)
The generic_chooser.views
module also provides a viewset class DRFChooserViewSet
for building choosers based on Django REST Framework API endpoints. Subclasses need to specify an api_base_url
attribute. For example, an API-based chooser for Wagtail's Page model can be implemented as follows:
from django.utils.translation import gettext_lazy as _
from generic_chooser.views import DRFChooserViewSet
class APIPageChooserViewSet(DRFChooserViewSet):
icon = 'page'
page_title = _("Choose a page")
api_base_url = 'http://localhost:8000/api/v2/pages/'
edit_item_url_name = 'wagtailadmin_pages:edit'
is_searchable = True
per_page = 5
title_field_name = 'title'
This viewset can be registered through Wagtail's register_admin_viewset
hook as above.
Creating objects within the chooser
Setting a form_class
attribute on the viewset will add a 'Create' tab containing that form, allowing users to create new objects within the chooser.
For a model-based chooser, this form class should be a ModelForm
, and the form will be shown for all users with 'create' permission on the corresponding model. As a shortcut, a fields
list can be specified in place of form_class
.
class PersonChooserViewSet(ModelChooserViewSet):
# ...
fields = ['first_name', 'last_name', 'job_title']
For a Django REST Framework-based chooser, form_class
must be defined explicitly (i.e. the fields
shortcut is not available) and the object will be created by sending a POST request to the API endpoint consisting of the form's cleaned_data
in JSON format. An API-based equivalent of PersonChooserViewSet
would be:
from django import forms
from django.contrib.admin.utils import quote
from django import forms
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from generic_chooser.views import DRFChooserMixin, DRFChooserViewSet
class PersonChooserMixin(DRFChooserMixin):
def get_edit_item_url(self, item):
# for Wagtail 4.x
return reverse('wagtailsnippets_base_person:edit', args=(quote(item['id']), ))
# for Wagtail <= 3.x
# return reverse('wagtailsnippets:edit', args=('base', 'person', quote(item['id'])))
def get_object_string(self, item):
return "%s %s" % (item['first_name'], item['last_name'])
class PersonForm(forms.Form):
first_name = forms.CharField(required=True)
last_name = forms.CharField(required=True)
job_title = forms.CharField(required=True)
class PersonChooserViewSet(DRFChooserViewSet):
icon = 'user'
api_base_url = 'http://localhost:8000/person-api/'
page_title = _("Choose a person")
per_page = 10
form_class = PersonForm
chooser_mixin_class = PersonChooserMixin
prefix = 'person-chooser'
This example requires the API to be configured with write access enabled, which can be done with a setting such as the following:
REST_FRAMEWORK = {
# Allow unauthenticated write access to the API. You probably don't want to this in production!
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny'
],
'DEFAULT_PAGINATION_CLASS': 'wagtail.api.v2.pagination.WagtailPagination',
'PAGE_SIZE': 100,
}
Customising chooser views
If the configuration options on ModelChooserViewSet
and DRFChooserViewSet
are not sufficient, it's possible to fully customise the chooser behaviour by overriding methods. To do this you'll need to work with the individual class-based views and mixins that make up the viewsets - this is best done by referring to the base implementations in generic_chooser/views.py
. The classes are:
ChooserMixin
- an abstract class providing helper methods shared by all views. These deal with data retrieval, and providing string and ID representations and URLs corresponding to the objects being chosen. To implement a chooser for a different data source besides Django models and Django REST Framework, you'll need to subclass this.ModelChooserMixin
- implementation ofChooserMixin
using a Django model as the data source.DRFChooserMixin
- implementation ofChooserMixin
using a Django REST Framework endpoint as the data source.ChooserListingTabMixin
- handles the behaviour and rendering of the results listing tab, including pagination and searching.ChooserCreateTabMixin
- handles the behaviour and rendering of the 'create' form tabModelChooserCreateTabMixin
- version ofChooserCreateTabMixin
for model formsDRFChooserCreateTabMixin
- version ofChooserCreateTabMixin
for Django REST FrameworkBaseChooseView
- abstract class-based view handling the main chooser UI. Subclasses should extend this and include the mixinsChooserMixin
,ChooserListingTabMixin
andChooserCreateTabMixin
(or suitable subclasses of them).ModelChooseView
,DRFChooseView
- model-based and DRF-based subclasses ofBaseChooseView
BaseChosenView
- class-based view that returns the chosen object as a JSON responseModelChosenView
,DRFChosenView
- model-based and DRF-based subclasses ofBaseChosenView
ChooserViewSet
- common base implementation ofModelChooserViewSet
andDRFChooserViewSet
For example, we may want to extend the PersonChooserViewSet above to return an 'edit this person' URL as part of its JSON response, pointing to the 'wagtailsnippets:edit'
view. Including an 'edit' URL in the response would normally be achieved by setting the edit_item_url_name
attribute on the viewset to a suitable URL route name, but 'wagtailsnippets:edit'
won't work here; this is because edit_item_url_name
expects it to take a single URL parameter, the ID, whereas the snippet edit view also needs to be passed the model's app name and model name. Instead, we can do this by overriding the get_edit_item_url
method on ModelChooserMixin
:
from django.contrib.admin.utils import quote
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from generic_chooser.views import ModelChooserMixin, ModelChooserViewSet
from bakerydemo.base.models import Person
class PersonChooserMixin(ModelChooserMixin):
def get_edit_item_url(self, item):
# for Wagtail 4.x
return reverse('wagtailsnippets_base_person:edit', args=(quote(item.pk), ))
# for Wagtail <= 3.x
# return reverse('wagtailsnippets:edit', args=('base', 'person', quote(item.pk)))
class PersonChooserViewSet(ModelChooserViewSet):
icon = 'user'
model = Person
page_title = _("Choose a person")
per_page = 10
order_by = 'first_name'
chooser_mixin_class = PersonChooserMixin
Chooser widgets (model-based)
The generic_chooser.widgets
module provides an AdminChooser
widget to be subclassed. For example, a widget for the Person
model, using the chooser views defined above, can be implemented as follows:
from django.contrib.admin.utils import quote
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from generic_chooser.widgets import AdminChooser
from bakerydemo.base.models import Person
class PersonChooser(AdminChooser):
choose_one_text = _('Choose a person')
choose_another_text = _('Choose another person')
link_to_chosen_text = _('Edit this person')
model = Person
choose_modal_url_name = 'person_chooser:choose'
icon = 'user'
def get_edit_item_url(self, item):
# for Wagtail 4.x
return reverse('wagtailsnippets_base_person:edit', args=(quote(item.pk), ))
# for Wagtail <= 3.x
# return reverse('wagtailsnippets:edit', args=('base', 'person', quote(item.pk)))
This widget can now be used in a form:
from myapp.widgets import PersonChooser
class BlogPage(Page):
author = models.ForeignKey(
'base.Person', related_name='blog_posts',
null=True, blank=True, on_delete=models.SET_NULL
)
content_panels = [
FieldPanel('author', widget=PersonChooser),
]
Chooser widgets (Django Rest Framework-based)
generic_chooser.widgets
also provides a DRFChooser
base class for chooser widgets backed by Django Rest Framework API endpoints:
from generic_chooser.widgets import DRFChooser
class PageAPIChooser(DRFChooser):
choose_one_text = _('Choose a page')
choose_another_text = _('Choose another page')
link_to_chosen_text = _('Edit this page')
choose_modal_url_name = 'page_chooser:choose'
edit_item_url_name = 'wagtailadmin_pages:edit'
api_base_url = 'http://localhost:8000/api/v2/pages/'
def get_title(self, instance):
return instance['title']
Chooser widgets (other data sources)
See the base class implementations in generic_chooser/widgets.py
.
StreamField blocks
A chooser widget as defined above can be wrapped in Wagtail's ChooserBlock
class to be used inside a StreamField. As of Wagtail 2.13, the block definition should be as follows:
from wagtail.blocks import ChooserBlock
class PersonChooserBlock(ChooserBlock):
@cached_property
def target_model(self):
from .models import Person
return Person
@cached_property
def widget(self):
from .widgets import PersonChooser
return PersonChooser()
def get_form_state(self, value):
return self.widget.get_value_data(value)
Limiting choices via linked fields
wagtail-generic-chooser provides a mechanism for limiting the options displayed in the chooser according to another input field on the calling page. For example, suppose the person model has a country field - we can then set up a page model with a country dropdown and a person chooser, where an editor first selects a country from the dropdown and then opens the person chooser to be presented with a list of people from that country.
First, we customise the chooser view to expose a country
URL parameter; to do this, we define a custom chooser_mixin_class
for the viewset to use, and override its get_unfiltered_object_list
method to filter by the country
parameter.
from generic_chooser.views import ModelChooserMixin, ModelChooserViewSet
class PersonChooserMixin(ModelChooserMixin):
preserve_url_parameters = ['country',] # preserve this URL parameter on pagination / search
def get_unfiltered_object_list(self):
objects = super().get_unfiltered_object_list()
country = self.request.GET.get('country')
if country:
objects = objects.filter(country_id=country)
return objects
class PersonChooserViewSet(ModelChooserViewSet):
model = Person
chooser_mixin_class = PersonChooserMixin
We now set up our chooser widget to inherit from LinkedFieldMixin
:
from generic_chooser.widgets import AdminChooser, LinkedFieldMixin
class PersonChooser(LinkedFieldMixin, AdminChooser):
icon = 'user'
model = Person
page_title = _("Choose a person")
This mixin allows us to pass a linked_fields
dict when constructing a PersonChooser
instance, specifying the URL parameters to pass to the chooser along with a CSS selector to indicate which field each one should be taken from.
class BlogPage(Page):
country = models.ForeignKey(Country, null=True, blank=True, on_delete=models.SET_NULL)
author = models.ForeignKey(Person, null=True, blank=True, on_delete=models.SET_NULL)
content_panels = Page.content_panels + [
FieldPanel('country'),
FieldPanel('person', widget=PersonChooser(linked_fields={
# pass the country selected in the id_country input to the person chooser
# as a URL parameter `country`
'country': '#id_country',
})),
]
A number of other lookup mechanisms are available:
PersonChooser(linked_fields={
'country': {'selector': '#id_country'} # equivalent to 'country': '#id_country'
})
# Look up by ID
PersonChooser(linked_fields={
'country': {'id': 'id_country'}
})
# Regexp match, for use in StreamFields and InlinePanels where IDs are dynamic:
# 1) Match the ID of the current widget's (the PersonChooser) against the regexp
# '^id_blog_person_relationship-\d+-'
# 2) Append 'country' to the matched substring
# 3) Retrieve the input field with that ID
PersonChooser(linked_fields={
'country': {'match': r'^id_blog_person_relationship-\d+-', 'append': 'country'},
})