Skip to content
Merged
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
12 changes: 11 additions & 1 deletion backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tool_instance_v2.models import ToolInstance
from utils.filtering import FilterHelper
from utils.pagination import OptionalPagination
from utils.user_context import UserContext

from adapter_processor_v2.adapter_processor import AdapterProcessor
Expand Down Expand Up @@ -146,6 +147,7 @@ class AdapterInstanceViewSet(
OwnerManagementMixin, ResourceShareManagementMixin, ModelViewSet
):
serializer_class = AdapterInstanceSerializer
pagination_class = OptionalPagination
notification_resource_name_field = "adapter_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand Down Expand Up @@ -187,7 +189,15 @@ def get_queryset(self) -> QuerySet | None:
constant.ADAPTER_NAME,
):
queryset = queryset.filter(**filter_args)
return queryset

search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(adapter_name__icontains=search)

# Order by the DISTINCT ON field so pagination is deterministic and the
# admin/service branch (no distinct) is ordered too. Not modified_at:
# that would conflict with the DISTINCT ON in for_user().
return queryset.order_by("id")

def get_serializer_class(
self,
Expand Down
11 changes: 10 additions & 1 deletion backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from rest_framework.versioning import URLPathVersioning
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.filtering import FilterHelper
from utils.pagination import OptionalPagination
from utils.user_context import UserContext

from backend.constants import RequestKey
Expand All @@ -45,6 +46,7 @@ class ConnectorInstanceViewSet(
):
versioning_class = URLPathVersioning
serializer_class = ConnectorInstanceSerializer
pagination_class = OptionalPagination
notification_resource_name_field = "connector_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand Down Expand Up @@ -103,6 +105,10 @@ def get_queryset(self) -> QuerySet | None:
if filter_args:
queryset = queryset.filter(**filter_args)

search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(connector_name__icontains=search)

# Filter by connector_mode
connector_mode_param = self.request.query_params.get("connector_mode")
if connector_mode_param:
Expand All @@ -121,7 +127,10 @@ def get_queryset(self) -> QuerySet | None:
)
queryset = queryset.none()

return queryset
# Order by the DISTINCT ON field so pagination is deterministic and the
# admin/service branch (no distinct) is ordered too. Not modified_at:
# that would conflict with the DISTINCT ON in for_user().
return queryset.order_by("id")

def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None:
"""Gets connector metadata for the ConnectorInstance.
Expand Down
9 changes: 8 additions & 1 deletion backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from tool_instance_v2.models import ToolInstance
from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper
from utils.hubspot_notify import notify_hubspot_event
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
from utils.user_session import UserSessionUtils
from workflow_manager.endpoint_v2.models import WorkflowEndpoint
Expand Down Expand Up @@ -128,6 +129,7 @@ class PromptStudioCoreView(
"""Viewset to handle all Custom tool related operations."""

versioning_class = URLPathVersioning
pagination_class = OptionalPagination

serializer_class = CustomToolSerializer
notification_resource_name_field = "tool_name"
Expand Down Expand Up @@ -172,7 +174,12 @@ def get_queryset(self) -> QuerySet | None:
qs = qs.select_related("created_by").annotate(
_prompt_count=Subquery(prompt_count_sq),
)
return qs
search = self.request.query_params.get("search")
if search:
qs = qs.filter(tool_name__icontains=search)
# Order by the DISTINCT ON field so pagination is deterministic and the
# admin/service branch (no distinct) is ordered too.
return qs.order_by("tool_id")

def get_object(self):
"""Override get_object to trigger lazy migration when accessing tools."""
Expand Down
20 changes: 20 additions & 0 deletions backend/utils/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,23 @@ class CustomPagination(PageNumberPagination):
page_size = Pagination.PAGE_SIZE
page_size_query_param = Pagination.PAGE_SIZE_QUERY_PARAM
max_page_size = Pagination.MAX_PAGE_SIZE


class OptionalPagination(CustomPagination):
"""Paginate only when the caller opts in via ?page / ?page_size.

These list endpoints are shared: besides the listing page they feed
dropdowns/selectors that expect a bare array. Returning None keeps that
response untouched for callers that don't ask for a page, while the
listing page opts in and gets the {count, next, previous, results} envelope.
"""

def paginate_queryset(self, queryset, request, view=None):
params = request.query_params
# Blank values (?page=) count as not opting in, so shared callers keep
# their bare-array response instead of getting an enveloped page.
if not params.get(self.page_query_param) and not params.get(
self.page_size_query_param
):
return None
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return super().paginate_queryset(queryset, request, view=view)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
81 changes: 81 additions & 0 deletions backend/utils/tests/test_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Contract tests for utils.pagination.OptionalPagination.

The four listing endpoints it backs (workflows, prompt studio, adapters,
connectors) are shared with dropdown/selector consumers that expect a bare
array. The opt-in behaviour below is what keeps those consumers regression-free:
no page/page_size param -> paginate_queryset returns None -> DRF serialises the
bare list; only an explicit opt-in gets the {count, next, previous, results}
envelope.

Deliberately DB-free and Django-settings-free (a hand-rolled request stub, no
APIRequestFactory) so it runs in the rig's unit tier.
"""

