CNK's Blog

Snippet Choosers

Continuing with our Location snippet from our previous post, we want to use locations in our event pages. So we need to be able to choose locations - but only locations entered into the current site - and we need to enforce the “same site” restriction in our foreign key relationships. Fortunately Django already supports using functions to create a list of valid options for choosers. So in our case, we need a function that does not take any arguments and returns the dictionary Django needs to build the queryset filter. See the Django docs for details.

Relationships

Our EventPage has a foreign key relationship with Location and we use a helper method to restrict the choices offered to locations in the same site. The help looks like this:

    def limit_to_current_site():
        """
        Use this function to limit a dropdown that lists models that reference a Site to those instances that reference
        the current Site.
        """
        request = get_current_request()
        if request:
            return {'site': Site.find_for_request(request)}
        else:
            # If we do not have a request, rely on the validations that ran when inserting this data.
            # NB: Our imports must be sure they are setting up the foreign key relations to data in the current site.
            return Q()

And then we use it in our page model definition like this.

    class EventPage(Page):
        start_date = models.DateTimeField('Start Date/Time')
        end_date = models.DateTimeField('End Date/Time')
        location = models.ForeignKey(
            'map.Location',
            blank=True,
            null=True,
            limit_choices_to=limit_to_current_site,
            on_delete=models.SET_NULL,
        )
        description = RichTextField(editor='minimal', blank=True)

        content_panels = Page.content_panels + [
                FieldRowPanel(
                    classname='datetimes-field date-field',
                    children=[
                        FieldPanel('start_date', classname='start-date'),
                        FieldPanel('end_date', classname='end-date')
                    ]
                ),
                FieldPanel(
                    'location',
                    widget=autocomplete.ModelSelect2(
                        url='map:location_autocomplete',
                        attrs={'data-placeholder': 'Search for Locations...'}
                    )
                ),
                FieldPanel('description'),
        ]

Autocomplete views

You will notice that we have specified a widget in the location FieldPanel. This is because we have too many locations in some sites to easily use a <select> input field. The code above enforces the site restriction for the foreign key relationship but we will need a custom view to allow editors to search for appropriate locations.

    # views.py
    from dal import autocomplete

    class LocationAutocompleteView(autocomplete.Select2QuerySetView):
        """
        An autocompleter that returns Location objects for use in the various forms.
        """
        paginate_by = None

        def get_queryset(self):
            # Start with all of the Locations for the site.
            site = Site.find_for_request(self.request)
            queryset = Location.objects.filter(site=site)

            # If the user has typed anything into the autocomplete widget, filter the queryset down to Locations that match.
            if self.q:
                queryset = queryset.filter(name__icontains=self.q)

            return queryset

    # Then in our urls.py we have this line to add the url
    path('location_autocomplete', never_cache(views.LocationAutocompleteView.as_view()), name='location_autocomplete'),
    # Then this url mapping is used as the `url` arg for the autocomplete widget in the form above.

Choosers

Wagtail snippets also provide chooser views to select instances of a model or create an instance if a suitable one does not already exist. Once again, we need to only offer instances from the current site to be associated with other models on the site. We are currently using the older wagtail-generic-chooser package so we created a mixin to take care of filtering by site.

I’ll update this code once we have converted to using the built-in ChooserViewSet. I think we should be able to subclass ChooserViewSet, customize get_object_list, and then follow the rest of the instructions but I haven’t tried it yet.

    # utils.py
    from generic_chooser.views import ModelChooserMixin, ModelChooserViewSet

    class SiteSpecificChooserMixin(ModelChooserMixin):
        """
        Use this ChooserMixin for Site-specific models, to ensure that users can only choose instances of that model
        belonging to the current Site.
        """

        def get_unfiltered_object_list(self):
            objects = super().get_unfiltered_object_list()
            return objects.filter(site=Site.find_for_request(self.request))

    # views.py
    class LocationChooserViewSet(ModelChooserViewSet):
    """
    This viewset defines the views that ae used to choose (and create, from within the chooser) Location objects.
    To make use of them, you must specify widget=LocationChooser in your FieldPanel for the Location field, or use an
    LocationChooserBlock in your StreamField block definition.
    """
    icon = 'map'
    model = Location
    page_title = 'Choose a Location'
    per_page = 40
    order_by = 'name'
    form_class = LocationModelForm
    chooser_mixin_class = SiteSpecificChooserMixin

    # forms.py
    class LocationModelForm(SiteSpecificModelForm):
    """
    wagtail-generic-choosers expects an _actual_ ModelForm, rather than a pseudo-ModelForm that Wagtail lets
    you use. This Form class manually specifies the Model it's for and the fields it presents, because that's the
    default way that it works in Django, and wagtail-generic-choosers expects that.
    """

    class Meta:
        model = Location
        fields = [ 'name', 'building_name', 'room_number']