Skip to content

Serving a read

A selector spec served as a page: a list of rows a person filters and pages through, or one row. SpecSelectorView admits the request as every page does, reads the query string as the spec's arguments, runs the kernel's dispatch, and renders the rows the selector returned. It answers GET and HEAD and nothing else, since a read changes nothing; an operation a row offers is a link to the page that runs it, such as a SpecConfirmView.

from django_spec_html import SpecSelectorView

The example lists books, filters them by title and author, and offers to withdraw a published one. Its specs:

@dataclasses.dataclass
class BookOut:
    """A book as a page names it."""

    pk: Annotated[int, FieldMarking.handle()]  # another call's input: never shown
    title: Annotated[str, FieldMarking.label()]  # the name a page calls it by
    status: Status


def book_by_pk(*, pk: int) -> QuerySet[Book]:
    return Book.objects.filter(pk=pk)


def withdraw(*, instance: Book) -> Book:
    instance.status = Status.DRAFT
    instance.save(update_fields=["status"])
    return instance


def withdrawals_open() -> bool:
    return not WITHDRAWALS_PAUSED


# The operation a row offers, with a condition of each kind: one on the row,
# answered for every row in the page's one query, and one over the seeds.
withdraw_book_spec = ServiceSpec(
    service=withdraw,
    permissions=OPEN,
    instance_selector_spec=SelectorSpec(
        kind=SelectorKind.RETRIEVE, selector=book_by_pk, reads=BY_PK
    ),
    presenter=DataclassPresenter(BookOut),
    affordances=[
        Affordance(
            code="not_published",
            reason="Only a published book can be withdrawn.",
            when=Q(status=Status.PUBLISHED),
        ),
        Affordance(
            code="withdrawals_paused",
            reason="Withdrawals are paused.",
            when=withdrawals_open,
        ),
    ],
)


class BookFilters(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr="icontains")
    author = django_filters.ModelChoiceFilter(queryset=Author.objects.order_by("name"))
    o = django_filters.OrderingFilter(fields=("title", "price"))

    class Meta:
        model = Book
        fields: list[str] = []


def books() -> QuerySet[Book]:
    # The author joined in, since the table shows each row's author's name.
    return Book.objects.select_related("author")


book_list_spec = SelectorSpec(
    kind=SelectorKind.LIST,
    selector=books,
    permissions=OPEN,
    filter_set=BookFilters,
    presenter=DataclassPresenter(BookOut),
    affordances={"withdraw": withdraw_book_spec},
)

book_spec = SelectorSpec(
    kind=SelectorKind.RETRIEVE,
    selector=book_by_pk,
    reads=BY_PK,
    permissions=OPEN,
    presenter=DataclassPresenter(BookOut),
    affordances={"withdraw": withdraw_book_spec},
)

and its URLconf, a list page, the same list as a table, a page for one book and the page that withdraws it:

urlpatterns = [
    path(
        "books/",
        SpecSelectorView.as_view(
            spec=book_list_spec,
            paginate_by=10,
            # A client may choose its page size, up to the ceiling.
            page_size_kwarg="per_page",
            max_page_size=50,
        ),
        name="books",
    ),
    path(
        "books/table/",
        SpecSelectorView.as_view(
            spec=book_list_spec,
            paginate_by=10,
            table_class=BookTable,
            # The filter set's OrderingFilter: each header link sends it.
            table_order_by_field="o",
        ),
        name="book-table",
    ),
    path("books/<int:pk>/", SpecSelectorView.as_view(spec=book_spec), name="book"),
    path(
        "books/<int:pk>/withdraw/",
        SpecConfirmView.as_view(
            spec=withdraw_book_spec,
            success_url=reverse_lazy("book-table"),
            route_kwargs=("pk",),
            display_target=True,
        ),
        name="withdraw-book",
    ),
]

