diff --git a/udata/core/organization/api.py b/udata/core/organization/api.py index 279f2b9818..aeba190c1d 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 @@ -29,9 +31,11 @@ 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 +from udata.search import adapter_for, get_elastic_client from .api_fields import ( invite_fields, @@ -674,27 +678,157 @@ 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 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." + + +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), 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 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", + 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(topic, count_for=None): + """Ids of organizations owning an element of this topic (its universe). + + 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. + """ + 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): + """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 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) + return facets.get("organization_counts", {}) + + @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 ) + # `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 orgs.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), 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 orgs.filter(id__in=args["count_facet_ids"]) if org.id not in seen + ] + + 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 [ - { - "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..4630efd6de 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 _ @@ -1058,6 +1059,87 @@ 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_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_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") + 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 6a639df28e..96a6b6f23e 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 @@ -11,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 @@ -658,3 +663,182 @@ 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" + + 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 diff --git a/udata/tests/search/test_services.py b/udata/tests/search/test_services.py new file mode 100644 index 0000000000..089f5470f4 --- /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=["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"] + + +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..d2fb4427ee 100644 --- a/udata_search_service/search_clients.py +++ b/udata_search_service/search_clients.py @@ -268,6 +268,56 @@ def configure_indices(prefix): cls._index._name = cls.Index.name +def organization_counts(search, get_filters_except, organization_ids): + """Count, per organization id, the documents matching the current search. + + 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 + `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( + "organizations_filtered", "filter", filter=query.Bool(must=org_filters) + ) + else: + parent = search.aggs + parent.bucket( + "organizations", + "terms", + field="organization", + include=organization_ids, + size=len(organization_ids), + ) + response = search[:0].execute() + + counts = {} + aggregations = getattr(response, "aggregations", None) + if aggregations is not None: + container = getattr(aggregations, "organizations_filtered", aggregations) + org_agg = getattr(container, "organizations", None) + if org_agg is not None: + counts = {bucket.key: bucket.doc_count for bucket in org_agg.buckets} + return 0, [], {"organization_counts": counts} + + class ElasticClient: def __init__(self, url: str, prefix: str): self.es = connections.create_connection(hosts=[url]) @@ -647,6 +697,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 +873,9 @@ def get_filters_except(exclude_key): filters_list.append(filter_dict[key]) return filters_list + if count_organizations is not None: + return organization_counts(search, get_filters_except, count_organizations) + format_filters = get_filters_except("format_family") if format_filters: format_agg = search.aggs.bucket( @@ -1053,6 +1107,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 +1268,9 @@ def get_filters_except(exclude_key: str): flt.append(filter_dict[k]) return flt + if count_organizations is not None: + return organization_counts(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 +1401,7 @@ def query_dataservices( page_size: int, filters: dict, sort: Optional[str] = None, + count_organizations: Optional[List[str]] = None, ): search = SearchableDataservice.search() @@ -1498,6 +1557,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_counts(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