Skip to content

Commit 4a9d4ec

Browse files
authored
Workspace search (baserow#4076)
Adds ability to search various items in workspace
1 parent d04b4b9 commit 4a9d4ec

File tree

61 files changed

+4849
-9
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+4849
-9
lines changed

backend/pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ markers =
5050
websockets: All tests related to handeling web socket connections
5151
import_export_workspace: All tests related to importing and exporting workspaces
5252
data_sync: All tests related to data sync functionality
53+
workspace_search: All tests related to workspace search functionality
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DEFAULT_SEARCH_LIMIT = 20

backend/src/baserow/api/search/serializers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from rest_framework import serializers
22

3+
from baserow.api.search.constants import DEFAULT_SEARCH_LIMIT
34
from baserow.contrib.database.search.handler import ALL_SEARCH_MODES
45

56

@@ -10,3 +11,43 @@ class SearchQueryParamSerializer(serializers.Serializer):
1011
default=None,
1112
choices=ALL_SEARCH_MODES,
1213
)
14+
15+
16+
class WorkspaceSearchSerializer(serializers.Serializer):
17+
"""Serializer for workspace search requests."""
18+
19+
query = serializers.CharField(min_length=1, max_length=100)
20+
limit = serializers.IntegerField(
21+
default=DEFAULT_SEARCH_LIMIT,
22+
min_value=1,
23+
max_value=100,
24+
help_text="Maximum number of results per type",
25+
)
26+
offset = serializers.IntegerField(
27+
default=0, min_value=0, help_text="Number of results to skip"
28+
)
29+
30+
31+
class SearchResultSerializer(serializers.Serializer):
32+
"""Serializer for individual search results."""
33+
34+
type = serializers.CharField()
35+
id = serializers.IntegerField()
36+
title = serializers.CharField()
37+
subtitle = serializers.CharField(required=False, allow_null=True)
38+
description = serializers.CharField(required=False, allow_null=True)
39+
metadata = serializers.DictField(required=False, allow_null=True)
40+
created_on = serializers.CharField(required=False, allow_null=True)
41+
updated_on = serializers.CharField(required=False, allow_null=True)
42+
43+
44+
class WorkspaceSearchResponseSerializer(serializers.Serializer):
45+
"""Serializer for workspace search responses."""
46+
47+
results = serializers.ListField(
48+
child=SearchResultSerializer(),
49+
help_text="Priority-ordered search results",
50+
)
51+
has_more = serializers.BooleanField(
52+
help_text="Whether there are more results available for pagination"
53+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from django.urls import path
2+
3+
from baserow.api.search.views import WorkspaceSearchView
4+
from baserow.core.feature_flags import FF_WORKSPACE_SEARCH, feature_flag_is_enabled
5+
6+
app_name = "baserow.api.search"
7+
8+
urlpatterns = []
9+
10+
if feature_flag_is_enabled(FF_WORKSPACE_SEARCH):
11+
urlpatterns = [
12+
path(
13+
"workspace/<int:workspace_id>/",
14+
WorkspaceSearchView.as_view(),
15+
name="workspace_search",
16+
),
17+
]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
2+
from drf_spectacular.utils import extend_schema
3+
from rest_framework.permissions import IsAuthenticated
4+
from rest_framework.response import Response
5+
from rest_framework.views import APIView
6+
7+
from baserow.api.decorators import map_exceptions, validate_query_parameters
8+
from baserow.api.errors import ERROR_GROUP_DOES_NOT_EXIST, ERROR_USER_NOT_IN_GROUP
9+
from baserow.api.schemas import get_error_schema
10+
from baserow.api.search.serializers import (
11+
WorkspaceSearchResponseSerializer,
12+
WorkspaceSearchSerializer,
13+
)
14+
from baserow.core.exceptions import UserNotInWorkspace, WorkspaceDoesNotExist
15+
from baserow.core.handler import CoreHandler
16+
from baserow.core.operations import ReadWorkspaceOperationType
17+
from baserow.core.search.handler import WorkspaceSearchHandler
18+
19+
20+
class WorkspaceSearchView(APIView):
21+
"""
22+
API view for workspace search functionality.
23+
"""
24+
25+
permission_classes = [IsAuthenticated]
26+
27+
@extend_schema(
28+
parameters=[
29+
OpenApiParameter(
30+
name="workspace_id",
31+
location=OpenApiParameter.PATH,
32+
type=OpenApiTypes.INT,
33+
description="Workspace ID to search within",
34+
),
35+
],
36+
request=WorkspaceSearchSerializer,
37+
responses={
38+
200: WorkspaceSearchResponseSerializer,
39+
400: get_error_schema(
40+
["ERROR_USER_NOT_IN_GROUP", "ERROR_INVALID_SEARCH_QUERY"]
41+
),
42+
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
43+
},
44+
tags=["Search"],
45+
operation_id="workspace_search",
46+
description="Search across all searchable content within a workspace",
47+
)
48+
@map_exceptions(
49+
{
50+
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
51+
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
52+
}
53+
)
54+
@validate_query_parameters(WorkspaceSearchSerializer, return_validated=True)
55+
def get(self, request, workspace_id, query_params):
56+
workspace = CoreHandler().get_workspace(workspace_id)
57+
CoreHandler().check_permissions(
58+
request.user,
59+
ReadWorkspaceOperationType.type,
60+
workspace=workspace,
61+
context=workspace,
62+
)
63+
64+
handler = WorkspaceSearchHandler()
65+
result_data = handler.search_workspace(
66+
user=request.user,
67+
workspace=workspace,
68+
query=query_params["query"],
69+
limit=query_params["limit"],
70+
offset=query_params["offset"],
71+
)
72+
serializer = WorkspaceSearchResponseSerializer(data=result_data)
73+
serializer.is_valid(raise_exception=True)
74+
return Response(serializer.data)

backend/src/baserow/api/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .jobs import urls as jobs_urls
1818
from .mcp import urls as mcp_urls
1919
from .notifications import urls as notifications_urls
20+
from .search import urls as search_urls
2021
from .settings import urls as settings_urls
2122
from .snapshots import urls as snapshots_urls
2223
from .spectacular.views import CachedSpectacularJSONAPIView
@@ -50,6 +51,7 @@
5051
path("snapshots/", include(snapshots_urls, namespace="snapshots")),
5152
path("_health/", include(health_urls, namespace="health")),
5253
path("notifications/", include(notifications_urls, namespace="notifications")),
54+
path("search/", include(search_urls, namespace="search")),
5355
path("admin/", include(admin_urls, namespace="admin")),
5456
path("mcp/", include(mcp_urls, namespace="mcp")),
5557
path(

backend/src/baserow/contrib/automation/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,9 @@ def ready(self):
213213
)
214214

215215
connect_to_node_pre_delete_signal()
216+
217+
from baserow.core.search.registries import workspace_search_registry
218+
219+
from .search_types import AutomationSearchType
220+
221+
workspace_search_registry.register(AutomationSearchType())
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from baserow.contrib.automation.models import Automation
2+
from baserow.core.search.search_types import ApplicationSearchType
3+
4+
5+
class AutomationSearchType(ApplicationSearchType):
6+
"""
7+
Searchable item type specifically for automations.
8+
"""
9+
10+
type = "automation"
11+
name = "Automation"
12+
model_class = Automation
13+
priority = 4

backend/src/baserow/contrib/builder/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,8 @@ def ready(self):
337337
# which need to be filled first.
338338
import baserow.contrib.builder.signals # noqa: F403, F401
339339
import baserow.contrib.builder.ws.signals # noqa: F403, F401
340+
from baserow.core.search.registries import workspace_search_registry
341+
342+
from .search_types import BuilderSearchType
343+
344+
workspace_search_registry.register(BuilderSearchType())
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from typing import Optional
2+
3+
from baserow.contrib.builder.models import Builder
4+
from baserow.core.models import Workspace
5+
from baserow.core.search.data_types import SearchResult
6+
from baserow.core.search.search_types import ApplicationSearchType
7+
8+
9+
class BuilderSearchType(ApplicationSearchType):
10+
"""
11+
Searchable item type specifically for builders.
12+
"""
13+
14+
type = "builder"
15+
name = "Builder"
16+
model_class = Builder
17+
priority = 2
18+
19+
def serialize_result(
20+
self, result, user=None, workspace: "Workspace" = None
21+
) -> Optional[SearchResult]:
22+
"""Convert builder to search result with builder_id in metadata."""
23+
24+
return SearchResult(
25+
type=self.type,
26+
id=result.id,
27+
title=result.name,
28+
subtitle=self.type,
29+
created_on=result.created_on,
30+
updated_on=result.updated_on,
31+
metadata={
32+
"workspace_id": workspace.id,
33+
"workspace_name": workspace.name,
34+
"builder_id": result.id,
35+
},
36+
)

0 commit comments

Comments
 (0)