The module is a whole project's URLconf over the shipped templates, so it serves real pages: set ROOT_URLCONF = "docs.examples.spec_selector", list django_spec_html, django_tables2 and tests.adapter_app in INSTALLED_APPS, run manage.py migrate --run-syncdb (the test app ships no migrations), call docs.examples.spec_selector.seed() once from manage.py shell, and open /books/.

What the page renders

as_view() refuses a spec that is not a SelectorSpec, or a source that does not compile to one, when the URLconf is imported, and points a write at the form pages. The context holds view and spec, as every page's does, and:

Key On a list On an instance page
rows the page's rows the one row, or none where allow_none let a missing one through
row - the one row
page the kernel's OutputPage: page, limit, total, total_pages, has_next, and items, which is rows -
previous_page_query, next_page_query the query string of each neighbouring page, where it exists -
filter the spec's filter_set, bound to the query string the same
row_displays each row with its label or pairs, as its Output marks it the same
table the table_class table, where one is set the same
errors, code on a refusal, in place of the rows the same

A key with nothing to hold is absent, so {% if page %} is false on an instance page. A row the selector does not find is Http404, as the form pages answer it.

The rows are the selector's own, never a presenter's copy: a template reads row.author.name off a Book it was handed. What a person is shown by default is each row as the spec's Output marks it - its label, or its fields with the handle left out - which is row_displays, presented with the context dispatch built for the call. So as_view() refuses a spec whose Output the page cannot display, unless the page is told it needs no display: table_class set, or display_rows = False for a template of the project's own.

Paging

A list is paged by the kernel's paginate_output: one COUNT, then the page of rows, both clamped, so page 99 of three is page three and its links lead where their labels say.

Attribute Default
page_kwarg "page" the query key naming the page
paginate_by None, the kernel's 100 the page size
max_page_size None the ceiling on any page size
page_size_kwarg None a query key a client chooses the page size with, up to max_page_size, which it then needs

The paging keys never reach the spec. They are taken off the query before the spec reads it, so ?page=2 is the page's under either unknown_arguments policy, and as_view() refuses a list spec declaring a parameter under a paging key, since that parameter could never be sent. Rename the key with page_kwarg instead. A page that is not a whole number is page one, and a size that is not one is paginate_by. An instance page is not paged, so its query is all the spec's: there ?page=2 is an argument like any other.

The links keep every other key, the filters and a client's page size among them, and replace the page.

A list that remembers its query

The query the page reads is get_query(request), which answers request.GET unless a subclass answers another QueryDict. The page asks it once a request and reads every key from its answer: the paging keys, the filter form, the spec's arguments and the links. A list that opens where the last visit left it keeps the query in the session:

class RememberedBooks(SpecSelectorView):
    """The book list, opened where this session's last visit left it."""

    def get_query(self, request: HttpRequest) -> QueryDict:
        # ?reset=1 forgets the saved query: the way out of one the spec
        # refuses, since it is saved before dispatch judges it.
        if "reset" in request.GET:
            request.session.pop("books_query", None)
            return QueryDict()
        # A visit that sends anything is the query, and is remembered...
        if request.GET:
            request.session["books_query"] = request.GET.urlencode()
            return request.GET
        # ...and a bare visit reads the last one: its filters, sort and page.
        return QueryDict(request.session.get("books_query", ""))


urlpatterns += [
    path(
        "books/remembered/",
        RememberedBooks.as_view(spec=book_list_spec, paginate_by=10),
        name="remembered-books",
    ),
]

A bare visit to /books/remembered/ serves the filters, the sort and the page of the visit before it, and its links keep them; a visit that sends any key is the query, and is saved in its place. The template is handed a request whose GET is the query the page read, so a tag building a link from request.GET keeps it too: a django-tables2 table's header sort links carry the remembered filters. The project needs Django's sessions installed, as the session is the host's and not the page's.

The hook saves the query before dispatch judges it. A saved query the spec refuses - a key it no longer declares under REJECT, or a filter value that is no longer valid, such as an author since deleted - is refused again on every bare visit. So the example forgets it on ?reset=1, which answers the list with no query at all; a page's "clear filters" link is that URL.

