From b9dd9417a1cc6a7b712c755aa0624503d8eea8b7 Mon Sep 17 00:00:00 2001 From: Sahil Raja Date: Thu, 5 Nov 2026 11:09:46 +0000 Subject: [PATCH 1/4] add django filter dependency --- .pre-commit-config.yaml | 1 + dev-requirements.txt | 23 +++++++++-------------- direct_webapp/settings/settings.py | 1 + doc-requirements.txt | 10 +++++++--- pyproject.toml | 8 +++++++- requirements.txt | 9 +++++---- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbdffc03..f1028b1b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,7 @@ repos: - types-django-import-export - django-multiselectfield - django-tables2 + - django-filter - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.50.0 hooks: diff --git a/dev-requirements.txt b/dev-requirements.txt index 35e1b3f7..394a7979 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,11 +1,13 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --extra=dev --output-file=dev-requirements.txt +# pip-compile --extra=dev --no-index --output-file=dev-requirements.txt # asgiref==3.13.0 # via django +ast-serialize==0.13.1 + # via mypy beautifulsoup4==4.15.0 # via direct_webapp (pyproject.toml) build==1.6.1 @@ -20,8 +22,6 @@ click==8.5.0 # via # djlint # pip-tools -colorama==0.4.6 - # via djlint confusable-homoglyphs==3.3.1 # via django-registration coverage[toml]==7.17.0 @@ -40,6 +40,7 @@ django==6.1.2 # direct_webapp (pyproject.toml) # django-bootstrap5 # django-crispy-forms + # django-filter # django-import-export # django-multiselectfield # django-registration @@ -52,6 +53,8 @@ django-crispy-forms==2.7 # via # crispy-bootstrap5 # direct_webapp (pyproject.toml) +django-filter==26.2 + # via direct_webapp (pyproject.toml) django-import-export==4.4.4 # via direct_webapp (pyproject.toml) django-multiselectfield==1.0.1 @@ -109,8 +112,8 @@ nodeenv==1.11.0 packaging==26.3 # via # build - # gunicorn # pytest + # virtualenv # wheel pathspec==1.1.1 # via @@ -119,9 +122,7 @@ pathspec==1.1.1 pip-tools==7.6.2 # via direct_webapp (pyproject.toml) platformdirs==4.14.0 - # via - # python-discovery - # virtualenv + # via virtualenv pluggy==1.7.0 # via # pytest @@ -158,18 +159,12 @@ requests==2.34.2 # via direct_webapp (pyproject.toml) ruff==0.17.3 # via direct_webapp (pyproject.toml) -six==1.17.0 - # via - # cssbeautifier - # jsbeautifier soupsieve==3.0.4 # via beautifulsoup4 sqlparse==0.6.0 # via django tablib==3.10.0 # via django-import-export -tqdm==4.70.1 - # via djlint types-django-import-export==4.4.0.20260813 # via direct_webapp (pyproject.toml) types-markdown==3.11.2.20261029 diff --git a/direct_webapp/settings/settings.py b/direct_webapp/settings/settings.py index b1604956..36fd91e2 100644 --- a/direct_webapp/settings/settings.py +++ b/direct_webapp/settings/settings.py @@ -128,6 +128,7 @@ "crispy_bootstrap5", "import_export", "django_tables2", + "django_filters", ] MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") STATIC_ROOT = BASE_DIR / "staticfiles" diff --git a/doc-requirements.txt b/doc-requirements.txt index c91b3187..ec37ece4 100644 --- a/doc-requirements.txt +++ b/doc-requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --extra=doc --output-file=doc-requirements.txt +# pip-compile --extra=doc --no-index --output-file=doc-requirements.txt # asgiref==3.13.0 # via django @@ -32,6 +32,7 @@ django==6.1.2 # direct_webapp (pyproject.toml) # django-bootstrap5 # django-crispy-forms + # django-filter # django-import-export # django-multiselectfield # django-registration @@ -43,6 +44,8 @@ django-crispy-forms==2.7 # via # crispy-bootstrap5 # direct_webapp (pyproject.toml) +django-filter==26.2 + # via direct_webapp (pyproject.toml) django-import-export==4.4.4 # via direct_webapp (pyproject.toml) django-multiselectfield==1.0.1 @@ -124,7 +127,6 @@ nh3==0.3.7 # via direct_webapp (pyproject.toml) packaging==26.3 # via - # gunicorn # mkdocs # properdocs paginate==0.5.7 @@ -173,6 +175,8 @@ tablib==3.10.0 # via django-import-export typing-extensions==4.16.0 # via django-stubs-ext +tzdata==2026.6 + # via python-dateutil urllib3==2.8.0 # via requests watchdog==6.0.0 diff --git a/pyproject.toml b/pyproject.toml index 0d94b749..b6fe6641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,9 @@ dependencies = [ "django-tables2", "markdown", "nh3", - "requests" + "requests", + "django-filter" + ] [project.optional-dependencies] @@ -94,6 +96,10 @@ follow_untyped_imports = true module = ["django_tables2.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["django_filters.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["main.io_resources"] disallow_any_generics = false diff --git a/requirements.txt b/requirements.txt index 1c838794..95a0d605 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile +# pip-compile --no-index # asgiref==3.13.0 # via django @@ -22,6 +22,7 @@ django==6.1.2 # direct_webapp (pyproject.toml) # django-bootstrap5 # django-crispy-forms + # django-filter # django-import-export # django-multiselectfield # django-registration @@ -33,6 +34,8 @@ django-crispy-forms==2.7 # via # crispy-bootstrap5 # direct_webapp (pyproject.toml) +django-filter==26.2 + # via direct_webapp (pyproject.toml) django-import-export==4.4.4 # via direct_webapp (pyproject.toml) django-multiselectfield==1.0.1 @@ -51,8 +54,6 @@ markdown==3.11.2 # via direct_webapp (pyproject.toml) nh3==0.3.7 # via direct_webapp (pyproject.toml) -packaging==26.3 - # via gunicorn requests==2.34.2 # via direct_webapp (pyproject.toml) sqlparse==0.6.0 From a049b5f9c248dab3df3189513c0cf734c78898d7 Mon Sep 17 00:00:00 2001 From: Sahil Raja Date: Thu, 5 Nov 2026 11:39:42 +0000 Subject: [PATCH 2/4] remove existing filtering, move to filters.py and update the html --- main/filters.py | 43 +++++++++++++++++++ .../main/pages/learning-resources.html | 18 ++++---- main/views/page_views.py | 27 +++--------- 3 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 main/filters.py diff --git a/main/filters.py b/main/filters.py new file mode 100644 index 00000000..afed98d3 --- /dev/null +++ b/main/filters.py @@ -0,0 +1,43 @@ +"""Filters for browsing learning resources.""" + +from django import forms +from django.db.models import Q, QuerySet +from django_filters import CharFilter, FilterSet + +from .models import LearningResource + + +class LearningResourceFilter(FilterSet): + """Search learning resources through a single cross-field search box.""" + + q = CharFilter( + method="filter_search", + label="Search learning resources", + widget=forms.SearchInput( + attrs={ + "id": "resource-filter", + "placeholder": "Filter learning resources", + } + ), + ) + + class Meta: + """Limit filtering to the explicitly supported query parameters.""" + + model = LearningResource + fields = ("q",) + + def filter_search( + self, + queryset: QuerySet[LearningResource], + name: str, + value: str, + ) -> QuerySet[LearningResource]: + """Search resource details and related names without duplicate rows.""" + return queryset.filter( + Q(name__icontains=value) + | Q(description__icontains=value) + | Q(language__icontains=value) + | Q(provider__name__icontains=value) + | Q(skill__name__icontains=value) + ).distinct() diff --git a/main/templates/main/pages/learning-resources.html b/main/templates/main/pages/learning-resources.html index 0ab0642a..f44771a0 100644 --- a/main/templates/main/pages/learning-resources.html +++ b/main/templates/main/pages/learning-resources.html @@ -1,5 +1,6 @@ {% extends "main/base.page.html" %} {% load static %} +{% load crispy_forms_tags %} {% load render_table from django_tables2 %} {% block title %} Learning Resources @@ -16,17 +17,14 @@

