Enable sorting of organisation codelists - #3134
Conversation
22ebca6 to
45622c5
Compare
b28e863 to
2027541
Compare
2027541 to
d66f634
Compare
This means we can store the state in the URL. By supporting query params users can share links or refresh the page without changes to the items on the page.
Moving between pages should not reset the ordering the user selected. Preserving the complete search and sort state keeps pagination within the same result set and avoids a confusing change of order.
Building query strings repeatedly in the template was becoming difficult to maintain as more state needed to be preserved.
The shared header control provides consistent behaviour across tables, whilst `aria-sort` make the state clear and has a11y benefits.
The previous names were ambiguous when passed between request handling, URL generation, and template context. The `sort_by` prefix links the names but clarifies their use.
Search, sorting, and pagination all depend on the same query state and can easily cause issues when changed independently.
2919e77 to
884aee6
Compare
mikerkelly
left a comment
There was a problem hiding this comment.
Thanks, Thomas. This seems to mostly do the right thing on local testing and the UI looks good. I like the URL parameter design which is clear and functional.
I think that the overall idea of separating out the organisation list views from the index but with some shared code is a very good one, same for the partial templates. Much of the functionality is along the right lines, but I think we can make some simplifications which will make this much easier to develop and maintain. I can also see that some of the issues I am having with this come from the index code that this was split off from and are longstanding.
I have been finding this module hard to understand and review and so I am
going to make some fairly extensive review comments. Given all that, I am
very happy to assist however I can, whether that is futher review, discussion,
pairing, or doing some or all of the work I am asking for. Let me know what you
think, and feel free to push back. I definitely think we can leave many of these
thoughts for the future if at all. I still have not looked in detail at the
templates or testing.
Probably I have left too many comments, but did not want to lose any thoughts. Sorry if there is too much text and it is unstructured and repetitive.
Some general comments below, more inline.
Correctness of template use of pagination
The Pagination object seems to have been set up right but the context has and template tries to use information about First and Last buttons and page numbers. I never see these, even on an organisation with many pages so I think something may be wrong in the logic in one of those but have not had time to look into that. Could you investigate, or let me know where I can see it working if you have.
I tried going to http://localhost:7000/codelist/opensafely/ which has 12 pages
that I went through and never see the links.
Code reuse and organisation
It seems like the index and organisation codelist pages do most of the sorting logic in Python whereas #3029 was written in JavaScript. Is there a reason for the difference? We now have two separate implementations of sorting a list of codelists for different pages. I think I like the Python implementation for this but could we share code for listing, sorting, and filtering codelists in future PR(s)?
Within views/organisations.py, the module is structured as two separate view functions sharing a lot of private utility functions to handle the differences between the views for published and under-review codelists. This does the job, but it introduces a lot of indirection that I am finding makes it quite hard to follow and reason about the differences.
I think for this PR we should worry less about good query performance and DRY code and more about making the QuerySet the view makes easy to understand and maintain. We can do some refactoring to add custom QuerySets and share code if we keep developing this.
I would like us to try and reduce the number of private helpers down so that there is less indirection and the top-level flow in the view is clearer. Each view is extracting and validating some URL params, making a QuerySet, and constructing a context including a Paginator. Probably those should be the top level helpers, if any. Inline review comments expand on this theme.
All of the QuerySet filtering and sorting for Codelist, CodelistVersion, and Handle get quite complicated here and in index. Probably this should not be in the views at all but somewhere in the models as a model queryset or manager, or in a separate queries module that the view can then call out to with the necessary parameters. I don't necessarily think that we should do anything about that in this PR.
I think it will be much easier for this PR to reason about the QuerySet if we construct one QuerySet with the generally in-scope handles (right org, current, not private, published version exists) then compose that with search_handles. And the part that does the composition should ideally all be done in one place so it can be understood, then detail looked at if required, not split across the organisation_review, _get_organisation_handles, _handles_with_status, and _under_review_versions functions. I think I am finding it too hard to understand and reason about the way it is currently organised.
I have a comment near the start of organisation_published show what that could look like.
For the future, if we want to be more DRY and have shared queryset functions like "saved" filters and order_bys, I think an idiomatic way to do this is to extend the QuerySet class with some custom methods that also return QuerySets, and use that as the model manager. That approach is explained in the documentation here and a nice example of what that looks like in this Julia Evans post.
Sorting
This PR sorts a QuerySet of Django model instances into an ordered list for display. Django QuerySets have a composable order_by method that should be used for ordering queryest as required. We should only do the sort ourselves in Python as a last resort, if there is a good reason not to do in with order_by. Using QuerySet methods lets us use the nice API the framework provides, write less code ourselves, and will do the work in the database when required, which will probably be orders of magnitude more performant.
https://docs.djangoproject.com/en/6.0/ref/models/querysets/#django.db.models.query.QuerySet.order_by
On performance: It took me 13s to open http://localhost:7000/codelist/opensafely/under-review/ on first visit, of which only 4ms was database queries. After that visits (such as changing sort order) took about 75-150ms, of which almost all was CPU time. http://localhost:7000/codelist/opensafely/ was similar. I am unsure if the initial visit was my local database warming up or populating caches or just a blip. ~100ms delay is something we could ignore as not materially affecting UX if we wanted, but we can do better that that and should use QuerySet for consistency reasons anyway.
Back on dc76451 the page took about 50-60ms to load, about 3ms on database queries. An extra 50-100ms is not a dealbreaker as performance is not very important to us, but hints how expensive unpacking into Python lists and sorting in Python can be, compared to doing it in the database. And we should be careful about compounding such performance hits.
| SORT_DIRECTION_ASC = "asc" | ||
| SORT_DIRECTION_DESC = "desc" | ||
| SORT_DIRECTIONS = {SORT_DIRECTION_ASC, SORT_DIRECTION_DESC} | ||
|
|
||
| SORT_BY_CREATED_AT = "created_at" | ||
| SORT_BY_NAME = "name" | ||
| SORT_BY_UPDATED_AT = "updated_at" | ||
| SORT_FOR_PUBLISHED = {SORT_BY_NAME, SORT_BY_UPDATED_AT} | ||
| SORT_FOR_UNDER_REVIEW = {SORT_BY_NAME, SORT_BY_CREATED_AT} | ||
|
|
There was a problem hiding this comment.
Suggestion, optional, terminology: I think field and order are more idiomatic and informative terms to qualify a sort than sort and direction. Those are the words used in Django QuerySet, so it probably makes sense to mirror that in our code.
https://docs.djangoproject.com/en/6.0/ref/models/querysets/#order-by
There was a problem hiding this comment.
Suggestion, optional: Python has Enum to represent a set of names bound to values. Since Python 3.11, we have StrEnum, which is slightly nicer when just working with string values, like we do here.
There was a problem hiding this comment.
Suggestion, optional: We know that later we will want to select valid sort fields by status. So instead of defining SORT_FOR_PUBLISHED and SORT_FOR_UNDER_REVIEW, you could define a dict keyed on Status, mapping to the allowed values. Then the other code can change behaviour by keying into this dictionary based on status rather than having to define explicitly which to use. This can make code drastically more expressive and concise, which makes it easier to follow and validate.
There was a problem hiding this comment.
Putting those together could could look something like:
from enum import StrEnum, auto
class SortOrder(StrEnum):
ASC = auto() # automatically "asc" ...
DESC = auto()
class SortField(StrEnum):
CREATED_AT = auto()
NAME = auto()
UPDATED_AT = auto()
ALLOWED_SORT_FIELDS = {
Status.PUBLISHED: {SortField.NAME, SortField.UPDATED_AT},
Status.UNDER_REVIEW: {SortField.NAME, SortField.CREATED_AT},
}For example, that could simplify this:
sort_options_by_status = {
Status.PUBLISHED: SORT_FOR_PUBLISHED,
Status.UNDER_REVIEW: SORT_FOR_UNDER_REVIEW,
}
sort_options = sort_options_by_status.get(status, {SORT_BY_NAME})
query_sort_by = request.GET.get("sort")
if query_sort_by in sort_options:
sort_by = query_sort_by
else:
sort_by = SORT_BY_NAMEto this (combining with ternary operator):
query_sort = request.GET.get("sort")
sort_by = query_sort if query_sort in ALLOWED_SORT_FIELDS[status] else SortField.NAME| sort_options_by_status = { | ||
| Status.PUBLISHED: SORT_FOR_PUBLISHED, | ||
| Status.UNDER_REVIEW: SORT_FOR_UNDER_REVIEW, | ||
| } | ||
| sort_options = sort_options_by_status.get(status, {SORT_BY_NAME}) | ||
|
|
||
| query_sort_by = request.GET.get("sort") | ||
| if query_sort_by in sort_options: | ||
| sort_by = query_sort_by | ||
| else: | ||
| sort_by = SORT_BY_NAME |
There was a problem hiding this comment.
Earlier I suggest that this can be simplified like:
query_sort = request.GET.get("sort")
sort_by = query_sort if query_sort in ALLOWED_SORT_FIELDS[status] else SortField.NAME| def organisation_published( | ||
| request: HttpRequest, organisation_slug: str | ||
| ) -> HttpResponse: | ||
| organisation, handles, q = _get_organisation_handles(request, organisation_slug) |
There was a problem hiding this comment.
Separation of concerns: I think that we should do the extraction of all of the query parameters first, in the view or in a function dedicated to that. Currently the view is passing the request to many of the utility functions, and it is not obvious why until you hunt down which specific parameters they later pluck out of the object.
It seems surprising that a function called _get_organisation_handles is returning something called q and also the organisation, as well as the handles. And that not all organisation handles are returned. Maybe these should be reorganised?
There was a problem hiding this comment.
Naming: Please let's not use variable names that are just initial letters of what we really mean. Otherwise the reader has to work that out for themselves. Obvious things are not obvious, and there are often more than one relevant concepts starting with a letter. So q should be query, probably, or maybe search_term would be even better. Here and throughout. Possibly also in the URL query parameters.
I know that this is how the index code does it. Probably it should change there, too. Actually, that module provides a great example of why this is problematic, as search_handles includes both a parameter called q and many references to Django Q objects, which are a totally different thing (and also have a bad name).
There was a problem hiding this comment.
So something like:
def organisation_published(request: HttpRequest, organisation_slug: str) -> HttpResponse:
status = Status.PUBLISHED
search_term, page, sort_order, sort_field = _parse_query_params(request)
organisation = get_object_or_404(Organisation, slug=organisation_slug)
handles = (
Handle.objects.filter(organisation=organisation)
.filter(is_current=True, codelist__is_private=False)
.filter(
Exists(
CodelistVersion.objects.filter(
codelist=OuterRef("codelist"), status=Status.PUBLISHED # change this in the other view
)
)
)
.select_related("codelist")
.order_by(Lower("name")) # can branch or have a look to add the right parameter here
)
# compose with `search_handles` if search term set.
paginator = Paginator(handles, PAGE_SIZE).get_page(request.GET.get("page"))There was a problem hiding this comment.
We can pass the handles queryset directly to the paginator, we don't need to convert it into a list. The templates can access {{handle.codelist}} fine.
| "first_url": url_for(1), | ||
| "last_url": url_for(paginator.num_pages), | ||
| "page_links": [ | ||
| {"number": page_number, "url": url_for(page_number)} | ||
| for page_number in paginator.page_range |
There was a problem hiding this comment.
I don't ever see first, last, or page number links no matter how many search results I have or which page I am on. Could you please check that this is working for you and tell me what parameters you were using to see that?
| ctx = { | ||
| "page_obj": Paginator(codelists, PAGE_SIZE).get_page(request.GET.get("page")), | ||
| "page_obj": page_obj, | ||
| "pagination": _pagination_context(page_obj, q, sort_by, sort_direction), | ||
| "organisation": organisation, | ||
| "q": q, | ||
| "sort_by": sort_by, | ||
| "sort_direction": sort_direction, | ||
| "sort_options": _sort_options_context( | ||
| q, sort_by, sort_direction, SORT_FOR_PUBLISHED | ||
| ), | ||
| } |
There was a problem hiding this comment.
This is almost identical between the two view functions. Maybe this can be a shared function?
There was a problem hiding this comment.
Why do we have sort_options the context as a sub-dict but also have each sort option as a top-level entry in the context dict?
| from opencodelists.list_utils import flatten | ||
|
|
||
|
|
||
| def aware_datetime(year: int, month: int, day: int) -> datetime: |
There was a problem hiding this comment.
I think it will be simpler to use the freezer pytest extension fixture to move time before creating each codelist.
If you want to do it declaratively without freezer then the required datetime should be a parameter to create_codelist so that functionality can be shared between tests, not special a helper just for this module.
| bravo = create_codelist("SNOMED Bravo") | ||
| alpha = create_codelist("SNOMED alpha") | ||
| charlie = create_codelist("SNOMED Charlie") |
There was a problem hiding this comment.
Maybe worth a brief comment explanation that the creation order is BaC but the case-insenstive lexicographic order is aBC. That makes it really obvious why these are called what they are.
You can also add id parameters to pytest test parametrisation arguments to hint as to the purpose of each parametrisation.
| create_codelists(70, owner=organisation, status=status) | ||
|
|
||
| rsp = client.get(f"/codelist/{organisation.slug}/", {"q": "Codelist"}) | ||
| rsp = client.get( |
There was a problem hiding this comment.
Question: do we really need to use the test client fixture in these tests? Would it be sufficient to use the rf request factory fixture instead and call the view code directly?
I ask because using the client makes a full real HTTP request to the test server, which is much slower than rf.
Using client makes this more like what in Job Server we call an integration test, of high level behaviour, with separate rf tests for the more numerous and detailed unit tests.
| ) | ||
|
|
||
|
|
||
| def _pagination_context(page_obj, q, sort_by, sort_direction): |
There was a problem hiding this comment.
I think it would be quite a bit cleaner to extend Paginator to either overload its methods or add new ones, if we want to do this logic in the view Python code. Don't have time to expand now, sorry.
|
|
||
|
|
||
| def _sort_under_review_versions(versions, sort_by, sort_direction): | ||
| sort_key_funcs = { |
There was a problem hiding this comment.
If we use order_by to do QuerySet sorting as suggested elsewhere, this dict could be at the module level and take a (SortField, SortOrder) tuple as key, and map to the argument needed to order_by. Then function is not needed, we can do something like
ordering_params = {
(SortField.NAME, SortOrder.ASC): "name__iexact"
(SortField.NAME, SortOrder.DESC): "-name__iexact"
...
}
...
.order_by(ordering_params[(sort_field, sort_order)])
Part of #3058 - this PR adds the functionality to sort codelists and stores the state in query params.
Published codelist tests cover:
sort=name&direction=ascsort=name&direction=descsort=updated_at&direction=ascsort=updated_at&direction=descUnder-review tests cover:
sort=name&direction=ascsort=name&direction=descsort=created_at&direction=ascsort=created_at&direction=descScreenshots
Sorted by name (asc) [default]
Sorted by name (desc)
Sorted by last updated (asc)
Sorted by last updated (desc)
Sorted by name (desc) with search