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.
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).