1

I'm trying to limit query results for a specific DocumentChooserBlock inside of a wagtail stream field block.

I already know that you can limit file types for DocumentChooser for a page type by using hooks, but I would like to avoid limiting possible file types page wide in case I need them for other StreamField blocks.

Are there any possible ways to implement what I am trying to achieve here?

Alex
  • 13
  • 2
  • 3

2 Answers2

0

Wagtail's Chooser Modal system works a bit differently to a normal Django widget (Class used to render html content of a field), the widget itself mostly renders a button 'Choose Document' and the button then triggers a modal which is a separate view and template.

As you noted, the construct_document_chooser_queryset hook can limit the results shown in these modals but only with access to the request object of the page being viewed, not of the Widget used to trigger that modal.

There is a way to get some limited desired functionality but it will not work for search results and will not restrict any additional uploads to that file type.

Step 1 - Create a custom DocumentChooserBlock

  • This block Class extends the DocumentChooserBlock and has a custom __init__ method that pulls out a kwarg accept and assigns it to the widget attrs.
  • Django Widgets all have the ability to accept attrs and these are output on the rendered HTML element, our custom Block assigns the value we want to the widget so that other methods have access to it.
  • This block can be used in the same way as any other block but will use a kwarg of accept' doc_block = SpecificDocumentChooserBlock(accept="svg,md") # uses accept kwarg
  • You can confirm this is working by viewing the DOM (inspect element in the browser), just after the 'Choose a Document', there will be a hidden attribute with something like <input type="hidden" name="body-2-value" accept="svg,md" id="body-2-value" value="">

blocks.py

from wagtail.documents.blocks import DocumentChooserBlock


class SpecificDocumentChooserBlock(DocumentChooserBlock):
    """
    Existing DocumentChooserBlock with the ability to add widget attrs based on the
    accept kwarg, anything on self.widget.attrs will be added to the hidden
    input field (so be careful what key is used).
    """

    def __init__(self, accept=None, **kwargs):
        super().__init__(**kwargs)

        self.widget.attrs["accept"] = accept


