From 09c5749f6a710c5dd9857a408b9c3572e0221f79 Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Fri, 9 Aug 2024 13:28:15 -0400 Subject: [PATCH 1/3] feat(templates): add sortable_header template tag The sortable_header inclusion tag renders a table header for a column for which a view provides sort functionality. The tag adds links to the header that include `sort` and `order` query parameters that can be used by the view to sort queryset results. --- .../tag_templates/sortable_header.html | 11 ++++ .../main_app/templatetags/helper_tags.py | 52 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 django/cantusdb_project/main_app/templates/tag_templates/sortable_header.html diff --git a/django/cantusdb_project/main_app/templates/tag_templates/sortable_header.html b/django/cantusdb_project/main_app/templates/tag_templates/sortable_header.html new file mode 100644 index 000000000..88eed7478 --- /dev/null +++ b/django/cantusdb_project/main_app/templates/tag_templates/sortable_header.html @@ -0,0 +1,11 @@ + + {% if attr_is_currently_ordering %} + {% if current_sort_param == "desc" %} + {{ column_name }} ▼ + {% else %} + {{ column_name }} ▲ + {% endif %} + {% else %} + {{ column_name }} + {% endif %} + \ No newline at end of file diff --git a/django/cantusdb_project/main_app/templatetags/helper_tags.py b/django/cantusdb_project/main_app/templatetags/helper_tags.py index 7f0ce2916..5cc4113b1 100644 --- a/django/cantusdb_project/main_app/templatetags/helper_tags.py +++ b/django/cantusdb_project/main_app/templatetags/helper_tags.py @@ -1,11 +1,12 @@ import calendar -from typing import Union, Optional +from typing import Union, Optional, Any from django import template from django.core.paginator import Paginator from django.db.models import Q from django.template.defaultfilters import stringfilter from django.utils.safestring import mark_safe +from django.http import HttpRequest from articles.models import Article from main_app.models import Source @@ -193,3 +194,52 @@ def get_user_created_source_pagination(context): page_number = context["request"].GET.get("page2") user_created_sources_page_obj = paginator.get_page(page_number) return user_created_sources_page_obj + + +@register.inclusion_tag("tag_templates/sortable_header.html") +def sortable_header( + request: HttpRequest, + order_attribute: str, + column_name: Optional[str] = None, +) -> dict[str, Union[str, bool, Optional[str]]]: + """ + A template tag for use in `ListView` templates or other templates that display + a table of model instances. This tag generates a table header () element + that, when clicked, sorts the table by the specified attribute. + + params: + context: the current template-rendering context (passed by Django) + order_attribute: the attribute of the model that clicking the table header + should sort by + column_name: the user-facing name of the column (e.g. the text + of the element). If None, use the camel-case version of + `sort_attribute`. + + returns: + a dictionary containing the following + - order_attribute: the unchanged `order_attribute` parameter + - column_name: the user-facing name of the column (e.g. the value of `column_name` + or the camel-case version of `order_attribute`) + - attr_is_currently_ordering: a boolean indicating whether the table is currently + ordered by `order_attribute` + - current_sort_param: the current sort order (either "asc" or "desc") + - url_wo_sort_params: the current URL without sorting and pagination parameters + """ + current_order_param = request.GET.get("order") + current_sort_param = request.GET.get("sort") + # Remove order, sort, and page parameters from the query string + query_dict = request.GET.copy() + for param in ["order", "sort", "page"]: + if param in query_dict: + query_dict.pop(param) + # Create the current URL without sorting and pagination parameters + url_wo_sort_params = f"{request.path}?{query_dict.urlencode()}" + if column_name is None: + column_name = order_attribute.replace("_", " ").title() + return { + "order_attribute": order_attribute, + "column_name": column_name, + "attr_is_currently_ordering": order_attribute == current_order_param, + "current_sort_param": current_sort_param, + "url_wo_sort_params": url_wo_sort_params, + } From c36febf15d4fcc5a45dd5d8048ebe0b7233d56a7 Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Fri, 9 Aug 2024 14:20:23 -0400 Subject: [PATCH 2/3] feat(source list): add sorting by country and source columns - add functionality to sort by a source's holding institution country (the "Country" column) and city/name/siglum (the "Source" column) in the Source List view - use the sortable_header helper tag for sortable column headers in the source_list.html template - fix the value used in the source column to the source's heading property --- .../main_app/templates/source_list.html | 10 ++++---- .../cantusdb_project/main_app/views/source.py | 23 ++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/django/cantusdb_project/main_app/templates/source_list.html b/django/cantusdb_project/main_app/templates/source_list.html index 35fd364d0..420e514fd 100644 --- a/django/cantusdb_project/main_app/templates/source_list.html +++ b/django/cantusdb_project/main_app/templates/source_list.html @@ -1,4 +1,6 @@ {% extends "base.html" %} +{% load helper_tags %} + {% block title %} Browse Sources | Cantus Database @@ -76,8 +78,8 @@

Browse Sources

- - + {% sortable_header request "country" %} + {% sortable_header request "heading" "Source" %} @@ -90,9 +92,9 @@

Browse Sources