Learning resources

-
- - + {{ filter.form.q|as_crispy_field }} + {% if table.order_by %} + + {% endif %} +
+ Reset
{% if table.rows %} diff --git a/main/views/page_views.py b/main/views/page_views.py index 430b6fa6..7c4f7981 100644 --- a/main/views/page_views.py +++ b/main/views/page_views.py @@ -18,8 +18,10 @@ from django.views.decorators.cache import cache_page from django.views.generic import DetailView from django.views.generic.base import TemplateView -from django_tables2 import RequestConfig, SingleTableView +from django_filters.views import FilterView +from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView +from ..filters import LearningResourceFilter from ..models import ( CompetencyDomain, Event, @@ -174,7 +176,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return context -class LearningResourcesPageView(SingleTableView): +class LearningResourcesPageView(SingleTableMixin, FilterView): """View that renders a page with all learning resources. Learning resources are listed in a table that is searchable and paginated. @@ -182,30 +184,13 @@ class LearningResourcesPageView(SingleTableView): model = LearningResource table_class = LearningResourceTable + filterset_class = LearningResourceFilter template_name = "main/pages/learning-resources.html" paginate_by = 20 def get_queryset(self) -> QuerySet[LearningResource]: """Return learning resources filtered by the optional search query.""" - queryset = LearningResource.objects.select_related("provider").order_by("name") - query = self.request.GET.get("q", "").strip() - - if query: - queryset = queryset.filter( - Q(name__icontains=query) - | Q(description__icontains=query) - | Q(language__icontains=query) - | Q(provider__name__icontains=query) - | Q(skill__name__icontains=query) - ).distinct() - - return queryset - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - """Add the current search query to the template context.""" - context = super().get_context_data(**kwargs) - context["query"] = self.request.GET.get("q", "").strip() - return context + return LearningResource.objects.select_related("provider").order_by("name") class ToolsLanguagesMethodologiesPageView(SingleTableView): From 263e840bd263f6c721e0e19dc88b5ee0c067c62a Mon Sep 17 00:00:00 2001 From: Sahil Raja Date: Thu, 5 Nov 2026 11:40:01 +0000 Subject: [PATCH 3/4] add tests --- tests/main/test_filters.py | 66 +++++++++++++++++++++++++++++++++++ tests/main/test_main_views.py | 52 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/main/test_filters.py diff --git a/tests/main/test_filters.py b/tests/main/test_filters.py new file mode 100644 index 00000000..35040343 --- /dev/null +++ b/tests/main/test_filters.py @@ -0,0 +1,66 @@ +"""Tests for learning resource filters.""" + +import pytest + +from main.filters import LearningResourceFilter +from main.models import LearningResource, Skill + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "field", + ["name", "description", "language", "provider__name", "skill__name"], +) +def test_search_matches_each_field(field, learning_resource, provider, skill): + """Search each supported field case-insensitively and trim whitespace.""" + LearningResource.objects.create(name="Unrelated course") + instance = learning_resource + attribute = field + value = "Needle" + if field == "provider__name": + instance, attribute = provider, "name" + elif field == "skill__name": + instance, attribute = skill, "name" + elif field == "language": + value = "fr" + setattr(instance, attribute, value) + instance.save() + + resource_filter = LearningResourceFilter({"q": f" {value.upper()} "}) + + assert resource_filter.is_valid() + assert list(resource_filter.qs) == [learning_resource] + + +@pytest.mark.parametrize("data", [{}, {"q": ""}, {"q": " "}]) +def test_empty_filters_return_all_resources(data, learning_resource): + """Empty and whitespace-only filters leave the queryset unchanged.""" + resource_filter = LearningResourceFilter(data) + + assert resource_filter.is_valid() + assert list(resource_filter.qs) == [learning_resource] + + +def test_search_with_no_matches(learning_resource): + """A nonmatching search returns no resources.""" + resource_filter = LearningResourceFilter({"q": "missing"}) + + assert resource_filter.is_valid() + assert not resource_filter.qs.exists() + + +def test_filter_has_only_one_search_field(): + """Only the single cross-field search is exposed by django-filter.""" + assert list(LearningResourceFilter().form.fields) == ["q"] + + +def test_search_does_not_duplicate_resources(learning_resource, skill, competency): + """Matching multiple related skills still returns each resource once.""" + second_skill = Skill.objects.create( + name="Another Skill", description="Another skill", competency=competency + ) + second_skill.learning_resources.add(learning_resource) + resource_filter = LearningResourceFilter({"q": "Skill"}) + + assert list(resource_filter.qs) == [learning_resource] diff --git a/tests/main/test_main_views.py b/tests/main/test_main_views.py index 805bff6d..b693c195 100644 --- a/tests/main/test_main_views.py +++ b/tests/main/test_main_views.py @@ -587,6 +587,58 @@ def test_page_preserves_filter_value(self, client): assert 'value="Python"' in response.content.decode() + @pytest.mark.django_db + def test_filter_form(self, client): + """Render a single labeled search control and a reset link.""" + response = client.get(self._get_url(), {"q": "Provider", "sort": "-name"}) + soup = BeautifulSoup(response.content, "html.parser") + form = soup.find("input", attrs={"name": "q"}).find_parent("form") + + assert form["method"] == "get" + assert form.find("input", attrs={"name": "q"})["value"] == "Provider" + assert len(form.select('input:not([type="hidden"])')) == 1 + assert not form.find("input", attrs={"name": "name"}) + assert form.find("label", attrs={"for": "resource-filter"}) + assert form.find("input", attrs={"name": "sort"})["value"] == "-name" + assert form.find("a", href=self._get_url()).get_text(strip=True) == "Reset" + assert not form.find("input", attrs={"name": "page"}) + + @pytest.mark.django_db + def test_page_with_no_matches(self, client, learning_provider_view_data): + """Nonmatching filters display the existing empty-results message.""" + response = client.get(self._get_url(), {"q": "Missing"}) + + assert response.status_code == HTTPStatus.OK + assert "No learning resources found." in response.content.decode() + + @pytest.mark.django_db + def test_filter_sorting_and_pagination(self, client, learning_provider_view_data): + """Sort and paginate filtered results while preserving the search.""" + provider, _ = learning_provider_view_data + for index in range(25): + LearningResource.objects.create( + name=f"Course {index:02}", provider=provider + ) + response = client.get( + self._get_url(), + {"q": "Provider One", "sort": "-name", "page": 2}, + ) + table = response.context["table"] + + assert response.status_code == HTTPStatus.OK + assert table.paginator.per_page == 20 + assert table.paginator.count == 27 + assert [row.record.name for row in table.page.object_list] == [ + f"Course {index:02}" for index in range(6, -1, -1) + ] + soup = BeautifulSoup(response.content, "html.parser") + for link in [ + *soup.select("th.orderable a"), + *soup.select(".pagination a"), + ]: + params = parseUrl.parse_qs(parseUrl.urlparse(link["href"]).query) + assert params["q"] == ["Provider One"] + @pytest.mark.django_db def test_page_content(self, learning_resource, skill, soup): """Test that the learning page contains a table with learning resources.""" From 7dc5c67cbf4d60bda199f7a9c10eb20fca949115 Mon Sep 17 00:00:00 2001 From: Sahil Raja Date: Thu, 5 Nov 2026 12:02:10 +0000 Subject: [PATCH 4/4] update producions requirements --- prod-requirements.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/prod-requirements.txt b/prod-requirements.txt index 2efded42..4925a3f9 100644 --- a/prod-requirements.txt +++ b/prod-requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --extra=prod --output-file=prod-requirements.txt +# pip-compile --extra=prod --no-index --output-file=prod-requirements.txt # asgiref==3.13.0 # via django @@ -22,6 +22,7 @@ django==6.1.2 # direct_webapp (pyproject.toml) # django-bootstrap5 # django-crispy-forms + # django-filter # django-import-export # django-multiselectfield # django-registration @@ -33,6 +34,8 @@ django-crispy-forms==2.7 # via # crispy-bootstrap5 # direct_webapp (pyproject.toml) +django-filter==26.2 + # via direct_webapp (pyproject.toml) django-import-export==4.4.4 # via direct_webapp (pyproject.toml) django-multiselectfield==1.0.1 @@ -53,8 +56,6 @@ mysqlclient==2.3.0 # via direct_webapp (pyproject.toml) nh3==0.3.7 # via direct_webapp (pyproject.toml) -packaging==26.3 - # via gunicorn requests==2.34.2 # via direct_webapp (pyproject.toml) sqlparse==0.6.0