Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
- ✨ integrate Find search

### Changed

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ $ make frontend-test
$ make frontend-lint
```

Backend tests can be run without docker with the env files
`env.d/development/common` and `env.d/development/common.test`.
`common.test` must overwrite some variables in `common`.

**Adding content**

You can create a basic demo site by running this command:
Expand Down
2 changes: 1 addition & 1 deletion docs/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.

## Configure settings of Docs

Add those Django settings the Docs application to enable the feature.
Add those Django settings to the Docs application to enable the feature.

```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
Expand Down
13 changes: 7 additions & 6 deletions env.d/development/common
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}

# Store OIDC tokens in the session. Needed by search/ endpoint.
# OIDC_STORE_ACCESS_TOKEN = True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
OIDC_STORE_ACCESS_TOKEN=True
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.

# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
Expand Down Expand Up @@ -82,8 +82,9 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15

# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
# Indexer
SEARCH_INDEXER_CLASS=core.services.search_indexers.FindDocumentIndexer
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
SEARCH_INDEXER_URL=http://find:8000/api/v1.0/documents/index/
SEARCH_INDEXER_QUERY_URL=http://find:8000/api/v1.0/documents/search/
SEARCH_INDEXER_QUERY_LIMIT=50
7 changes: 7 additions & 0 deletions env.d/development/common.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Test environment configuration for running tests without docker
# Base configuration is loaded from 'common' file

DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_CONFIGURATION=Test
DB_PORT=15432
AWS_S3_ENDPOINT_URL=http://localhost:9000
1 change: 1 addition & 0 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
"search": {"GET": "retrieve"},
}


Expand Down
7 changes: 2 additions & 5 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,8 +980,5 @@ def get_abilities(self, thread):
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""

q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)
q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
path = serializers.CharField(required=False, allow_blank=False)
162 changes: 81 additions & 81 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@
from core.utils import extract_attachments, filter_descendants

from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .filters import (
DocumentFilter,
ListDocumentFilter,
UserSearchFilter,
)
from .throttling import (
DocumentThrottle,
UserListThrottleBurst,
Expand Down Expand Up @@ -459,14 +463,12 @@ def list(self, request, *args, **kwargs):
It performs early filtering on model fields, annotates user roles, and removes
descendant documents to keep only the highest ancestors readable by the current user.
"""
user = self.request.user
user = request.user

# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()

filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data
Expand Down Expand Up @@ -956,26 +958,6 @@ def all(self, request, *args, **kwargs):

return self.get_response_for_queryset(queryset)

@drf.decorators.action(
detail=True,
methods=["get"],
ordering=["path"],
)
def descendants(self, request, *args, **kwargs):
"""Handle listing descendants of a document"""
document = self.get_object()

queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)

filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)

queryset = filterset.qs

return self.get_response_for_queryset(queryset)

@drf.decorators.action(
detail=True,
methods=["get"],
Expand Down Expand Up @@ -1183,57 +1165,6 @@ def duplicate(self, request, *args, **kwargs):
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)

def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)

if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)

queryset = filterset.filter_queryset(queryset)

return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)

def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
queryset = models.Document.objects.all()

# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
)

docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]

page = self.paginate_queryset(ordered_docs)

serializer = self.get_serializer(
page if page else ordered_docs,
many=True,
context={
"request": request,
},
)

return self.get_paginated_response(serializer.data)

@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
Expand All @@ -1252,13 +1183,82 @@ def search(self, request, *args, **kwargs):
params.is_valid(raise_exception=True)

indexer = get_document_indexer()

if indexer:
return self._search_fulltext(indexer, request, params=params)
return self._search_with_indexer(indexer, request, params=params)

# The indexer is not configured, we fallback on title search
return self.title_search(request, params.validated_data, *args, **kwargs)

# The indexer is not configured, we fallback on a simple icontains filter by the
# model field 'title'.
return self._search_simple(request, text=params.validated_data["q"])
@staticmethod
def _search_with_indexer(indexer, request, params):
"""
Returns a list of documents matching the query (q) according to the configured indexer.
"""
queryset = models.Document.objects.all()
user_query = params.validated_data["q"]

results = indexer.search(
q=user_query if user_query != "" else "*",
token=request.session.get("oidc_access_token"),
path=(
params.validated_data["path"]
if "path" in params.validated_data
else None
),
visited=get_visited_document_ids_of(queryset, request.user),
)

return drf_response.Response(
{
"count": len(results),
"next": None,
"previous": None,
"results": results,
}
)

def title_search(self, request, validated_data, *args, **kwargs):
"""
Fallback search by title when indexer is not configured.
If path is provided, list descendants, otherwise list all documents.
"""
request.GET = request.GET.copy()
request.GET["title"] = validated_data["q"]
if not "path" in request.GET or not request.GET["path"]:
return self.list(request, *args, **kwargs)
return self._list_descendants(request)

def _list_descendants(self, request):
"""
List all documents whose path starts with the provided path parameter.
Includes the parent document itself.
Used internally by the search endpoint when path filtering is requested.
"""
# Get parent document without access filtering
parent_path = request.GET["path"]
try:
parent = models.Document.objects.get(path=parent_path)
except models.Document.DoesNotExist as exc:
raise drf.exceptions.NotFound("Document not found from path.") from exc

# Check object-level permissions using DocumentPermission logic
self.check_object_permissions(request, parent)

# Get descendants and include the parent, ordered by path
queryset = (
parent.get_descendants(include_self=True)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
queryset = self.filter_queryset(queryset)

# filter by title
filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)

queryset = filterset.qs
return self.get_response_for_queryset(queryset)

@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
Expand Down
Loading
Loading