The hook is a plain def, as every request hook is, and as_view() refuses an async def one, since the page does not await it. An answer that is not a QueryDict raises TypeError naming the hook: the paging keys are popped off a copy of it as lists.

Filters

A spec's filter_set is the page's filter form, as filter: bound to the query string for display, with the paging keys taken off. The page never validates it. Dispatch reads the same query through the same filter set and is the one reader that decides; its refusal is placed on that form by add_argument_errors, so filter.form.errors is the refusal, and an error the form would have raised in words of its own is not shown twice. A refused query renders the page at its refusal's status - 400 for a value the reads refuse - with the messages in errors and no rows.

The shipped page draws the form's fields with no errors beside them, and every message of a refusal in the results, so a page that swaps its results under htmx shows them too.

A key the spec does not declare

Is ignored. SpecSelectorView.unknown_arguments is UnknownArguments.IGNORE, so ?utm_source=newsletter on a list page serves the rows as if it were not there, and beside a filter the filter is read as usual. The page links keep it, as they keep every key. The query string of a page a person reads is written by links the page does not control: a tracking parameter, a bookmark from before a filter was renamed. Refusing what the spec does not declare would answer that visitor a 400 for the link. It holds on an instance page too.

A key the spec does declare is still read by its declaration, so ?author=999 is refused at 400 whatever else the query carries, and a route kwarg the spec does not declare is the host's misconfiguration under either policy. The form and confirmation pages send dispatch only their form's fields and the route, so a query key they do not declare never reaches it; the JSON views refuse an undeclared key by default, since a client's code chose it rather than whoever wrote a link.

A list whose query is built by code alone opts back into the refusal:

# A list whose query only code builds, refusing a key the spec does not
# declare where the default would ignore it.
strict_book_list = SpecSelectorView.as_view(
    spec=book_list_spec, paginate_by=10, unknown_arguments=UnknownArguments.REJECT
)

which answers ?utm_source=newsletter with a 400 and utm_source: Unknown argument. on the page, placed on the filter form's non-field errors.

What a row offers

A selector spec's affordances name the operations a row offers, and dispatch answers every condition for every row in the page's one query: a Q as an annotation on it, a callable once. The page attaches those answers to each row it serves, and the available filter reads them:

{% load django_spec_html %}
{% for row in rows %}
  {{ row.title }}
  {% if row|available:"withdraw" %}<a href="{% url "withdraw-book" row.pk %}">Withdraw</a>
  {% else %}<span>Not available</span>{% endif %}
{% endfor %}

So the button follows the rule the operation's own dispatch enforces, and asking costs no query. A name the spec does not declare raises TemplateSyntaxError, naming those it does, rather than reading as unavailable for good.

Read a negated or combined condition through {% with %}. Django's {% if %} reads an error raised inside not, and, or or a comparison as the whole condition false, so {% if not row|available:"withdarw" %} draws nothing and raises nothing. {% with %} resolves the filter outside {% if %}, so the same misspelt name raises there:

{% with ok=row|available:"withdraw" %}
  {% if not ok %}<span>Not available</span>{% endif %}
{% endwith %}

A filter on its own, {% if row|available:"withdraw" %}, raises as it is. The same holds for unavailable, below.

A row that is a mapping, such as one from values(), is answered on a copy, so a selector's own rows are left as they were. A row that cannot carry an attribute, such as a tuple from values_list(), is served without answers, and either filter raises for it.

Why a row's action is unavailable

The unavailable filter reads the same answers and says why: the row's answer, with the code and reason of the first condition the row does not meet, when the action is unavailable, and None when it is available. It costs no query either, and raises for the same names and rows.

{% with why=row|unavailable:"withdraw" %}
  {% if why %}<span title="{{ why.code }}">{{ why.reason }}</span>
  {% else %}<a href="{% url "withdraw-book" row.pk %}">Withdraw</a>{% endif %}
{% endwith %}

