19.9 The change list: changelist_view and ChangeList
ModelAdmin serves a model’s list page, and ChangeList turns the query string into its queryset: the filters, the lookups left over, the joins, the ordering, the search, the count and the page. BaseModelAdmin decides which lookups a query string may make.
Whatever a user does to the list, filtering, sorting, searching or paging, is written in its query string, which is also the user’s to write: the change list takes the keys it knows as instructions and every other key as a lookup on the model, and refuses a lookup that reaches too far. A ChangeList is made for each request and does all of this in its __init__, so the view receives it built and counted.
changelist_view in its order
The view asks BaseModelAdmin, and raises PermissionDenied without it. ModelAdmin reads the hooks (ModelAdmin and the others), puts "action_checkbox" in front of the columns when the user has an action for the change list, and makes the class ModelAdmin returns.
A query string the change list cannot use raises IncorrectLookupParameters, and the view redirects to its own path with "?e=1", every other parameter dropped. If the error comes again with "e" present, the view renders "admin/, “Database error”: bad parameters with the flag already set mean, the comment reasons, that the database is at fault. DisallowedModelAdminLookup and DisallowedModelAdminToField are not caught: they are SuspiciousOperations, which the handler answers with 400 (How an exception becomes a response).
A "POST" with an action goes to ModelAdmin with the queryset ChangeList builds again, the filtered list, of which the action gets the selected rows (Actions: response_action and delete_selected). A "POST" with "_save" saves the rows of ModelAdmin; otherwise, for a user who may change, the view builds that formset for the page (The change list’s columns: list_display and list_editable). Then come the action form, the media and the context, with the preserved filters, the query string each link to a change form carries so that a save can return to the same list (Filters and search: list_filter, search_fields and date_hierarchy). The note “0 of 2 selected” on nora’s list counts the page’s rows with len, which is the query that fetches them. The template is ModelAdmin, or the model’s, the application’s or "admin/, the first found (The admin’s templates and media).
Reading the query string: ChangeList.__init__
| key | name | what it holds |
|---|---|---|
"q" |
name SEARCH_VAR |
what it holds the search term, read by Change |
"o" |
name ORDER_VAR |
what it holds the ordering, as indexes of columns |
"p" |
name PAGE_VAR |
what it holds the page number, 1 if it is not a number |
"all" |
name ALL_VAR |
what it holds every row, if no more than Model |
"e" |
name ERROR_FLAG |
what it holds set by the redirect after an error |
IGNORED_PARAMS lists the keys that are not lookups: "all", "o", "q", and the admin’s "_facets", "_popup", "_source_model" and "_to_field"; "p" and "e" are deleted from the parameters outright. Every other key is a lookup. Errors of the search form become messages and do not stop the page: "?q=%00" showed “Null characters are not allowed.” above an unsearched list.
"_to_field" is how a related-object popup names the field its relation points at, the primary key or another (The admin’s widgets: related objects, raw ids and autocomplete). BaseModelAdmin admits the primary key, which a comment says the change page’s URL shows already, a field that the table of one of the model’s own many-to-many fields points at, and one that a relation from a registered model or an inline’s model points at; it refuses the rest. "?_to_field=tonnage" was answered 400.
ChangeList is the model admin’s BaseModelAdmin: the default manager, ordered by BaseModelAdmin (Registering models: AdminSite and ModelAdmin). The title follows the permission: “Select ship to change” for nora, “Select port to view” on the ports.
Which lookups are allowed: lookup_allowed
ChangeList puts each lookup and each of its values to BaseModelAdmin before it makes a filter, and raises DisallowedModelAdminLookup at the first refusal. The documentation gives the reason: the lookups can be written by the user, and “they must be sanitized to prevent unauthorized data exposure”. A filter tests a value whether or not the page shows it, and a lookup across relations can test any model they reach.
The method walks the lookup’s parts and keeps those that go somewhere new: the first field, and each field reached across a relation, except the related model’s primary key, a link to a parent model and, where the related model’s key is automatic, the field the foreign key was told to point at. A lookup with no more than one such part, a local field or a foreign key’s value, is allowed, as is one that names no field and fails later. A longer one is allowed only where its path is ModelAdmin or is named by get_list_filter: as a field path, as the field of a pair, or as a SimpleListFilter. First of all it admits the lookups, value and all, by which a foreign key to the model limits its choices, which raw-id popups send.
As nora, "?tonnage__gt=300" listed Endeavour, and "?home__id__exact=1" passed, the port’s id being what home’s key holds. "?home__country=Malta" was answered 400, logged as “Filtering by home__country not allowed”, and so was "?home__name=Valletta", though home__name is a search field: a search goes through ModelAdmin, not lookup_allowed.
Building the queryset: ChangeList.get_queryset
ChangeList builds the queryset in a fixed order. For a request that filters, searches and sorts:
get_filters makes a filter of each item of ModelAdmin, and each filter takes the keys it expects out of the parameters, so what is left is lookups no filter claimed; the date hierarchy’s year, with any month and day, becomes a bounded range. The leftovers go through prepare_lookup_value, which splits an "__in" value at its commas and makes an "__isnull" value a boolean.
get_queryset applies each filter’s ListFilter, then the leftovers as one Q made by build_q_object_from_lookup_parameters, the values of one key joined by OR and the keys by AND: "?name=Beagle&name=Endeavour" listed both ships. Any exception there but SuspiciousOperation and ImproperlyConfigured becomes IncorrectLookupParameters, since, the comment says, there is no other way to validate the lookups; "?tonnage=abc" was redirected to "?e=1".
ChangeList runs unless the queryset has a QuerySet already. With ModelAdmin false, the default, it joins each column of ModelAdmin that is a foreign key or one-to-one field written by its name: ShipAdmin’s list joins the port, and a column home_id would join nothing. A list or tuple joins what it names.
ChangeList starts from BaseModelAdmin, or the model’s ordering. An "o" replaces it: indexes joined by dots, each with "-" for descending, into the columns as the change list holds them, "action_checkbox" first. So "o=2" is tonnage for nora, and would be home for a user with no action. ChangeList turns a column into a field, a method by its "admin_order_field": "?o=-4", the Large column, sorted by descending tonnage. A column with none, or an index past the end, is skipped: "?o=9" left only "-pk". The queryset’s own ordering is added, and "-pk" last unless QuerySet finds the ordering total, so that, as the docstring says, the order is deterministic (Annotations, ordering and the other methods that change the query).
The search follows, from get_search_results, which says whether it may repeat rows. QuerySet is added when it may, or a field’s filter or a leftover may: lookup_spawns_duplicates follows a path and answers yes where it crosses a many-to-many or a reverse foreign key. home__name does not; a search of ports by their ships’ names would.
Counting and the page: ChangeList.get_results
ChangeList asks ModelAdmin for a paginator with ModelAdmin, 100 by default, and counts with it. Unless BaseModelAdmin is false it counts root_queryset too, for the total beside a filtered count, a query the documentation warns is costly on a large table. A list that fits one page, or one asked for with "all" and no longer than list_max_show_all, 200 by default, is the whole queryset; any other is Paginator.page of the page number, and an InvalidPage becomes IncorrectLookupParameters (Pagination: Paginator and Page).
So "?p=9" on the two ships showed both: they fit one page, and the number was never used. With list_per_page = 1 in a subclass of ShipAdmin, "?p=9" was redirected to "?e=1", and "?p=9&e=1" was the “Database error” page.