From ae597a78fc6614d04b2be07e5e50ff95cbb5cf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bult=C3=A9?= Date: Tue, 24 Jun 2025 16:17:26 +0200 Subject: [PATCH 1/3] feat(topics): add search route --- udata/core/topic/apiv2.py | 18 +++++++++++++++++- udata/search/__init__.py | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/udata/core/topic/apiv2.py b/udata/core/topic/apiv2.py index f871cd6e45..26487907ae 100644 --- a/udata/core/topic/apiv2.py +++ b/udata/core/topic/apiv2.py @@ -4,6 +4,7 @@ from flask import request, url_for from flask_security import current_user +from udata import search from udata.api import API, apiv2 from udata.core.discussions.models import Discussion from udata.core.topic.api_fields import ( @@ -18,7 +19,8 @@ from udata.core.topic.models import Topic, TopicElement from udata.core.topic.parsers import TopicApiParser, TopicElementsParser from udata.core.topic.permissions import TopicEditPermission -from udata.utils import get_by +from udata.core.topic.search import TopicSearch +from udata.utils import get_by, multi_to_dict DEFAULT_SORTING = "-created_at" @@ -28,6 +30,7 @@ topic_parser = TopicApiParser() elements_parser = TopicElementsParser() +search_parser = TopicSearch.as_request_parser() common_doc = {"params": {"topic": "The topic ID"}} @@ -312,3 +315,16 @@ def put(self, topic, element_id): topic.update(**data) return element + + +@ns.route("/search/", endpoint="topic_search") +class TopicSearchAPI(API): + """Topics collection search endpoint""" + + @apiv2.doc("search_topics") + @apiv2.expect(search_parser) + @apiv2.marshal_with(topic_page_fields) + def get(self): + """Search all topics""" + search_parser.parse_args() + return search.query(TopicSearch, **multi_to_dict(request.args)) diff --git a/udata/search/__init__.py b/udata/search/__init__.py index 89b5fdd941..23c6968fbe 100644 --- a/udata/search/__init__.py +++ b/udata/search/__init__.py @@ -114,3 +114,4 @@ def init_app(app): import udata.core.dataset.search # noqa import udata.core.reuse.search # noqa import udata.core.organization.search # noqa + import udata.core.topic.search # noqa From 8a96b91301228b6c0216828ca86dd4ee38c1fe90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bult=C3=A9?= Date: Wed, 25 Jun 2025 10:21:42 +0200 Subject: [PATCH 2/3] elements_titles naive serialization --- udata/core/topic/search.py | 116 +++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 udata/core/topic/search.py diff --git a/udata/core/topic/search.py b/udata/core/topic/search.py new file mode 100644 index 0000000000..94740e4de8 --- /dev/null +++ b/udata/core/topic/search.py @@ -0,0 +1,116 @@ +import datetime + +from udata.core.spatial.constants import ADMIN_LEVEL_MAX +from udata.core.spatial.models import GeoZone, admin_levels +from udata.core.topic.models import Topic +from udata.core.topic.parsers import TopicApiParser +from udata.models import Organization, User +from udata.search import ( + BoolFilter, + Filter, + ListFilter, + ModelSearchAdapter, + ModelTermsFilter, + register, +) +from udata.utils import to_iso_datetime + +__all__ = ("TopicSearch",) + +DEFAULT_SORTING = "-created_at" + + +@register +class TopicSearch(ModelSearchAdapter): + model = Topic + search_url = "topics/" + + sorts = {"created": "created_at", "last_modified": "last_modified"} + + filters = { + "tag": ListFilter(), + "organization": ModelTermsFilter(model=Organization), + "owner": ModelTermsFilter(model=User), + "geozone": ModelTermsFilter(model=GeoZone), + "granularity": Filter(), + "featured": BoolFilter(), + } + + @classmethod + def is_indexable(cls, topic: Topic) -> bool: + return not topic.private + + @classmethod + def mongo_search(cls, args): + topics = Topic.objects.visible() + topics = TopicApiParser.parse_filters(topics, args) + + sort = ( + cls.parse_sort(args["sort"]) + or ("$text_score" if args["q"] else None) + or DEFAULT_SORTING + ) + return topics.order_by(sort).paginate(args["page"], args["page_size"]) + + @classmethod + def serialize(cls, topic: Topic) -> dict: + organization = None + owner = None + if topic.organization: + org = Organization.objects(id=topic.organization.id).first() + organization = { + "id": str(org.id), + "name": org.name, + "public_service": 1 if org.public_service else 0, + "followers": org.metrics.get("followers", 0), + } + elif topic.owner: + owner = User.objects(id=topic.owner.id).first() + extras = {} + for key, value in topic.extras.items(): + extras[key] = to_iso_datetime(value) if isinstance(value, datetime.datetime) else value + + def get_elements_titles(elements: list, max_elements: int = 1000): + titles = [] + for element in elements[:max_elements]: + if element.title: + titles.append(element.title) + elif element.element and getattr(element.element, "title", None): + titles.append(element.element.title) + return " ".join(titles) + + document = { + "id": str(topic.id), + "name": topic.name, + "description": topic.description, + "created_at": to_iso_datetime(topic.created_at), + "last_modified": to_iso_datetime(topic.last_modified), + "featured": topic.featured, + "organization": organization, + "owner": str(owner.id) if owner else None, + "tags": topic.tags, + "extras": extras, + "elements_titles": get_elements_titles(topic.elements), + } + + if topic.spatial is not None: + zone_ids = [z.id for z in topic.spatial.zones] + zones = GeoZone.objects(id__in=zone_ids) + geozones = [] + coverage_level = ADMIN_LEVEL_MAX + for zone in zones: + geozones.append( + { + "id": zone.id, + "name": zone.name, + } + ) + coverage_level = min(coverage_level, admin_levels[zone.level]) + document.update( + { + "geozones": geozones, + "granularity": topic.spatial.granularity, + } + ) + + return document From 3e3d86ee4dd636567da4a1a305ed3d0f2f5bdfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bult=C3=A9?= Date: Wed, 25 Jun 2025 11:28:50 +0200 Subject: [PATCH 3/3] test serializer --- udata/tests/topic/test_search.py | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 udata/tests/topic/test_search.py diff --git a/udata/tests/topic/test_search.py b/udata/tests/topic/test_search.py new file mode 100644 index 0000000000..3e3849b5cd --- /dev/null +++ b/udata/tests/topic/test_search.py @@ -0,0 +1,45 @@ +from udata.core.organization import constants as org_constants +from udata.core.organization.factories import OrganizationFactory +from udata.core.spatial.factories import SpatialCoverageFactory +from udata.core.topic.factories import TopicFactory +from udata.core.topic.search import TopicSearch +from udata.tests.api import APITestCase +from udata.utils import to_iso_datetime + + +class TestTopicSearch(APITestCase): + def test_adapter_serialize(self): + org = OrganizationFactory(name="orga") + org.add_badge(org_constants.CERTIFIED) + org.add_badge(org_constants.PUBLIC_SERVICE) + assert org.public_service is True + spatial = SpatialCoverageFactory() + topic = TopicFactory(private=False, organization=org, spatial=spatial) + + assert TopicSearch.is_indexable(topic) + serialized = TopicSearch.serialize(topic) + assert serialized == { + "id": str(topic.id), + "name": topic.name, + "description": topic.description, + "created_at": to_iso_datetime(topic.created_at), + "last_modified": to_iso_datetime(topic.last_modified), + "featured": topic.featured, + "organization": { + "id": str(topic.organization.id), + "name": "orga", + "public_service": 1, + "followers": 0, + }, + "owner": None, + "tags": topic.tags, + "extras": topic.extras, + "elements_titles": " ".join([element.title for element in topic.elements]), + "geozones": [ + { + "id": spatial.zones[0].id, + "name": spatial.zones[0].name, + } + ], + "granularity": spatial.granularity, + }