from __future__ import annotations

from utils.pagination import OptionalPagination

QUERYSET = list(range(1, 101)) # 100 sliceable items stand in for a queryset


class _Req:
"""Minimal stand-in exposing the surface DRF pagination touches."""

def __init__(self, params: dict[str, str]):
self.query_params = params

def build_absolute_uri(self) -> str:
return "https://testserver/things/"


class TestOptionalPagination:
def test_no_params_returns_none(self):
"""No opt-in -> None, so callers keep their bare-array response."""
assert OptionalPagination().paginate_queryset(QUERYSET, _Req({})) is None

def test_unrelated_params_do_not_trigger_pagination(self):
req = _Req({"adapter_type": "LLM", "search": "foo"})
assert OptionalPagination().paginate_queryset(QUERYSET, req) is None

def test_blank_params_do_not_trigger_pagination(self):
"""Empty ?page= / ?page_size= keep the bare-array response."""
req = _Req({"page": "", "page_size": ""})
assert OptionalPagination().paginate_queryset(QUERYSET, req) is None

def test_single_blank_param_does_not_trigger_pagination(self):
for params in ({"page": ""}, {"page_size": ""}):
assert OptionalPagination().paginate_queryset(QUERYSET, _Req(params)) is None

def test_blank_page_with_explicit_page_size_serves_first_page(self):
"""A blank ?page= alongside a real ?page_size= is page 1, not an error.

DRF reads the page number as `query_params.get("page") or 1`, so a blank
value falls back to the first page instead of raising NotFound.
"""
page = OptionalPagination().paginate_queryset(
QUERYSET, _Req({"page": "", "page_size": "10"})
)
assert page == list(range(1, 11))

def test_blank_page_size_with_explicit_page_uses_default_size(self):
page = OptionalPagination().paginate_queryset(
QUERYSET, _Req({"page": "2", "page_size": ""})
)
assert page == list(range(51, 101))

def test_page_param_opts_in(self):
paginator = OptionalPagination()
page = paginator.paginate_queryset(QUERYSET, _Req({"page": "1"}))
assert page == list(range(1, 51)) # default page_size == 50
assert paginator.page.paginator.count == 100

def test_page_size_param_alone_opts_in(self):
page = OptionalPagination().paginate_queryset(QUERYSET, _Req({"page_size": "10"}))
assert page == list(range(1, 11))

def test_page_size_capped_at_max(self):
page = OptionalPagination().paginate_queryset(
QUERYSET, _Req({"page_size": "100000"})
)
# max_page_size (1000) caps the request; all 100 rows fit in one page
assert page == QUERYSET
17 changes: 13 additions & 4 deletions backend/workflow_manager/workflow_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from rest_framework.views import APIView
from utils.filtering import FilterHelper
from utils.organization_utils import filter_queryset_by_organization, resolve_organization
from utils.pagination import OptionalPagination

from backend.constants import RequestKey
from unstract.core.data_models import FileHistoryCreateRequest
Expand Down Expand Up @@ -75,6 +76,7 @@ class WorkflowViewSet(
OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet
):
versioning_class = URLPathVersioning
pagination_class = OptionalPagination
notification_resource_name_field = "workflow_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand Down Expand Up @@ -112,11 +114,18 @@ def get_queryset(self) -> QuerySet:
queryset = queryset.select_related("created_by").prefetch_related(
"memberships__user"
)

search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(workflow_name__icontains=search)

# `id` tiebreaker keeps ordering deterministic across paginated requests
# (the for_user() manager uses plain .distinct(), so there is no default)
order_by = self.request.query_params.get("order_by")
if order_by == "desc":
queryset = queryset.order_by("-modified_at")
elif order_by == "asc":
queryset = queryset.order_by("modified_at")
if order_by == "asc":
queryset = queryset.order_by("modified_at", "id")
else:
queryset = queryset.order_by("-modified_at", "id")

return queryset

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/workflows/workflow/Workflows.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,9 @@
flex: 1;
overflow-y: auto;
}

.workflows-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
}
Loading
Loading