Step 2 - ensure the widget attrs get passed to the modal trigger

  • Unfortunately, the data used for the query URL is not located on the above HTML input element but instead on the container div, see the data-chooser-url on the document-chooser block div.
  • This data attribute is generated by a system called Telepath.
  • The main part to understand is that there is a Class used to tell the browser what to render based on the Widget and this does not, by default, pass down the widget attrs.
  • The code below should be added to wagtail_hooks.py, as we will need that file anyway and we know it only gets run once at runtime.
  • The line widget.render_html( is the key part, we are using the ** syntax to unpack any widget.attrs values (one will be the accept item set by our custom block).

hooks.py

from wagtail.core.telepath import register as telepath_register
from wagtail.documents.widgets import AdminDocumentChooser, DocumentChooserAdapter

class CustomDocumentChooserAdapter(DocumentChooserAdapter):
    def js_args(self, widget):
        return [
            widget.render_html(
                # this line is changed, allocate any widget.attrs to the attrs passed to render_html
                "__NAME__",
                None,
                attrs={**widget.attrs, "id": "__ID__"},
            ),
            widget.id_for_label("__ID__"),
        ]


telepath_register(CustomDocumentChooserAdapter(), AdminDocumentChooser)

Step 3 - Override the admin template for the document chooser

  • Please have a look at the docs for Customising admin templates as you may need to add some more apps to your INSTALLED_APPS for this step.
  • Create a new file myapp/templates/wagtaildocs/widgets/document_chooser.html, the part after templates is critical here as we want to override and extend this exact template.
  • In the template we will extend the original and override the block chooser_attributes as this is what adds the data-chooser-url used by the Chooser Modal trigger.
  • Important: Restart your dev server here as you have added a new template override.
  • Once complete, in your browser inspect the element containing the 'Choose a Document' button you should be able to see the container element now has a data-chooser-url with a query string added to the URL <div id="body-2-value-chooser" class="chooser document-chooser blank" data-chooser-url="/admin/documents/chooser/?accept=svg,md">

myapp/templates/wagtaildocs/widgets/document_chooser.html

{% extends "wagtaildocs/widgets/document_chooser.html" %}
{% comment %}
  This template overrides the Wagtail default chooser field, this is not the modal but
  the button / selected value shown in the page editor.
  chooser_attributes are the attributes that are used by the modal trigger, we will
  override the 'data-chooser-url' value with a url param
{% endcomment %}

{% block chooser_attributes %}data-chooser-url="{% url "wagtaildocs:chooser" %}{% if attrs.accept %}?accept={{ attrs.accept }}{% endif %}"{% endblock %}

Step 4 - handle the accept query string param

  • Using the construct_document_chooser_queryset now, it will be possible to pull in the GET param accept and parse it to generate a different set of document results.

wagtail_hooks.py

@hooks.register("construct_document_chooser_queryset")
def show_accepted_documents_only(documents, request):
    accept = request.GET.get("accept")

    if accept:
        accepted_files = accept.split(",")

        queries = [Q(file__iendswith=f".{value}") for value in accepted_files]

        query = queries.pop()
        for item in queries:
            query |= item

        documents = documents.filter(query)

    return documents

Caveats

  • This solution does not block the user from uploading only specific files within the modal, but you could explore hiding that tab with some CSS (Blocks accept a classname prop).
  • When the user searches in the modal, it will not honour the URL set up this way unfortunately.
  • The solution could be fragile over various releases, especially the CustomDocumentChooserAdapter so be sure to keep an eye on the Wagtail code changes.
LB Ben Johnston
  • 4,751
  • 13
  • 29
0

Using wagtail-generic-chooser offers much more ability to customise the way the Chooser modal works.

Step 1 - Install wagtail-generic-chooser

  • Run: pip install wagtail-generic-chooser
  • Then add generic_chooser to your project's INSTALLED_APPS.

Step 2 - Set up the Chooser view set

  • Similar to the docs instructions on setting up a Chooser view set
  • Ensure we can handle the accept parameter by creating a custom class that extends the ModelChooserMixin, this will mean the param still gets passed when searching.
  • Add handling of a accept URL param to conditionally filter the returned values.
  • Set up a class that extends ModelChooserViewSet which will handle the showing of the Document listing within the modal.

base/views.py

from django.db.models import Q

from generic_chooser.views import ModelChooserMixin, ModelChooserViewSet

from wagtail.documents.models import Document


class RestrictedDocumentChooserMixin(ModelChooserMixin):
    # preserve this URL parameter on pagination / search
    preserve_url_parameters = [
        "accept",
    ]

    def get_unfiltered_object_list(self):
        objects = super().get_unfiltered_object_list()

        accept = self.request.GET.get("accept")
        print("get_unfiltered_object_list", accept)

        if accept:
            accepted_files = accept.split(",")

            queries = [Q(file__iendswith=f".{value}") for value in accepted_files]

            query = queries.pop()
            for item in queries:
                query |= item

            objects = objects.filter(query)
        return objects


class RestrictedDocumentChooserViewSet(ModelChooserViewSet):
    chooser_mixin_class = RestrictedDocumentChooserMixin

    icon = "doc"
    model = Document
    page_title = "Choose a document"
    per_page = 10
    order_by = "title"
    fields = ["title", "file"]

Step 3 - Create the Chooser Widget

  • This widget is not the Block but will be used as the base for the Block and can also be used for a FieldPanel.
  • Similar to the Setting up of a model based Widget create a class that extends the AdminChooser.
  • In the __init__ method we pull out the accept kwarg so we can use it to generate the custom URL param.
  • Override the get_edit_item_url method which will allow the clicking of a selected Document to edit it.
  • Override the ``get_choose_modal_urlto append the URL query param (note: I could not getreverse` working here without heaps more wrangling).

base/models.py

from django.contrib.admin.utils import quote
from django.urls import reverse

from generic_chooser.widgets import AdminChooser

from wagtail.documents.models import Document


class RestrictedDocumentChooser(AdminChooser):
    def __init__(self, **kwargs):

        self.accept = kwargs.pop("accept")

        super().__init__(**kwargs)

    choose_one_text = "Choose a Document"
    choose_another_text = "Choose another document"
    link_to_chosen_text = "Edit this document"
    model = Document
    choose_modal_url_name = "restricted_document_chooser:choose"

    def get_choose_modal_url(self):
        url = super().get_choose_modal_url()
        return url + "?accept=%s" % self.accept

    def get_edit_item_url(self, item):
        return reverse("wagtaildocs:edit", args=[item.id])

Step 4 - Register the chooser viewset in Wagtail Hooks

  • No need to use the construct_document_chooser_queryset here, instead use the hook register_admin_viewset and register the RestrictedDocumentChooserViewSet.

base/wagtail_hooks.py

from wagtail.core import hooks
from .views import RestrictedDocumentChooserViewSet

# ... other hooks etc

@hooks.register("register_admin_viewset")
def register_restricted_document_chooser_viewset():
    return RestrictedDocumentChooserViewSet(
        "restricted_document_chooser", url_prefix="restricted-document-chooser"
    )

Step 5 - Set up and use the custom Block

  • This class extends the ChooserBlock and wraps the RestrictedDocumentChooser widget that has been created.
  • On __init__ the same kwarg accept is pulled out and passed to the RestrictedDocumentChooser when created.
  • This block can be used by calling it similar to any other block, with the kwarg accept though. doc_block = RestrictedDocumentChooserBlock(accept="svg,md")

base/blocks.py

from django.utils.functional import cached_property
from wagtail.images.blocks import ChooserBlock

# ...

class RestrictedDocumentChooserBlock(ChooserBlock):
    def __init__(self, **kwargs):
        self.accept = kwargs.pop("accept")
        super().__init__(**kwargs)

    @cached_property
    def target_model(self):
        from wagtail.documents.models import Document

        return Document

    @cached_property
    def widget(self):
        from .widgets import RestrictedDocumentChooser

        return RestrictedDocumentChooser(accept=self.accept)

    def get_form_state(self, value):
        return self.widget.get_value_data(value)

LB Ben Johnston
  • 4,751
  • 13
  • 29
  • Note: I have added a feature request to wagtail generic chooser to make this implementation a bit easier https://github.com/wagtail/wagtail-generic-chooser/issues/36 – LB Ben Johnston Oct 03 '21 at 21:26