(backend) add a breadcrumb in the search response

In the search response, we want to display a breadcrumb for every
document returned. For this we added a "parents" property containing a list
documents, all are the parents of the current document (ordered by their
depth). With this we can easily create a breadcrumb.
This commit is contained in:
Manuel Raynaud
2026-05-27 17:41:39 +02:00
parent 31b088c307
commit fdc99adc78
5 changed files with 1416 additions and 6 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to
### Changed
- ♻️(backend) allow global search in sub documents
- ✨(backend) add a breadcrumb in the search response
### Fixed
+11
View File
@@ -167,6 +167,17 @@ class ListDocumentSerializer(serializers.ModelSerializer):
return instance.ancestors_deleted_at
class SearchDocumentSerializer(ListDocumentSerializer):
"""Serialize items for search."""
parents = ListDocumentSerializer(many=True, read_only=True)
class Meta:
model = models.Document
fields = ListDocumentSerializer.Meta.fields + ["parents"]
read_only_fields = ListDocumentSerializer.Meta.read_only_fields + ["parents"]
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
+55 -2
View File
@@ -558,7 +558,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.SearchDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -611,6 +611,48 @@ class DocumentViewSet(
serializer = self.get_serializer(queryset, many=True, context=context)
return drf.response.Response(serializer.data)
def _compute_parents(self, documents):
"""
Compute parents for the documents by analyzing their paths and fetching missing parents.
Nota bene: current implementation may appear naive, but it has been
optimized to avoid n+1 database queries issue. At this time, we only
perform a single database request when parents are missing.
"""
documents = list(documents)
steplen = models.Document.steplen
# Build parents dictionary and collect missing parent paths
parents = {document.path: document for document in documents}
missing_parent_paths = set()
for document in documents:
path = document.path
for i in range(steplen, len(path), steplen):
parent_path = path[:i]
if parent_path not in parents:
missing_parent_paths.add(parent_path)
# Fetch missing ancestors from database
if missing_parent_paths:
for parent in (
models.Document.objects.annotate_user_roles(self.request.user)
.annotate_is_favorite(self.request.user)
.filter(path__in=missing_parent_paths)
.iterator()
):
parents[parent.path] = parent
# Set parents for each item
for document in documents:
path = document.path
document_parents = []
for i in range(steplen, len(path), steplen):
document_parents.append(parents[path[:i]])
document.parents = document_parents
return documents
def list(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
@@ -1499,6 +1541,17 @@ class DocumentViewSet(
}
)
def _get_response_for_search_queryset(self, queryset):
page = self.paginate_queryset(queryset)
documents_queryset = page if page else queryset
documents = self._compute_parents(documents_queryset)
serializer = self.get_serializer(documents, many=True)
if page is None:
return drf.response.Response(serializer.data)
return self.get_paginated_response(serializer.data)
def _search_using_database(self, request, validated_data, *args, **kwargs):
"""
Fallback search method when no indexer is configured.
@@ -1581,7 +1634,7 @@ class DocumentViewSet(
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
return self._get_response_for_search_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -88,7 +88,9 @@ def test_api_documents_search_simple_search_anonymous(settings):
assert response.json()["count"] == 0
def test_api_documents_search_fall_back_on_simple_search(settings):
def test_api_documents_search_fall_back_on_simple_search(
settings, django_assert_num_queries
):
"""
When indexer is not configured the search should be made in the database on the
title field.
@@ -125,7 +127,8 @@ def test_api_documents_search_fall_back_on_simple_search(settings):
client.force_login(user)
q = "alpha"
response = client.get("/api/v1.0/documents/search/", data={"q": q})
with django_assert_num_queries(13):
response = client.get("/api/v1.0/documents/search/", data={"q": q})
assert response.status_code == 200
@@ -371,7 +374,7 @@ def test_api_documents_search_simple_search_only_match_in_depth(settings):
}
def test_api_documents_saerch_with_title_also_matching_link_traces(settings):
def test_api_documents_search_with_title_also_matching_link_traces(settings):
"""Test that link_traces can also be present in the search result."""
assert get_document_indexer() is None
assert settings.OIDC_STORE_REFRESH_TOKEN is False
File diff suppressed because it is too large Load Diff