From f14dbe37d202ec5f8bb94ea14229ecd716b91b9c Mon Sep 17 00:00:00 2001 From: Anett Seeker Date: Wed, 11 Feb 2026 11:02:17 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=85=20Add=20GeoRegionFactory=20for=20?= =?UTF-8?q?testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- froide/georegion/factories.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 froide/georegion/factories.py diff --git a/froide/georegion/factories.py b/froide/georegion/factories.py new file mode 100644 index 000000000..647173da8 --- /dev/null +++ b/froide/georegion/factories.py @@ -0,0 +1,19 @@ +from django.contrib.gis.geos import MultiPolygon + +import factory +from factory.django import DjangoModelFactory + +from froide.georegion.models import GeoRegion +from froide.helper.text_utils import slugify + + +class GeoRegionFactory(DjangoModelFactory): + class Meta: + model = GeoRegion + + name = factory.Sequence(lambda n: "GeoRegion {0}".format(n)) + slug = factory.LazyAttribute(lambda o: slugify(o.name)) + kind = "country" + geom = MultiPolygon() + depth = 1 + path = factory.Sequence(lambda n: f"{n:04d}") From 3319802a3a809551affaca0e54ee0798c9cefae2 Mon Sep 17 00:00:00 2001 From: Anett Seeker Date: Wed, 11 Feb 2026 11:05:24 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20Add=20tests=20for=20publicbody?= =?UTF-8?q?=20search=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- froide/publicbody/tests.py | 196 ++++++++++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 1 deletion(-) diff --git a/froide/publicbody/tests.py b/froide/publicbody/tests.py index 3799e3b41..350626717 100644 --- a/froide/publicbody/tests.py +++ b/froide/publicbody/tests.py @@ -11,6 +11,7 @@ from froide.account.factories import UserFactory from froide.foirequest.tests.factories import make_world, rebuild_index +from froide.georegion.factories import GeoRegionFactory from froide.georegion.models import GeoRegion from froide.helper.csv_utils import export_csv_bytes @@ -22,7 +23,12 @@ PublicBodyChangeProposalFactory, PublicBodyFactory, ) -from .models import FoiLaw, Jurisdiction, PublicBody, PublicBodyChangeProposal +from .models import ( + FoiLaw, + Jurisdiction, + PublicBody, + PublicBodyChangeProposal, +) class PublicBodyTest(TestCase): @@ -286,6 +292,194 @@ def test_autocomplete(self): self.assertEqual(obj["objects"], []) +@pytest.fixture +def publicbody_data(db): + # Jurisdictions + berlin = JurisdictionFactory(name="Berlin", slug="berlin") + hamburg = JurisdictionFactory(name="Hamburg", slug="hamburg") + + # Classifications + ministry = ClassificationFactory(name="Ministry", slug="ministry") + agency = ClassificationFactory(name="Agency", slug="agency") + + # Categories + environment = CategoryFactory(name="Environment", slug="environment") + traffic = CategoryFactory(name="Traffic", slug="traffic") + + # Regions + mitte = GeoRegionFactory(name="Mitte", kind="district") + altona = GeoRegionFactory(name="Altona", kind="district") + + # PublicBodies + pb1 = PublicBodyFactory( + name="Umweltministerium Berlin", + jurisdiction=berlin, + classification=ministry, + description="Zuständig für Umweltschutz", + ) + pb1.categories.add(environment) + pb1.regions.add(mitte) + + pb2 = PublicBodyFactory( + name="Verkehrsministerium Berlin", + jurisdiction=berlin, + classification=ministry, + description="Zuständig für Verkehr", + ) + pb2.categories.add(traffic) + + pb3 = PublicBodyFactory( + name="Umweltbehörde Hamburg", + jurisdiction=hamburg, + classification=agency, + description="Hamburger Umweltbehörde", + ) + pb3.categories.add(environment) + pb3.regions.add(altona) + + pb4 = PublicBodyFactory( + name="Bezirksamt Mitte", + jurisdiction=berlin, + classification=agency, + ) + pb4.regions.add(mitte) + + rebuild_index() + + yield { + "jurisdictions": {"berlin": berlin, "hamburg": hamburg}, + "classifications": {"ministry": ministry, "agency": agency}, + "categories": {"environment": environment, "traffic": traffic}, + "regions": {"mitte": mitte, "altona": altona}, + "publicbodies": {"pb1": pb1, "pb2": pb2, "pb3": pb3, "pb4": pb4}, + } + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "query_params,expected_count,expected_names", + [ + # Text search + ({"q": "Umwelt"}, 2, ["Umweltministerium Berlin", "Umweltbehörde Hamburg"]), + # Jurisdiction filter + ( + {"jurisdiction": "berlin"}, + 3, + [ + "Umweltministerium Berlin", + "Verkehrsministerium Berlin", + "Bezirksamt Mitte", + ], + ), + # Classification filter + ( + {"classification": "ministry"}, + 2, + ["Umweltministerium Berlin", "Verkehrsministerium Berlin"], + ), + # Category filter + ( + {"categories": "environment"}, + 2, + ["Umweltministerium Berlin", "Umweltbehörde Hamburg"], + ), + # GeoRegion filter + ({"regions": "mitte"}, 2, ["Umweltministerium Berlin", "Bezirksamt Mitte"]), + # GeoRegion kind filter + ( + {"regions_kind": "district"}, + 3, + ["Umweltministerium Berlin", "Bezirksamt Mitte", "Umweltbehörde Hamburg"], + ), + # Combination text + jurisdiction + ({"q": "Umwelt", "jurisdiction": "berlin"}, 1, ["Umweltministerium Berlin"]), + # Combination jurisdiction + classification + ( + {"jurisdiction": "berlin", "classification": "ministry"}, + 2, + ["Umweltministerium Berlin", "Verkehrsministerium Berlin"], + ), + # Combination category + classification + ( + {"categories": "environment", "classification": "ministry"}, + 1, + ["Umweltministerium Berlin"], + ), + # No results + ({"q": "NichtVorhanden"}, 0, []), + # Empty search (all results) + ( + {}, + 4, + [ + "Umweltministerium Berlin", + "Verkehrsministerium Berlin", + "Umweltbehörde Hamburg", + "Bezirksamt Mitte", + ], + ), + ], + ids=[ + "text_search", + "jurisdiction_filter", + "classification_filter", + "category_filter", + "georegion_filter", + "georegion_kind_filter", + "text_and_jurisdiction", + "jurisdiction_and_classification", + "category_and_classification", + "no_results", + "empty_search", + ], +) +def test_publicbody_search_filters( + client, publicbody_data, query_params, expected_count, expected_names +): + """Test different combinations of search and filters for the publicbody search API.""" + + test_params = query_params.copy() + + # Replace slugs with actual IDs. + if "jurisdiction" in test_params: + slug = test_params["jurisdiction"] + test_params["jurisdiction"] = publicbody_data["jurisdictions"][slug].pk + + if "classification" in test_params: + slug = test_params["classification"] + test_params["classification"] = publicbody_data["classifications"][slug].pk + + if "categories" in test_params: + slug = test_params["categories"] + test_params["categories"] = publicbody_data["categories"][slug].pk + + if "regions" in test_params: + slug = test_params["regions"] + test_params["regions"] = publicbody_data["regions"][slug].pk + + url = reverse("api:publicbody-search") + response = client.get(url, test_params) + + assert response.status_code == 200 + data = response.json() + + # Check counts. + assert len(data["objects"]) == expected_count + assert data["meta"]["total_count"] == expected_count + + # Check names. + result_names = {pb["name"] for pb in data["objects"]} + assert result_names == set(expected_names) + + # Check facets. + assert "facets" in data + facets = data["facets"]["fields"] + assert "jurisdiction" in facets + assert "classification" in facets + assert "categories" in facets + assert "regions" in facets + + @pytest.mark.django_db def test_accept_change_proposal(): pb = PublicBodyFactory.create() From c2c3b35f08b123b17ba1ee7b264c80cb4e7db960 Mon Sep 17 00:00:00 2001 From: Anett Seeker Date: Wed, 11 Feb 2026 14:30:57 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9A=A7=20Extend=20PublicBodyFilterSet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- froide/publicbody/filters.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/froide/publicbody/filters.py b/froide/publicbody/filters.py index 779788644..09d96a7f3 100644 --- a/froide/publicbody/filters.py +++ b/froide/publicbody/filters.py @@ -3,6 +3,7 @@ import django_filters +from froide.georegion.models import GeoRegion from froide.helper.search.filters import BaseSearchFilterSet from froide.helper.widgets import BootstrapSelect @@ -44,9 +45,18 @@ class PublicBodyFilterSet(BaseSearchFilterSet): method="filter_classification", ) + regions = django_filters.ModelMultipleChoiceFilter( + queryset=GeoRegion.objects.all(), + method="filter_regions", + ) + + regions_kind = django_filters.CharFilter( + method="filter_regions_kind", + ) + class Meta: model = PublicBody - fields = ["q", "jurisdiction", "category", "classification"] + fields = ["q", "jurisdiction", "category", "classification", "regions"] def filter_jurisdiction(self, qs, name, value): return self.apply_filter(qs, name, jurisdiction=value.id) @@ -56,3 +66,11 @@ def filter_category(self, qs, name, value): def filter_classification(self, qs, name, value): return self.apply_filter(qs, name, classification=value.id) + + def filter_regions(self, qs, name, value): + if value: + return self.apply_filter(qs, name, regions=[v.id for v in value]) + return qs + + def filter_regions_kind(self, qs, name, value): + return self.apply_filter(qs, name, regions_kind=value) From a22eafd0e0342dbf750b1ff42960cc80f6e9e104 Mon Sep 17 00:00:00 2001 From: Anett Seeker Date: Wed, 11 Feb 2026 14:32:00 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9A=A7=20Use=20PublicBodyFilterSet=20?= =?UTF-8?q?for=20PublicBody=20API=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- froide/publicbody/api_views.py | 38 +++++++--------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/froide/publicbody/api_views.py b/froide/publicbody/api_views.py index 201c45261..971c446f2 100644 --- a/froide/publicbody/api_views.py +++ b/froide/publicbody/api_views.py @@ -3,7 +3,6 @@ from django.db.models import Q from django_filters import rest_framework as filters -from elasticsearch_dsl.query import Q as ESQ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.settings import api_settings @@ -15,6 +14,7 @@ ) from froide.helper.search import SearchQuerySetWrapper from froide.helper.search.api_views import ESQueryMixin +from froide.publicbody.filters import PublicBodyFilterSet from .documents import PublicBodyDocument from .models import Category, Classification, FoiLaw, Jurisdiction, PublicBody @@ -233,6 +233,9 @@ class PublicBodyViewSet( } filter_backends = (filters.DjangoFilterBackend,) filterset_class = PublicBodyFilter + search_model = PublicBody + search_document = PublicBodyDocument + searchfilterset_class = PublicBodyFilterSet # OpenRefine needs JSONP responses # This is OK because authentication is not considered @@ -299,36 +302,8 @@ def get_serializer_context(self): return ctx def get_searchqueryset(self): - query = self.request.GET.get("q", "") - - sqs = SearchQuerySetWrapper(PublicBodyDocument.search(), PublicBody) - - if len(query) > 2: - sqs = sqs.set_query( - ESQ("multi_match", query=query, fields=["name_auto", "content"]) - ) - - model_filters = { - "jurisdiction": Jurisdiction, - "classification": Classification, - "categories": Category, - "regions": GeoRegion, - } - for key, model in model_filters.items(): - pks = self.request.GET.getlist(key) - if pks: - try: - obj = model.objects.filter(pk__in=pks) - sqs = sqs.filter(**{key: [o.pk for o in obj]}) - except ValueError: - # Make result set empty, no 0 pk present - sqs = sqs.filter(key, **{key: 0}) - - other_filters = {"regions_kind": "regions_kind"} - for key, search_key in other_filters.items(): - values = self.request.GET.getlist(key) - if values: - sqs = sqs.filter(**{search_key: values}) + sqs = self.search_document.search() + sqs = SearchQuerySetWrapper(sqs, self.search_model) sqs = sqs.add_aggregation( [ @@ -338,4 +313,5 @@ def get_searchqueryset(self): "regions", ] ) + return sqs