A condition on the row asked of a row that no longer exists answers with no code and no reason, since none of the conditions' sentences is true of it: why is still the answer, so {% if why %} holds, and {{ why.reason }} renders as any missing variable does. The answer is a dict, so {{ why|json_script:"why" }} hands it to a script, and a fresh copy per read whose clear and popitem no template can call, so a template cannot empty it.

A row's action refused or gone is the host's to answer, for htmx too, by the handlers in a denial under htmx.

A django-tables2 table

table_class names a django-tables2 Table. The page builds it over the rows it served, as table, and the shipped template includes the table's own template. The package does not depend on django-tables2: install it and list django_tables2 in INSTALLED_APPS in a project that sets table_class.

class BookTable(tables.Table):
    title = tables.Column()
    # Not among the OrderingFilter's fields, so its header must not link a
    # sort: dispatch would refuse ``o=author``, and the link would answer 400.
    author = tables.Column(accessor="author__name", verbose_name="Author", orderable=False)
    price = tables.Column()
    # The button where the row may be withdrawn, and otherwise why not: the
    # reason of the first condition the row does not meet.
    withdraw = tables.TemplateColumn(
        '{% load django_spec_html %}{% with why=record|unavailable:"withdraw" %}'
        "{% if why %}<span>{{ why.reason }}</span>"
        '{% else %}<a href="{% url "withdraw-book" record.pk %}">Withdraw</a>{% endif %}'
        "{% endwith %}",
        orderable=False,
        verbose_name="",
    )

A TemplateColumn asks each record what it offers, as a template asks a row: the example's shows the button, or the reason it is not there. The table never pages or orders the rows: they are the spec's page, in the spec's order. It is built with no request, since django-tables2 then runs its RequestConfig, which would page and order the data from the query string, and with order_by=(), so a Meta.order_by is never applied.

With no table_order_by_field, no column is orderable and no header links a sort. Set it to a parameter the spec declares - a filter set's OrderingFilter, o in the example - and each orderable column's header links ?o=<column>, which dispatch reads as any other argument. as_view() refuses a field the spec does not declare. A table whose Meta sets a prefix links the prefixed key, ?t-o=<column> for a prefix of t-, so that key is the one the spec must declare. Mark a column the filter cannot order by orderable=False, as the example's author is: its link would send a value the OrderingFilter refuses, and answer 400.

Two limits follow from the table never being told the order. A header always links the ascending sort, so a second click does not reverse it; a link reversing it is the template's to write, from filter.form. And a header link keeps the page the person was on, where a new order usually starts again from page one.

The shipped page includes the table's template rather than calling {% render_table %}, which the package does not load, so a TemplateColumn renders without the page's context, and a {% csrf_token %} in one renders nothing. A form posting from a column belongs on a template of the project's own that renders the table with {% render_table table %}.

An htmx request

An htmx request is answered with the results alone, spec_selector_fragment.html by default or the template's spec-results partial, as the form pages answer with their form (an htmx request). A refusal is the fragment at 200, since htmx swaps no 4xx by default, and every response carries Vary: HX-Request. The shipped page wraps its results in <div id="spec-results"> and adds no hx- attribute of its own; a live search is the project's:

<form method="get" hx-get="" hx-target="#spec-results" hx-trigger="input changed delay:300ms">
  {{ filter.form }}
</form>

How many queries a page costs

A list page costs the same number of queries whatever its size: the COUNT, the page of rows with every row's affordance flags annotated on it, and whatever the template asks of its own - in the example, the author filter's choices. The example's tests hold three queries for both its pages at three books and at thirty. A query per row is the template's, from a relation the selector does not join, which is why the example's books() selects the author the table shows.

Server arguments, seeds and route values

As on every page: a route's captured values are arguments, pool_seeds and get_pool_seeds add to the call's seeds, and server_arguments fills a parameter from the server's side, outranking a value the query string sends (the request).