-
CountrySourceSummary Date/Origin Image Link {{ source.holding_institution.country }} + - {{ source.title|truncatechars_html:100 }} + {{ source.heading|truncatechars_html:100 }} diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py index 208eabec8..510d86f98 100644 --- a/django/cantusdb_project/main_app/views/source.py +++ b/django/cantusdb_project/main_app/views/source.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import UserPassesTestMixin @@ -214,7 +216,7 @@ class SourceListView(ListView): context_object_name = "sources" template_name = "source_list.html" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["provenances"] = ( Provenance.objects.all().order_by("name").values("id", "name") @@ -224,11 +226,11 @@ def get_context_data(self, **kwargs): ) return context - def get_queryset(self): + def get_queryset(self) -> QuerySet[Source]: # use select_related() for foreign keys to reduce DB queries queryset = Source.objects.select_related( "segment", "provenance", "holding_institution" - ).order_by("siglum") + ) display_unpublished: bool = self.request.user.is_authenticated if display_unpublished: @@ -338,8 +340,23 @@ def get_queryset(self): ) q_obj_filter &= indexing_search_q + order_param = self.request.GET.get("order") + order_fields = ["siglum"] + if order_param == "country": + order_fields.insert(0, "holding_institution__country") + if order_param == "heading": + order_fields.insert(0, "holding_institution__city") + order_fields.insert(1, "holding_institution__name") + if self.request.GET.get("sort") == "desc": + sort_prefix = "-" + else: + sort_prefix = "" + + order_by_args = [f"{sort_prefix}{field}" for field in order_fields] + return ( queryset.filter(q_obj_filter) + .order_by(*order_by_args) .distinct() .prefetch_related( Prefetch("century", queryset=Century.objects.all().order_by("id")) From a206538c64143644494ee2571dcc73371a6848c0 Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Fri, 9 Aug 2024 14:40:49 -0400 Subject: [PATCH 3/3] refactor(source list view): minor source list view refactoring - remove unused variables - reduce number of statements with assignment operator --- .../cantusdb_project/main_app/views/source.py | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py index 510d86f98..4766e163e 100644 --- a/django/cantusdb_project/main_app/views/source.py +++ b/django/cantusdb_project/main_app/views/source.py @@ -210,7 +210,7 @@ def get_context_data(self, **kwargs): return context -class SourceListView(ListView): +class SourceListView(ListView): # type: ignore model = Source paginate_by = 100 context_object_name = "sources" @@ -232,41 +232,39 @@ def get_queryset(self) -> QuerySet[Source]: "segment", "provenance", "holding_institution" ) - display_unpublished: bool = self.request.user.is_authenticated - if display_unpublished: + if self.request.user.is_authenticated: q_obj_filter = Q() else: q_obj_filter = Q(published=True) - if self.request.GET.get("century"): - century_name = Century.objects.get(id=self.request.GET.get("century")).name + if century_id := self.request.GET.get("century"): + century_name = Century.objects.get(id=century_id).name q_obj_filter &= Q(century__name__icontains=century_name) - if self.request.GET.get("provenance"): - provenance_id = int(self.request.GET.get("provenance")) - q_obj_filter &= Q(provenance__id=provenance_id) - if self.request.GET.get("segment"): - segment_id = int(self.request.GET.get("segment")) - q_obj_filter &= Q(segment__id=segment_id) - if self.request.GET.get("fullSource") in ["true", "false"]: - full_source_str = self.request.GET.get("fullSource") + if provenance_id := self.request.GET.get("provenance"): + q_obj_filter &= Q(provenance__id=int(provenance_id)) + if segment_id := self.request.GET.get("segment"): + q_obj_filter &= Q(segment__id=int(segment_id)) + if (full_source_str := self.request.GET.get("fullSource")) in ["true", "false"]: if full_source_str == "true": full_source_q = Q(full_source=True) | Q(full_source=None) q_obj_filter &= full_source_q else: q_obj_filter &= Q(full_source=False) - if self.request.GET.get("general"): + if general_str := self.request.GET.get("general"): # Strip spaces at the beginning and end. Then make list of terms split on spaces - general_search_terms = self.request.GET.get("general").strip(" ").split(" ") + general_search_terms = general_str.strip(" ").split(" ") # We need a Q Object for each field we're gonna look into shelfmark_q = Q() siglum_q = Q() holding_institution_q = Q() holding_institution_city_q = Q() description_q = Q() - # it seems that old cantus don't look into title and provenance for the general search terms - # cantus.uwaterloo.ca/source/123901 this source cannot be found by searching its provenance 'Kremsmünster' in the general search field + # it seems that old cantus don't look into title and provenance + # for the general search terms + # cantus.uwaterloo.ca/source/123901 this source cannot be found by searching + # its provenance 'Kremsmünster' in the general search field # provenance_q = Q() summary_q = Q() @@ -302,9 +300,9 @@ def get_queryset(self) -> QuerySet[Source]: # For the indexing notes search we follow the same procedure as above but with # different fields - if self.request.GET.get("indexing"): + if indexing_str := self.request.GET.get("indexing"): # Make list of terms split on spaces - indexing_search_terms = self.request.GET.get("indexing").split(" ") + indexing_search_terms = indexing_str.strip(" ").split(" ") # We need a Q Object for each field we're gonna look into inventoried_by_q = Q() full_text_entered_by_q = Q()