From b5864b8b25d200978234e2ae7a101156198794fd Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Wed, 17 Jun 2026 15:51:57 +0200 Subject: [PATCH 1/5] feat: return objets counts in orgs suggest for search facets --- udata/core/organization/api.py | 118 ++++++++++++++++-- udata/core/organization/api_fields.py | 8 ++ udata/tests/api/test_organizations_api.py | 22 ++++ udata/tests/search/test_search_integration.py | 58 +++++++++ udata/tests/search/test_services.py | 39 ++++++ udata_search_service/search_clients.py | 58 +++++++++ udata_search_service/services.py | 8 +- 7 files changed, 297 insertions(+), 14 deletions(-) create mode 100644 udata/tests/search/test_services.py diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index 279f2b9818..e7b006fdda 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -1,7 +1,9 @@ from datetime import UTC, datetime +from types import SimpleNamespace -from flask import make_response, redirect, request, url_for +from flask import current_app, make_response, redirect, request, url_for from mongoengine.queryset.visitor import Q +from werkzeug.datastructures import MultiDict from udata.api import API, api, errors from udata.api.parsers import ModelApiParser @@ -32,6 +34,7 @@ from udata.mongo import db from udata.mongo.errors import FieldValidationError from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content +from udata.search import adapter_for, get_elastic_client from .api_fields import ( invite_fields, @@ -674,27 +677,116 @@ class FollowOrgAPI(FollowAPI): model = Organization +# Models whose search index can back an organization suggest count (count_for=...). +COUNT_FOR_MODELS = {"dataset": Dataset, "reuse": Reuse, "dataservice": Dataservice} +# Prefix namespacing the context-search filters from the suggest's own params: the +# suggest `q` is an organization name, while `count_filter.q` is the dataset/reuse/... +# full-text query the count is scoped to (e.g. count_filter.tag=transport). +COUNT_FILTER_PREFIX = "count_filter." +# Upper bound on the number of organizations we send to ES in a single count request. +# The candidate set comes from a name `icontains`, which is barely selective for short +# queries (on data.gouv.fr "a" matches ~5000 of ~6300 orgs, "le" ~1500), so we always +# keep only the top organizations by followers. The count ranking is therefore exact +# whenever the name match already fits under this cap (typically queries of 3+ chars, +# e.g. "data" matches ~90 orgs) and a best-effort top-by-followers otherwise. +COUNT_CANDIDATE_POOL = 100 + + +def org_suggestion(org): + return { + "id": org.id, + "name": org.name, + "acronym": org.acronym, + "slug": org.slug, + "image_url": org.logo, + "page": org.self_web_url(), + } + + +org_suggest_parser = suggest_parser.copy() +org_suggest_parser.add_argument( + "count_for", + type=str, + choices=list(COUNT_FOR_MODELS), + location="args", + help=( + "Annotate each suggestion with the number of matching objects of this kind " + "(dataset, reuse or dataservice), scoped to the search passed via " + f"`{COUNT_FILTER_PREFIX}*` params (e.g. {COUNT_FILTER_PREFIX}tag=transport). " + "Empty organizations are pushed to the end, the follower ranking is kept." + ), +) + + +def organization_match_counts(count_for, organizations): + """Return {organization_id: matching_count} for the given candidate organizations. + + Counts come from the search index of ``count_for`` (datasets/reuses/dataservices) — + the only place a search-scoped per-organization count exists — via an exact ES + `include` of the candidates. Returns an empty mapping when search is disabled. + """ + if not organizations or not current_app.config["ELASTICSEARCH_URL"]: + return {} + + adapter = adapter_for(COUNT_FOR_MODELS[count_for]) + + # Parse the count_filter.* params through the target model's own request parser so + # filters are validated/typed exactly like a real search on that model. + stripped = MultiDict() + for key in request.args: + if key.startswith(COUNT_FILTER_PREFIX): + for value in request.args.getlist(key): + stripped.add(key[len(COUNT_FILTER_PREFIX) :], value) + parser = adapter.as_request_parser(paginate=False, store_missing=False) + params = dict(parser.parse_args(req=SimpleNamespace(args=stripped))) + + params.setdefault("q", "") + params["page"] = 1 + # Ignored: count-only mode forces ES `size: 0`, so no hits are ever fetched. + params["page_size"] = 1 + # The exact indexed `organization_with_id` terms ("|") to count. + params["count_organizations"] = [f"{org.id}|{org.name}" for org in organizations] + + service = adapter.service_class(get_elastic_client()) + _, _, _, facets = service.search(params) + + # Bucket keys are "|"; key counts by id to stay robust to a stale name. + return { + bucket["name"].split("|", 1)[0]: bucket["count"] + for bucket in facets.get("organization_id_with_name", []) + if bucket["name"] != "all" + } + + @ns.route("/suggest/", endpoint="suggest_organizations") class OrganizationSuggestAPI(API): @api.doc("suggest_organizations") - @api.expect(suggest_parser) + @api.expect(org_suggest_parser) @api.marshal_list_with(org_suggestion_fields) def get(self): """Organizations suggest endpoint using mongoDB contains""" - args = suggest_parser.parse_args() + args = org_suggest_parser.parse_args() orgs = Organization.objects( Q(name__icontains=args["q"]) | Q(acronym__icontains=args["q"]), deleted=None - ) + ).order_by(SUGGEST_SORTING) + + if not args["count_for"]: + return [org_suggestion(org) for org in orgs.limit(args["size"])] + + # Count the top organizations by followers matching the name. We cap the pool + # because a name `icontains` is barely selective on short queries (see + # COUNT_CANDIDATE_POOL); the counted set stays exact for queries narrow enough + # to fit under the cap. + candidates = list(orgs.limit(COUNT_CANDIDATE_POOL)) + counts = organization_match_counts(args["count_for"], candidates) + + # Keep the follower order (stable sort), only pushing empty organizations to the + # end. We never sort by count: a huge low-quality organization must not outrank a + # well-followed one. + candidates.sort(key=lambda org: counts.get(str(org.id), 0) == 0) return [ - { - "id": org.id, - "name": org.name, - "acronym": org.acronym, - "slug": org.slug, - "image_url": org.logo, - "page": org.self_web_url(), - } - for org in orgs.order_by(SUGGEST_SORTING).limit(args["size"]) + {**org_suggestion(org), "matching_count": counts.get(str(org.id), 0)} + for org in candidates[: args["size"]] ] diff --git a/udata/core/organization/api_fields.py b/udata/core/organization/api_fields.py index a0c333654c..91257962ba 100644 --- a/udata/core/organization/api_fields.py +++ b/udata/core/organization/api_fields.py @@ -99,5 +99,13 @@ size=BIGGEST_LOGO_SIZE, description="The organization logo URL", readonly=True ), "page": fields.String(description="The organization web page URL", readonly=True), + "matching_count": fields.Integer( + description=( + "Number of objects (set by the `count_for` param) owned by this " + "organization and matching the `count_filter.*` search. Null unless " + "`count_for` was requested." + ), + readonly=True, + ), }, ) diff --git a/udata/tests/api/test_organizations_api.py b/udata/tests/api/test_organizations_api.py index 51157d7fb8..5dc26dad00 100644 --- a/udata/tests/api/test_organizations_api.py +++ b/udata/tests/api/test_organizations_api.py @@ -1058,6 +1058,28 @@ def test_unfollow_org(self): assert Follow.objects.following(user).count() == 0 assert Follow.objects.followers(user).count() == 0 + def test_suggest_organizations_without_count_for_has_null_count(self): + """Without count_for the endpoint stays the plain Mongo suggest.""" + OrganizationFactory(name="plain-org") + response = self.get(url_for("api.suggest_organizations", q="plain", size=5)) + assert200(response) + assert response.json[0]["name"] == "plain-org" + assert response.json[0]["matching_count"] is None + + def test_suggest_organizations_count_for_without_search_service(self): + """With count_for but no search service, suggestions are returned without crashing.""" + OrganizationFactory(name="degraded-org") + response = self.get( + url_for("api.suggest_organizations", q="degraded", size=5, count_for="dataset") + ) + assert200(response) + assert response.json[0]["name"] == "degraded-org" + + def test_suggest_organizations_invalid_count_for(self): + """count_for only accepts known model kinds.""" + response = self.get(url_for("api.suggest_organizations", q="x", size=5, count_for="banana")) + assert response.status_code == 400 + def test_suggest_organizations_api(self): """It should suggest organizations""" for i in range(3): diff --git a/udata/tests/search/test_search_integration.py b/udata/tests/search/test_search_integration.py index 6a639df28e..d4e6be9740 100644 --- a/udata/tests/search/test_search_integration.py +++ b/udata/tests/search/test_search_integration.py @@ -1,6 +1,7 @@ import time import pytest +from flask import url_for from udata.core.access_type.constants import AccessType from udata.core.dataservices.factories import DataserviceFactory @@ -658,3 +659,60 @@ def test_dataservice_filter_by_tags(self): titles = [d["title"] for d in response.json["data"]] assert "DS tagged" in titles assert "DS other" not in titles + + def test_suggest_organizations_with_dataset_count(self): + """count_for=dataset annotates each suggestion and pushes empty orgs to the end.""" + org_two = OrganizationFactory(name="Count Alpha") + org_one = OrganizationFactory(name="Count Beta") + OrganizationFactory(name="Count Empty") + DatasetFactory(organization=org_two) + DatasetFactory(organization=org_two) + DatasetFactory(organization=org_one) + + time.sleep(1) + + response = self.get( + url_for("api.suggest_organizations", q="Count", size=10, count_for="dataset") + ) + self.assert200(response) + counts = {org["name"]: org["matching_count"] for org in response.json} + assert counts == {"Count Alpha": 2, "Count Beta": 1, "Count Empty": 0} + # The empty organization is demoted to the end, the others keep their ranking. + assert response.json[-1]["name"] == "Count Empty" + + def test_suggest_organizations_count_scoped_by_filter(self): + """The count respects the count_filter.* search context.""" + org = OrganizationFactory(name="Scoped Org") + DatasetFactory(organization=org, tags=["transport"]) + DatasetFactory(organization=org, tags=["sante"]) + + time.sleep(1) + + response = self.get( + url_for( + "api.suggest_organizations", + q="Scoped", + size=10, + count_for="dataset", + **{"count_filter.tag": "transport"}, + ) + ) + self.assert200(response) + assert response.json[0]["name"] == "Scoped Org" + assert response.json[0]["matching_count"] == 1 + + def test_suggest_organizations_dataservice_count(self): + """count_for=dataservice replaces the old "orgs with at least one API" flag.""" + org_api = OrganizationFactory(name="Api Provider") + OrganizationFactory(name="Api Nothing") + DataserviceFactory(organization=org_api) + + time.sleep(1) + + response = self.get( + url_for("api.suggest_organizations", q="Api", size=10, count_for="dataservice") + ) + self.assert200(response) + counts = {org["name"]: org["matching_count"] for org in response.json} + assert counts == {"Api Provider": 1, "Api Nothing": 0} + assert response.json[-1]["name"] == "Api Nothing" diff --git a/udata/tests/search/test_services.py b/udata/tests/search/test_services.py new file mode 100644 index 0000000000..2582c36a46 --- /dev/null +++ b/udata/tests/search/test_services.py @@ -0,0 +1,39 @@ +from udata_search_service.services import DatasetService + + +class FakeElasticClient: + """Records the last query_datasets call so we can assert on the plumbing.""" + + def __init__(self): + self.last_call = None + + def index_dataset(self, *args, **kwargs): + pass + + def find_one_dataset(self, *args, **kwargs): + pass + + def delete_one_dataset(self, *args, **kwargs): + pass + + def query_datasets(self, search_text, offset, page_size, filters, sort=None, **kwargs): + self.last_call = {"filters": filters, "kwargs": kwargs} + return 0, [], {} + + +def base_filters(**extra): + return {"q": "", "page": 1, "page_size": 20, "sort": None, **extra} + + +def test_count_organizations_forwarded_when_set(): + client = FakeElasticClient() + DatasetService(client).search(base_filters(count_organizations=["abc|Org"])) + assert client.last_call["kwargs"]["count_organizations"] == ["abc|Org"] + # It must not leak into the filters sent to ES as a regular filter. + assert "count_organizations" not in client.last_call["filters"] + + +def test_count_organizations_not_forwarded_when_absent(): + client = FakeElasticClient() + DatasetService(client).search(base_filters()) + assert "count_organizations" not in client.last_call["kwargs"] diff --git a/udata_search_service/search_clients.py b/udata_search_service/search_clients.py index 5c5a08b318..f15dbb0147 100644 --- a/udata_search_service/search_clients.py +++ b/udata_search_service/search_clients.py @@ -268,6 +268,52 @@ def configure_indices(prefix): cls._index._name = cls.Index.name +def organization_count_facet(search, get_filters_except, include_terms): + """Count-only mode for the organization facet (used by the organization suggest). + + Instead of running a full search (hits + every facet), we run an aggregation-only + request (`size: 0`, no fetch phase) building *only* the organization `terms` + aggregation, restricted to `include_terms` (the exact "|" keys of the + candidate organizations). Counts stay faithful to a real search because we reuse + the relevance query and `get_filters_except("organization_id_with_name")` already + built by the caller — the organization filter itself is excluded so selecting an + organization doesn't zero out the others. + + Alternative considered and rejected (kept here on purpose): doing everything in ES + with no Mongo round-trip — a single `terms` aggregation ordered by + `max(orga_followers)` with an `include` regex on the name. We don't because + `orga_followers` isn't indexed on dataservices (would need a reindex), ordering a + terms aggregation by a sub-metric is shard-approximate, and matching the name would + become a case/accent-sensitive regex. Letting Mongo own the name match + follower + sort (exact, uniform across models, and needed anyway for the logo/slug/page + payload) and using ES only for the exact counts is simpler and correct. + """ + org_filters = get_filters_except("organization_id_with_name") + if org_filters: + parent = search.aggs.bucket( + "organization_id_with_name_filtered", "filter", filter=query.Bool(must=org_filters) + ) + else: + parent = search.aggs + parent.bucket( + "organization_id_with_name", + "terms", + field="organization_with_id", + include=include_terms, + size=len(include_terms), + ) + response = search[:0].execute() + + buckets = [] + aggregations = getattr(response, "aggregations", None) + if aggregations is not None: + container = getattr(aggregations, "organization_id_with_name_filtered", aggregations) + org_agg = getattr(container, "organization_id_with_name", None) + if org_agg is not None: + buckets = [{"name": b.key, "count": b.doc_count} for b in org_agg.buckets] + return 0, [], {"organization_id_with_name": buckets} + + class ElasticClient: def __init__(self, url: str, prefix: str): self.es = connections.create_connection(hosts=[url]) @@ -647,6 +693,7 @@ def query_datasets( page_size: int, filters: dict, sort: Optional[str] = None, + count_organizations: Optional[List[str]] = None, ) -> Tuple[int, List[dict], dict]: search = SearchableDataset.search() @@ -822,6 +869,9 @@ def get_filters_except(exclude_key): filters_list.append(filter_dict[key]) return filters_list + if count_organizations is not None: + return organization_count_facet(search, get_filters_except, count_organizations) + format_filters = get_filters_except("format_family") if format_filters: format_agg = search.aggs.bucket( @@ -1053,6 +1103,7 @@ def query_reuses( page_size: int, filters: dict, sort: Optional[str] = None, + count_organizations: Optional[List[str]] = None, ) -> Tuple[int, List[dict], dict]: search = SearchableReuse.search() @@ -1213,6 +1264,9 @@ def get_filters_except(exclude_key: str): flt.append(filter_dict[k]) return flt + if count_organizations is not None: + return organization_count_facet(search, get_filters_except, count_organizations) + facet_fields = { "producer_type": ("producer_type", "producer_type"), "organization_id_with_name": ("organization_with_id", "organization_id_with_name"), @@ -1343,6 +1397,7 @@ def query_dataservices( page_size: int, filters: dict, sort: Optional[str] = None, + count_organizations: Optional[List[str]] = None, ): search = SearchableDataservice.search() @@ -1498,6 +1553,9 @@ def get_filters_except(exclude_key: str): filters_list.append(filter_dict[k]) return filters_list + if count_organizations is not None: + return organization_count_facet(search, get_filters_except, count_organizations) + facet_fields = { "access_type": ("access_type", "access_type"), "producer_type": ("producer_type", "producer_type"), diff --git a/udata_search_service/services.py b/udata_search_service/services.py index 7769773eb5..594ac98847 100644 --- a/udata_search_service/services.py +++ b/udata_search_service/services.py @@ -38,13 +38,19 @@ def search(self, filters: dict) -> Tuple[List[EntityBase], int, int, dict]: page_size = filters.pop("page_size") search_text = filters.pop("q") sort = self.format_sort(filters.pop("sort", None)) + # Opt-in organization count-only mode (organization suggest). Only forwarded when + # set so query methods that don't support it (organization, topic, ...) are untouched. + count_organizations = filters.pop("count_organizations", None) offset = page_size * (page - 1) if page > 1 else 0 self.format_filters(filters) + extra = {} + if count_organizations is not None: + extra["count_organizations"] = count_organizations results_number, search_results, facets = self._client_query( - search_text, offset, page_size, filters, sort + search_text, offset, page_size, filters, sort, **extra ) results = [self.entity_class.load_from_dict(hit) for hit in search_results] total_pages = ceil(results_number / page_size) or 1 From 79d6b822fde3d3bb79d0937a79e9885c781ae425 Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Wed, 17 Jun 2026 16:05:58 +0200 Subject: [PATCH 2/5] simplify org filtering --- udata/core/organization/api.py | 13 +++---- udata/tests/search/test_services.py | 4 +-- udata_search_service/search_clients.py | 48 ++++++++++++++------------ 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index e7b006fdda..d5467aa6f6 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -744,18 +744,13 @@ def organization_match_counts(count_for, organizations): params["page"] = 1 # Ignored: count-only mode forces ES `size: 0`, so no hits are ever fetched. params["page_size"] = 1 - # The exact indexed `organization_with_id` terms ("|") to count. - params["count_organizations"] = [f"{org.id}|{org.name}" for org in organizations] + # The search layer owns how organizations are indexed; we only pass ids and get back + # a {organization_id: count} mapping. + params["count_organizations"] = [str(org.id) for org in organizations] service = adapter.service_class(get_elastic_client()) _, _, _, facets = service.search(params) - - # Bucket keys are "|"; key counts by id to stay robust to a stale name. - return { - bucket["name"].split("|", 1)[0]: bucket["count"] - for bucket in facets.get("organization_id_with_name", []) - if bucket["name"] != "all" - } + return facets.get("organization_counts", {}) @ns.route("/suggest/", endpoint="suggest_organizations") diff --git a/udata/tests/search/test_services.py b/udata/tests/search/test_services.py index 2582c36a46..089f5470f4 100644 --- a/udata/tests/search/test_services.py +++ b/udata/tests/search/test_services.py @@ -27,8 +27,8 @@ def base_filters(**extra): def test_count_organizations_forwarded_when_set(): client = FakeElasticClient() - DatasetService(client).search(base_filters(count_organizations=["abc|Org"])) - assert client.last_call["kwargs"]["count_organizations"] == ["abc|Org"] + DatasetService(client).search(base_filters(count_organizations=["org-id-1", "org-id-2"])) + assert client.last_call["kwargs"]["count_organizations"] == ["org-id-1", "org-id-2"] # It must not leak into the filters sent to ES as a regular filter. assert "count_organizations" not in client.last_call["filters"] diff --git a/udata_search_service/search_clients.py b/udata_search_service/search_clients.py index f15dbb0147..d2fb4427ee 100644 --- a/udata_search_service/search_clients.py +++ b/udata_search_service/search_clients.py @@ -268,16 +268,20 @@ def configure_indices(prefix): cls._index._name = cls.Index.name -def organization_count_facet(search, get_filters_except, include_terms): - """Count-only mode for the organization facet (used by the organization suggest). +def organization_counts(search, get_filters_except, organization_ids): + """Count, per organization id, the documents matching the current search. - Instead of running a full search (hits + every facet), we run an aggregation-only - request (`size: 0`, no fetch phase) building *only* the organization `terms` - aggregation, restricted to `include_terms` (the exact "|" keys of the - candidate organizations). Counts stay faithful to a real search because we reuse - the relevance query and `get_filters_except("organization_id_with_name")` already - built by the caller — the organization filter itself is excluded so selecting an - organization doesn't zero out the others. + Used by the organization suggest: given a set of candidate organization ids, return + a ``{organization_id: count}`` mapping scoped to the same query/filters as a real + search. The caller only deals in ids — the "|" composite indexed field is + a search-internal detail, so here we aggregate on the plain ``organization`` id field. + + Instead of a full search (hits + every facet), this is an aggregation-only request + (`size: 0`, no fetch phase) building *only* the organization `terms` aggregation, + restricted to ``organization_ids`` via `include`. Counts stay faithful because we + reuse the relevance query and `get_filters_except("organization_id_with_name")` + already built by the caller — the organization filter itself is excluded so selecting + an organization doesn't zero out the others. Alternative considered and rejected (kept here on purpose): doing everything in ES with no Mongo round-trip — a single `terms` aggregation ordered by @@ -291,27 +295,27 @@ def organization_count_facet(search, get_filters_except, include_terms): org_filters = get_filters_except("organization_id_with_name") if org_filters: parent = search.aggs.bucket( - "organization_id_with_name_filtered", "filter", filter=query.Bool(must=org_filters) + "organizations_filtered", "filter", filter=query.Bool(must=org_filters) ) else: parent = search.aggs parent.bucket( - "organization_id_with_name", + "organizations", "terms", - field="organization_with_id", - include=include_terms, - size=len(include_terms), + field="organization", + include=organization_ids, + size=len(organization_ids), ) response = search[:0].execute() - buckets = [] + counts = {} aggregations = getattr(response, "aggregations", None) if aggregations is not None: - container = getattr(aggregations, "organization_id_with_name_filtered", aggregations) - org_agg = getattr(container, "organization_id_with_name", None) + container = getattr(aggregations, "organizations_filtered", aggregations) + org_agg = getattr(container, "organizations", None) if org_agg is not None: - buckets = [{"name": b.key, "count": b.doc_count} for b in org_agg.buckets] - return 0, [], {"organization_id_with_name": buckets} + counts = {bucket.key: bucket.doc_count for bucket in org_agg.buckets} + return 0, [], {"organization_counts": counts} class ElasticClient: @@ -870,7 +874,7 @@ def get_filters_except(exclude_key): return filters_list if count_organizations is not None: - return organization_count_facet(search, get_filters_except, count_organizations) + return organization_counts(search, get_filters_except, count_organizations) format_filters = get_filters_except("format_family") if format_filters: @@ -1265,7 +1269,7 @@ def get_filters_except(exclude_key: str): return flt if count_organizations is not None: - return organization_count_facet(search, get_filters_except, count_organizations) + return organization_counts(search, get_filters_except, count_organizations) facet_fields = { "producer_type": ("producer_type", "producer_type"), @@ -1554,7 +1558,7 @@ def get_filters_except(exclude_key: str): return filters_list if count_organizations is not None: - return organization_count_facet(search, get_filters_except, count_organizations) + return organization_counts(search, get_filters_except, count_organizations) facet_fields = { "access_type": ("access_type", "access_type"), From ee3279ab811546746d31b88d28b67cce8c643964 Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Thu, 18 Jun 2026 10:20:06 +0200 Subject: [PATCH 3/5] rework org suggest counts: topic-restricted candidates merged with search facet ids --- udata/core/organization/api.py | 89 ++++++++---- udata/tests/api/test_organizations_api.py | 44 ++++++ udata/tests/search/test_search_integration.py | 128 +++++++++++++++++- 3 files changed, 236 insertions(+), 25 deletions(-) diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index d5467aa6f6..62196b4510 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -31,6 +31,7 @@ parse_uploaded_image, uploaded_image_fields, ) +from udata.core.topic.models import Topic from udata.mongo import db from udata.mongo.errors import FieldValidationError from udata.rdf import RDF_EXTENSIONS, graph_response, negociate_content @@ -679,17 +680,10 @@ class FollowOrgAPI(FollowAPI): # Models whose search index can back an organization suggest count (count_for=...). COUNT_FOR_MODELS = {"dataset": Dataset, "reuse": Reuse, "dataservice": Dataservice} -# Prefix namespacing the context-search filters from the suggest's own params: the -# suggest `q` is an organization name, while `count_filter.q` is the dataset/reuse/... -# full-text query the count is scoped to (e.g. count_filter.tag=transport). +# Prefix namespacing the count-context filters from the suggest's own params: the suggest +# `q` is an organization name, while `count_filter.q` is the dataset/reuse/... full-text +# query the count is scoped to (e.g. count_filter.tag=transport). COUNT_FILTER_PREFIX = "count_filter." -# Upper bound on the number of organizations we send to ES in a single count request. -# The candidate set comes from a name `icontains`, which is barely selective for short -# queries (on data.gouv.fr "a" matches ~5000 of ~6300 orgs, "le" ~1500), so we always -# keep only the top organizations by followers. The count ranking is therefore exact -# whenever the name match already fits under this cap (typically queries of 3+ chars, -# e.g. "data" matches ~90 orgs) and a best-effort top-by-followers otherwise. -COUNT_CANDIDATE_POOL = 100 def org_suggestion(org): @@ -711,11 +705,38 @@ def org_suggestion(org): location="args", help=( "Annotate each suggestion with the number of matching objects of this kind " - "(dataset, reuse or dataservice), scoped to the search passed via " + "(dataset, reuse or dataservice), counted in the search index and scoped by the " f"`{COUNT_FILTER_PREFIX}*` params (e.g. {COUNT_FILTER_PREFIX}tag=transport). " "Empty organizations are pushed to the end, the follower ranking is kept." ), ) +org_suggest_parser.add_argument( + "topic", + type=str, + location="args", + help="Restrict suggestions to organizations owning a `count_for` object in this topic.", +) +org_suggest_parser.add_argument( + "count_facet_ids", + action="split", + location="args", + help=( + "Organization ids of the current search facet. They are always kept as candidates " + "so the organizations that actually have results show up even when they are not " + "the most followed." + ), +) + + +def topic_organization_ids(count_for, topic): + """Ids of organizations owning a `count_for` object listed in this topic (the universe). + + `distinct` on the `organization` reference returns Organization documents, so we map + them to their ids for an `id__in` filter. + """ + model = COUNT_FOR_MODELS[count_for] + element_ids = topic.get_nested_elements_ids(model.__name__) + return [org.id for org in model.objects(id__in=element_ids).distinct("organization") if org] def organization_match_counts(count_for, organizations): @@ -761,24 +782,44 @@ class OrganizationSuggestAPI(API): def get(self): """Organizations suggest endpoint using mongoDB contains""" args = org_suggest_parser.parse_args() - orgs = Organization.objects( + name_match = Organization.objects( Q(name__icontains=args["q"]) | Q(acronym__icontains=args["q"]), deleted=None - ).order_by(SUGGEST_SORTING) + ) if not args["count_for"]: - return [org_suggestion(org) for org in orgs.limit(args["size"])] + return [ + org_suggestion(org) + for org in name_match.order_by(SUGGEST_SORTING).limit(args["size"]) + ] - # Count the top organizations by followers matching the name. We cap the pool - # because a name `icontains` is barely selective on short queries (see - # COUNT_CANDIDATE_POOL); the counted set stays exact for queries narrow enough - # to fit under the cap. - candidates = list(orgs.limit(COUNT_CANDIDATE_POOL)) - counts = organization_match_counts(args["count_for"], candidates) + # A — most followed organizations matching the name, restricted to the topic + # universe when given (only orgs owning a `count_for` object in the topic). + universe = name_match + if args["topic"]: + topic = Topic.objects.get_or_404(pk=args["topic"]) + universe = universe.filter(id__in=topic_organization_ids(args["count_for"], topic)) + candidates = list(universe.order_by(SUGGEST_SORTING).limit(args["size"])) + + # B — organizations from the front's current facet (those that actually have + # results), always kept as candidates so the most relevant ones show up even when + # they are not the most followed. + if args["count_facet_ids"]: + seen = {org.id for org in candidates} + candidates += [ + org + for org in name_match.filter(id__in=args["count_facet_ids"]) + if org.id not in seen + ] - # Keep the follower order (stable sort), only pushing empty organizations to the - # end. We never sort by count: a huge low-quality organization must not outrank a - # well-followed one. - candidates.sort(key=lambda org: counts.get(str(org.id), 0) == 0) + counts = organization_match_counts(args["count_for"], candidates) + # Organizations with results first (by followers), empty ones pushed to the end. We + # never sort by count: a huge low-quality org must not outrank a well-followed one. + candidates.sort( + key=lambda org: ( + counts.get(str(org.id), 0) == 0, + -(org.metrics or {}).get("followers", 0), + ) + ) return [ {**org_suggestion(org), "matching_count": counts.get(str(org.id), 0)} for org in candidates[: args["size"]] diff --git a/udata/tests/api/test_organizations_api.py b/udata/tests/api/test_organizations_api.py index 5dc26dad00..950e52ba4c 100644 --- a/udata/tests/api/test_organizations_api.py +++ b/udata/tests/api/test_organizations_api.py @@ -13,6 +13,7 @@ from udata.core.discussions.factories import DiscussionFactory from udata.core.organization.factories import OrganizationFactory from udata.core.reuse.factories import ReuseFactory +from udata.core.topic.factories import TopicElementDatasetFactory, TopicFactory from udata.core.user.factories import AdminFactory, UserFactory from udata.features.notifications.models import Notification from udata.i18n import _ @@ -1080,6 +1081,49 @@ def test_suggest_organizations_invalid_count_for(self): response = self.get(url_for("api.suggest_organizations", q="x", size=5, count_for="banana")) assert response.status_code == 400 + def test_suggest_organizations_restricted_by_topic_selection(self): + """`topic` limits candidates to organizations owning a dataset in the topic. + + This restriction is pure MongoDB (via TopicElement), so it is exercised without a + running search service — only the candidate set is asserted, not the counts. + """ + in_topic = OrganizationFactory(name="topic-member") + out_topic = OrganizationFactory(name="topic-outsider") + dataset_in = DatasetFactory(organization=in_topic) + DatasetFactory(organization=out_topic) + topic = TopicFactory() + TopicElementDatasetFactory(topic=topic, element=dataset_in) + + response = self.get( + url_for( + "api.suggest_organizations", + q="topic", + size=10, + count_for="dataset", + topic=str(topic.id), + ) + ) + assert200(response) + assert [org["name"] for org in response.json] == ["topic-member"] + + def test_suggest_organizations_facet_ids_must_match_query(self): + """Facet ids only contribute organizations that also match the name query.""" + OrganizationFactory(name="alpha-corp") + beta = OrganizationFactory(name="beta-corp") + response = self.get( + url_for( + "api.suggest_organizations", + q="alpha", + size=10, + count_for="dataset", + count_facet_ids=str(beta.id), + ) + ) + assert200(response) + names = [org["name"] for org in response.json] + assert "alpha-corp" in names + assert "beta-corp" not in names + def test_suggest_organizations_api(self): """It should suggest organizations""" for i in range(3): diff --git a/udata/tests/search/test_search_integration.py b/udata/tests/search/test_search_integration.py index d4e6be9740..96a6b6f23e 100644 --- a/udata/tests/search/test_search_integration.py +++ b/udata/tests/search/test_search_integration.py @@ -12,7 +12,11 @@ from udata.core.organization.factories import OrganizationFactory from udata.core.post.factories import PostFactory from udata.core.reuse.factories import VisibleReuseFactory -from udata.core.topic.factories import TopicElementFactory, TopicFactory +from udata.core.topic.factories import ( + TopicElementDatasetFactory, + TopicElementFactory, + TopicFactory, +) from udata.core.user.factories import UserFactory from udata.tests.api import APITestCase from udata.tests.helpers import requires_search_service @@ -716,3 +720,125 @@ def test_suggest_organizations_dataservice_count(self): counts = {org["name"]: org["matching_count"] for org in response.json} assert counts == {"Api Provider": 1, "Api Nothing": 0} assert response.json[-1]["name"] == "Api Nothing" + + def test_suggest_organizations_restricted_by_topic(self): + """`topic` restricts candidates to orgs owning a dataset in the topic.""" + in_topic = OrganizationFactory(name="Topic Member") + out_topic = OrganizationFactory(name="Topic Outsider") + dataset_in = DatasetFactory(organization=in_topic) + DatasetFactory(organization=out_topic) + topic = TopicFactory() + TopicElementDatasetFactory(topic=topic, element=dataset_in) + + time.sleep(1) + + response = self.get( + url_for( + "api.suggest_organizations", + q="Topic", + size=10, + count_for="dataset", + topic=str(topic.id), + ) + ) + self.assert200(response) + names = [org["name"] for org in response.json] + assert names == ["Topic Member"] + assert response.json[0]["matching_count"] == 1 + + def test_suggest_organizations_facet_ids_surface_relevant_orgs(self): + """count_facet_ids brings in result-having orgs beyond the follower-limited top.""" + org_top = OrganizationFactory(name="Facet One", metrics={"followers": 3}) + OrganizationFactory(name="Facet Two", metrics={"followers": 2}) # no dataset + org_low = OrganizationFactory(name="Facet Three", metrics={"followers": 1}) + DatasetFactory(organization=org_top) + DatasetFactory(organization=org_low) + + time.sleep(1) + + # size=2 → A (top followers) = One, Two ; Three is only reachable via the facet ids. + response = self.get( + url_for( + "api.suggest_organizations", + q="Facet", + size=2, + count_for="dataset", + count_facet_ids=str(org_low.id), + ) + ) + self.assert200(response) + # "Two" (0 result) is demoted out, "Three" surfaces thanks to the facet ids. + assert [org["name"] for org in response.json] == ["Facet One", "Facet Three"] + assert [org["matching_count"] for org in response.json] == [1, 1] + + def test_suggest_organizations_reuse_count(self): + """count_for=reuse counts reuses per organization.""" + org = OrganizationFactory(name="Reuse Org") + VisibleReuseFactory(organization=org) + VisibleReuseFactory(organization=org) + + time.sleep(1) + + response = self.get( + url_for("api.suggest_organizations", q="Reuse", size=10, count_for="reuse") + ) + self.assert200(response) + assert response.json[0]["name"] == "Reuse Org" + assert response.json[0]["matching_count"] == 2 + + def test_suggest_organizations_ranked_by_followers_not_count(self): + """Among orgs with results, ranking is by followers — never by number of matches.""" + popular = OrganizationFactory(name="Rank Popular", metrics={"followers": 100}) + prolific = OrganizationFactory(name="Rank Prolific", metrics={"followers": 1}) + DatasetFactory(organization=popular) + DatasetFactory.create_batch(3, organization=prolific) + + time.sleep(1) + + response = self.get( + url_for("api.suggest_organizations", q="Rank", size=10, count_for="dataset") + ) + self.assert200(response) + # Prolific has more datasets, but Popular has far more followers → Popular first. + assert [org["name"] for org in response.json] == ["Rank Popular", "Rank Prolific"] + assert {org["name"]: org["matching_count"] for org in response.json} == { + "Rank Popular": 1, + "Rank Prolific": 3, + } + + def test_suggest_organizations_topic_restriction_and_count_are_independent(self): + """`topic` restricts candidates; the count scope is driven separately by count_filter.""" + org = OrganizationFactory(name="Deco Org") + dataset_in = DatasetFactory(organization=org) + DatasetFactory(organization=org) # second dataset, not in the topic + topic = TopicFactory() + TopicElementDatasetFactory(topic=topic, element=dataset_in) + + time.sleep(1) + + # Restricted to the topic universe, no count scope → counts all org datasets (2). + response = self.get( + url_for( + "api.suggest_organizations", + q="Deco", + size=10, + count_for="dataset", + topic=str(topic.id), + ) + ) + self.assert200(response) + assert response.json[0]["matching_count"] == 2 + + # Same candidates, but the count is scoped to the topic via count_filter → 1. + response = self.get( + url_for( + "api.suggest_organizations", + q="Deco", + size=10, + count_for="dataset", + topic=str(topic.id), + **{"count_filter.topic": str(topic.id)}, + ) + ) + self.assert200(response) + assert response.json[0]["matching_count"] == 1 From 2bf67a975b1acf8341db8b4a313fe18beb536afd Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Thu, 18 Jun 2026 10:25:57 +0200 Subject: [PATCH 4/5] apply topic restriction to facet ids candidates in org suggest --- udata/core/organization/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index 62196b4510..b78e887582 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -802,12 +802,12 @@ def get(self): # B — organizations from the front's current facet (those that actually have # results), always kept as candidates so the most relevant ones show up even when - # they are not the most followed. + # they are not the most followed. Same universe + name constraints as A. if args["count_facet_ids"]: seen = {org.id for org in candidates} candidates += [ org - for org in name_match.filter(id__in=args["count_facet_ids"]) + for org in universe.filter(id__in=args["count_facet_ids"]) if org.id not in seen ] From e93a9a4e19874e19595ae271a2ec8db176a6ff3e Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Thu, 18 Jun 2026 10:34:34 +0200 Subject: [PATCH 5/5] allow standalone topic filter --- udata/core/organization/api.py | 52 +++++++++++++---------- udata/tests/api/test_organizations_api.py | 16 +++++++ 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index b78e887582..aeba190c1d 100644 --- a/udata/core/organization/api.py +++ b/udata/core/organization/api.py @@ -714,7 +714,10 @@ def org_suggestion(org): "topic", type=str, location="args", - help="Restrict suggestions to organizations owning a `count_for` object in this topic.", + help=( + "Restrict suggestions to organizations owning an object in this topic (an object " + "of `count_for`'s kind when set, any kind otherwise). Usable without `count_for`." + ), ) org_suggest_parser.add_argument( "count_facet_ids", @@ -728,15 +731,21 @@ def org_suggestion(org): ) -def topic_organization_ids(count_for, topic): - """Ids of organizations owning a `count_for` object listed in this topic (the universe). +def topic_organization_ids(topic, count_for=None): + """Ids of organizations owning an element of this topic (its universe). - `distinct` on the `organization` reference returns Organization documents, so we map - them to their ids for an `id__in` filter. + With ``count_for`` the universe is restricted to that element type; otherwise every + element type is considered. `distinct` on the `organization` reference returns + Organization documents, hence the mapping to their ids for an `id__in` filter. """ - model = COUNT_FOR_MODELS[count_for] - element_ids = topic.get_nested_elements_ids(model.__name__) - return [org.id for org in model.objects(id__in=element_ids).distinct("organization") if org] + models = [COUNT_FOR_MODELS[count_for]] if count_for else list(COUNT_FOR_MODELS.values()) + org_ids = set() + for model in models: + element_ids = topic.get_nested_elements_ids(model.__name__) + org_ids.update( + org.id for org in model.objects(id__in=element_ids).distinct("organization") if org + ) + return list(org_ids) def organization_match_counts(count_for, organizations): @@ -782,33 +791,30 @@ class OrganizationSuggestAPI(API): def get(self): """Organizations suggest endpoint using mongoDB contains""" args = org_suggest_parser.parse_args() - name_match = Organization.objects( + orgs = Organization.objects( Q(name__icontains=args["q"]) | Q(acronym__icontains=args["q"]), deleted=None ) + # `topic` restricts to the organizations of that universe. Usable on its own (a + # plain name suggest scoped to a topic) or together with `count_for`. + if args["topic"]: + topic = Topic.objects.get_or_404(pk=args["topic"]) + orgs = orgs.filter(id__in=topic_organization_ids(topic, args["count_for"])) if not args["count_for"]: return [ - org_suggestion(org) - for org in name_match.order_by(SUGGEST_SORTING).limit(args["size"]) + org_suggestion(org) for org in orgs.order_by(SUGGEST_SORTING).limit(args["size"]) ] - # A — most followed organizations matching the name, restricted to the topic - # universe when given (only orgs owning a `count_for` object in the topic). - universe = name_match - if args["topic"]: - topic = Topic.objects.get_or_404(pk=args["topic"]) - universe = universe.filter(id__in=topic_organization_ids(args["count_for"], topic)) - candidates = list(universe.order_by(SUGGEST_SORTING).limit(args["size"])) + # A — most followed organizations of the (optionally topic-restricted) universe. + candidates = list(orgs.order_by(SUGGEST_SORTING).limit(args["size"])) # B — organizations from the front's current facet (those that actually have - # results), always kept as candidates so the most relevant ones show up even when - # they are not the most followed. Same universe + name constraints as A. + # results), kept as candidates so the most relevant ones show up even when they are + # not the most followed. Same universe + name constraints as A. if args["count_facet_ids"]: seen = {org.id for org in candidates} candidates += [ - org - for org in universe.filter(id__in=args["count_facet_ids"]) - if org.id not in seen + org for org in orgs.filter(id__in=args["count_facet_ids"]) if org.id not in seen ] counts = organization_match_counts(args["count_for"], candidates) diff --git a/udata/tests/api/test_organizations_api.py b/udata/tests/api/test_organizations_api.py index 950e52ba4c..4630efd6de 100644 --- a/udata/tests/api/test_organizations_api.py +++ b/udata/tests/api/test_organizations_api.py @@ -1106,6 +1106,22 @@ def test_suggest_organizations_restricted_by_topic_selection(self): assert200(response) assert [org["name"] for org in response.json] == ["topic-member"] + def test_suggest_organizations_topic_standalone_without_count_for(self): + """`topic` works on its own: a plain name suggest scoped to the topic's orgs.""" + in_topic = OrganizationFactory(name="universe-member") + out_topic = OrganizationFactory(name="universe-outsider") + dataset_in = DatasetFactory(organization=in_topic) + DatasetFactory(organization=out_topic) + topic = TopicFactory() + TopicElementDatasetFactory(topic=topic, element=dataset_in) + + response = self.get( + url_for("api.suggest_organizations", q="universe", size=10, topic=str(topic.id)) + ) + assert200(response) + assert [org["name"] for org in response.json] == ["universe-member"] + assert response.json[0]["matching_count"] is None + def test_suggest_organizations_facet_ids_must_match_query(self): """Facet ids only contribute organizations that also match the name query.""" OrganizationFactory(name="alpha-corp")