From 1f99547f42ae67435cec6a4d81270bc9dc6aa9b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:07:58 +0000 Subject: [PATCH 1/4] feat: skip COUNT(*) by default in list endpoints via with_counts param Add a with_counts query parameter to LimitOffsetPaginationWithPermissions. When with_counts is not provided or set to false (the default), the expensive COUNT(*) query is skipped and count is returned as null. A limit+1 fetch strategy is used to determine next/previous links without needing the full count. Existing tests that asserted on the count value are updated to pass with_counts=true explicitly. Co-Authored-By: Claude Agent-Logs-Url: https://github.com/RolnickLab/antenna/sessions/08338cc2-3ec7-4991-b383-ddba7fc5f357 Co-authored-by: mihow <158175+mihow@users.noreply.github.com> --- ami/base/pagination.py | 103 +++++++++++++++++++++++++++++++++++++++-- ami/main/tests.py | 82 +++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 6 deletions(-) diff --git a/ami/base/pagination.py b/ami/base/pagination.py index 9ebca7b21..fb515aa46 100644 --- a/ami/base/pagination.py +++ b/ami/base/pagination.py @@ -1,17 +1,112 @@ +from django.forms import BooleanField from rest_framework.pagination import LimitOffsetPagination +from rest_framework.response import Response from .permissions import add_collection_level_permissions +# Query parameter name used to request the total count in paginated list responses. +# When not provided or set to false, COUNT(*) is skipped for performance on large tables. +WITH_TOTAL_COUNT_PARAM = "with_counts" + class LimitOffsetPaginationWithPermissions(LimitOffsetPagination): + """ + LimitOffsetPagination that optionally skips the expensive COUNT(*) query. + + By default the total count is not computed (``with_counts`` defaults to + ``False``). Callers that need the total for pagination UI can pass + ``?with_counts=true``; all other callers get a fast response where + ``count`` is ``null`` in the JSON payload. + + When the count is skipped, ``next`` / ``previous`` cursor links are still + computed correctly by fetching one extra row to detect whether a following + page exists. + """ + + # Sentinel used internally when COUNT(*) is skipped. + _SKIP_COUNT = object() + + def paginate_queryset(self, queryset, request, view=None): + self.request = request + self.limit = self.get_limit(request) + if self.limit is None: + return None + self.offset = self.get_offset(request) + + if self._should_skip_count(request): + # Fetch one extra item to detect whether a next page exists without + # issuing a COUNT(*) on the full table. + page = list(queryset[self.offset : self.offset + self.limit + 1]) + self._has_next = len(page) > self.limit + self.count = self._SKIP_COUNT # type: ignore[assignment] + return page[: self.limit] + + # Normal path: compute the exact total count. + self.count = self.get_count(queryset) + if self.count > self.limit and self.template is not None: + self.display_page_controls = True + if self.count == 0 or self.offset > self.count: + return [] + return list(queryset[self.offset : self.offset + self.limit]) + + def get_next_link(self): + if self.count is self._SKIP_COUNT: + if not self._has_next: + return None + url = self.request.build_absolute_uri() + from rest_framework.pagination import replace_query_param + + url = replace_query_param(url, self.limit_query_param, self.limit) + return replace_query_param(url, self.offset_query_param, self.offset + self.limit) + return super().get_next_link() + + def get_previous_link(self): + # Previous link logic does not depend on the total count. + if self.count is self._SKIP_COUNT: + if self.offset <= 0: + return None + url = self.request.build_absolute_uri() + from rest_framework.pagination import replace_query_param + + url = replace_query_param(url, self.limit_query_param, self.limit) + offset = max(0, self.offset - self.limit) + if offset == 0: + return url + return replace_query_param(url, self.offset_query_param, offset) + return super().get_previous_link() + def get_paginated_response(self, data): model = self._get_current_model() project = self._get_project() - paginated_response = super().get_paginated_response(data=data) - paginated_response.data = add_collection_level_permissions( - user=self.request.user, response_data=paginated_response.data, model=model, project=project + count = None if self.count is self._SKIP_COUNT else self.count + response = Response( + { + "count": count, + "next": self.get_next_link(), + "previous": self.get_previous_link(), + "results": data, + } ) - return paginated_response + response.data = add_collection_level_permissions( + user=self.request.user, response_data=response.data, model=model, project=project + ) + return response + + def get_paginated_response_schema(self, schema): + paginated_schema = super().get_paginated_response_schema(schema) + # Allow count to be null when WITH_TOTAL_COUNT_PARAM is not requested. + paginated_schema["properties"]["count"]["nullable"] = True + return paginated_schema + + def _should_skip_count(self, request) -> bool: + """Return True when the caller has not opted in to receiving the total count.""" + raw = request.query_params.get(WITH_TOTAL_COUNT_PARAM, None) + if raw is None: + return True + try: + return not BooleanField(required=False).clean(raw) + except Exception: + return True def _get_current_model(self): """ diff --git a/ami/main/tests.py b/ami/main/tests.py index 4bfbdc4de..6610d238d 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -810,7 +810,7 @@ def setUp(self) -> None: def test_occurrences_for_project(self): # Test that occurrences are specific to each project for project in [self.project_one, self.project_two]: - response = self.client.get(f"/api/v2/occurrences/?project_id={project.pk}") + response = self.client.get(f"/api/v2/occurrences/?project_id={project.pk}&with_counts=true") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["count"], Occurrence.objects.filter(project=project).count()) @@ -853,7 +853,7 @@ def _test_taxa_for_project(self, project: Project): """ from ami.main.models import Taxon - response = self.client.get(f"/api/v2/taxa/?project_id={project.pk}") + response = self.client.get(f"/api/v2/taxa/?project_id={project.pk}&with_counts=true") self.assertEqual(response.status_code, 200) project_occurred_taxa = Taxon.objects.filter(occurrences__project=project).distinct() # project_any_taxa = Taxon.objects.filter(projects=project) @@ -3782,3 +3782,81 @@ def test_list_pipelines_public_project_non_member(self): self.client.force_authenticate(user=non_member) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class TestPaginationWithCounts(APITestCase): + """ + Verify that list endpoints skip the COUNT(*) query by default and include + it only when ``with_counts=true`` is passed. + """ + + def setUp(self) -> None: + project, deployment = setup_test_project() + create_captures(deployment=deployment, num_nights=2, images_per_night=5) + self.project = project + self.user = User.objects.create_user( # type: ignore + email="pagination_test@insectai.org", + is_staff=True, + is_superuser=True, + ) + self.client.force_authenticate(user=self.user) + return super().setUp() + + def _captures_url(self, **params): + from urllib.parse import urlencode + + base = f"/api/v2/captures/?project_id={self.project.pk}" + if params: + base += "&" + urlencode(params) + return base + + def test_default_response_has_null_count(self): + """Without with_counts, the response count field is null.""" + response = self.client.get(self._captures_url(limit=5)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("count", data) + self.assertIsNone(data["count"]) + self.assertIn("results", data) + + def test_with_counts_true_returns_integer_count(self): + """with_counts=true causes count to be an integer.""" + response = self.client.get(self._captures_url(with_counts="true", limit=5)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNotNone(data["count"]) + self.assertIsInstance(data["count"], int) + self.assertGreater(data["count"], 0) + + def test_next_link_present_when_more_results(self): + """next link is returned even without count when more results exist.""" + total = SourceImage.objects.filter(deployment__project=self.project).count() + limit = max(1, total - 1) + response = self.client.get(self._captures_url(limit=limit)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNone(data["count"]) + self.assertIsNotNone(data["next"]) + + def test_next_link_absent_on_last_page(self): + """next is None when the current page is the last page.""" + total = SourceImage.objects.filter(deployment__project=self.project).count() + response = self.client.get(self._captures_url(limit=total)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNone(data["count"]) + self.assertIsNone(data["next"]) + + def test_previous_link_present_with_nonzero_offset(self): + """previous link is returned correctly without count.""" + response = self.client.get(self._captures_url(limit=2, offset=2)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNone(data["count"]) + self.assertIsNotNone(data["previous"]) + + def test_with_counts_false_explicit(self): + """Explicitly passing with_counts=false also returns null count.""" + response = self.client.get(self._captures_url(with_counts="false", limit=5)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.json()["count"]) From f8b11bae9aabe4b60ae05a4f36926ca6d06cfeb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:10:09 +0000 Subject: [PATCH 2/4] refactor: address code review feedback on pagination - Move replace_query_param and remove_query_param imports to top-level - Use remove_query_param when offset is 0 in get_previous_link - Narrow exception catch to ValidationError instead of bare Exception Co-Authored-By: Claude Agent-Logs-Url: https://github.com/RolnickLab/antenna/sessions/08338cc2-3ec7-4991-b383-ddba7fc5f357 Co-authored-by: mihow <158175+mihow@users.noreply.github.com> --- ami/base/pagination.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ami/base/pagination.py b/ami/base/pagination.py index fb515aa46..8b057113a 100644 --- a/ami/base/pagination.py +++ b/ami/base/pagination.py @@ -1,5 +1,6 @@ +from django.core.exceptions import ValidationError from django.forms import BooleanField -from rest_framework.pagination import LimitOffsetPagination +from rest_framework.pagination import LimitOffsetPagination, remove_query_param, replace_query_param from rest_framework.response import Response from .permissions import add_collection_level_permissions @@ -54,8 +55,6 @@ def get_next_link(self): if not self._has_next: return None url = self.request.build_absolute_uri() - from rest_framework.pagination import replace_query_param - url = replace_query_param(url, self.limit_query_param, self.limit) return replace_query_param(url, self.offset_query_param, self.offset + self.limit) return super().get_next_link() @@ -66,12 +65,10 @@ def get_previous_link(self): if self.offset <= 0: return None url = self.request.build_absolute_uri() - from rest_framework.pagination import replace_query_param - url = replace_query_param(url, self.limit_query_param, self.limit) offset = max(0, self.offset - self.limit) if offset == 0: - return url + return remove_query_param(url, self.offset_query_param) return replace_query_param(url, self.offset_query_param, offset) return super().get_previous_link() @@ -105,7 +102,7 @@ def _should_skip_count(self, request) -> bool: return True try: return not BooleanField(required=False).clean(raw) - except Exception: + except ValidationError: return True def _get_current_model(self): From 4d4e01858ff348c9c43f31bed2df2d8dd3a3a1b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:28:00 +0000 Subject: [PATCH 3/4] feat: capped COUNT(*) safety valve for with_counts=true requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When with_counts=true is requested, run a bounded COUNT(*) via a LIMIT N+1 subquery instead of a full table scan. Result sets ≤ LARGE_QUERYSET_THRESHOLD (10,000) get an exact count; larger ones fall back to count:null with probe-based next/previous links. Co-Authored-By: Claude Agent-Logs-Url: https://github.com/RolnickLab/antenna/sessions/cf5a994a-a9df-4c62-80c3-2808f589bbcc Co-authored-by: mihow <158175+mihow@users.noreply.github.com> --- ami/base/pagination.py | 38 ++++++++++++++++++++++++++++++++++++-- ami/main/tests.py | 22 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/ami/base/pagination.py b/ami/base/pagination.py index 8b057113a..bc3398b2a 100644 --- a/ami/base/pagination.py +++ b/ami/base/pagination.py @@ -27,6 +27,11 @@ class LimitOffsetPaginationWithPermissions(LimitOffsetPagination): # Sentinel used internally when COUNT(*) is skipped. _SKIP_COUNT = object() + # Maximum rows scanned when with_counts=true is requested. If the filtered + # result set contains at least this many rows the full COUNT(*) is abandoned + # and the response falls back to ``count: null``. + LARGE_QUERYSET_THRESHOLD = 10_000 + def paginate_queryset(self, queryset, request, view=None): self.request = request self.limit = self.get_limit(request) @@ -42,8 +47,16 @@ def paginate_queryset(self, queryset, request, view=None): self.count = self._SKIP_COUNT # type: ignore[assignment] return page[: self.limit] - # Normal path: compute the exact total count. - self.count = self.get_count(queryset) + # with_counts=true path: attempt a capped count so we never run a + # full COUNT(*) against a huge result set. + self.count = self._get_capped_count(queryset) + if self.count is self._SKIP_COUNT: + # Result set exceeds LARGE_QUERYSET_THRESHOLD - fall back to the + # probe-based fast path (count stays null in the response). + page = list(queryset[self.offset : self.offset + self.limit + 1]) + self._has_next = len(page) > self.limit + return page[: self.limit] + if self.count > self.limit and self.template is not None: self.display_page_controls = True if self.count == 0 or self.offset > self.count: @@ -95,6 +108,27 @@ def get_paginated_response_schema(self, schema): paginated_schema["properties"]["count"]["nullable"] = True return paginated_schema + def _get_capped_count(self, queryset): + """ + Run a bounded COUNT(*) that stops scanning after ``LARGE_QUERYSET_THRESHOLD`` + rows. Returns the exact count when the result set is small, or the + ``_SKIP_COUNT`` sentinel when the threshold is reached so callers can + fall back gracefully. + + Django translates ``queryset[:N].count()`` into:: + + SELECT COUNT(*) FROM (SELECT … LIMIT N) sub + + which is always O(N) regardless of total table size. + """ + # Fetch one extra row beyond the threshold so we can distinguish + # "exactly N rows" (exact count returned) from "more than N rows" + # (sentinel returned to avoid the full scan). + capped = queryset[: self.LARGE_QUERYSET_THRESHOLD + 1].count() + if capped <= self.LARGE_QUERYSET_THRESHOLD: + return capped + return self._SKIP_COUNT + def _should_skip_count(self, request) -> bool: """Return True when the caller has not opted in to receiving the total count.""" raw = request.query_params.get(WITH_TOTAL_COUNT_PARAM, None) diff --git a/ami/main/tests.py b/ami/main/tests.py index 6610d238d..0296845e1 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -3860,3 +3860,25 @@ def test_with_counts_false_explicit(self): response = self.client.get(self._captures_url(with_counts="false", limit=5)) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNone(response.json()["count"]) + + def test_with_counts_true_falls_back_when_large(self): + """ + When with_counts=true is requested but the result set meets or exceeds + LARGE_QUERYSET_THRESHOLD, count is null and next/previous links still work. + """ + from unittest.mock import patch + + from ami.base.pagination import LimitOffsetPaginationWithPermissions + + # Patch the threshold to 1 so even a single row triggers the fallback. + with patch.object(LimitOffsetPaginationWithPermissions, "LARGE_QUERYSET_THRESHOLD", 1): + total = SourceImage.objects.filter(deployment__project=self.project).count() + self.assertGreater(total, 1, "Need at least 2 captures for this test") + + # Page 1 – should see next link but null count. + response = self.client.get(self._captures_url(with_counts="true", limit=1)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNone(data["count"], "count must be null when threshold is exceeded") + self.assertIsNotNone(data["next"], "next link must still be present") + self.assertIsNone(data["previous"]) From 0b19045dc50dfde6a989f96c2c12f0f515a70e97 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Fri, 17 Apr 2026 16:47:59 -0700 Subject: [PATCH 4/4] refactor: keep with_counts=true as default, scope this PR to opt-out only Flip the default so existing callers (and the React UI) keep receiving `count` exactly as before. Callers that don't need the total can opt out with `?with_counts=false`. The capped COUNT(*) safety valve still applies on the default path: result sets that exceed LARGE_QUERYSET_THRESHOLD return `count: null` and probe-based next/previous links. A follow-up PR will: - Update React Query hooks and PaginationBar to tolerate `count: null` - Switch list pages to request counts only when needed (e.g. via a second `with_counts=true` call to populate "showing N of total") - Flip the server default to `with_counts=false` Tests updated to assert that the default response now includes an integer count, with explicit opt-in/opt-out coverage and a fallback test for the threshold path. Co-Authored-By: Claude --- ami/base/pagination.py | 35 +++++++++++++----------- ami/main/tests.py | 62 +++++++++++++++++++++++------------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/ami/base/pagination.py b/ami/base/pagination.py index bc3398b2a..2ec26c1ef 100644 --- a/ami/base/pagination.py +++ b/ami/base/pagination.py @@ -5,23 +5,25 @@ from .permissions import add_collection_level_permissions -# Query parameter name used to request the total count in paginated list responses. -# When not provided or set to false, COUNT(*) is skipped for performance on large tables. +# Query parameter name used to opt out of the total count in paginated list responses. +# Pass ``?with_counts=false`` to skip COUNT(*) for performance on large tables. WITH_TOTAL_COUNT_PARAM = "with_counts" class LimitOffsetPaginationWithPermissions(LimitOffsetPagination): """ - LimitOffsetPagination that optionally skips the expensive COUNT(*) query. - - By default the total count is not computed (``with_counts`` defaults to - ``False``). Callers that need the total for pagination UI can pass - ``?with_counts=true``; all other callers get a fast response where - ``count`` is ``null`` in the JSON payload. - - When the count is skipped, ``next`` / ``previous`` cursor links are still - computed correctly by fetching one extra row to detect whether a following - page exists. + LimitOffsetPagination that lets callers opt out of the expensive COUNT(*) query. + + Default behavior matches DRF's upstream LimitOffsetPagination: ``count`` is + computed (via a capped COUNT(*), see ``LARGE_QUERYSET_THRESHOLD``) and + returned in the response. Callers that don't need the total can pass + ``?with_counts=false`` to skip the count entirely and receive ``count: null`` + instead. In that mode ``next`` / ``previous`` links are still computed + correctly by fetching one extra row to detect whether a following page exists. + + A follow-up PR will flip the default to ``false`` and teach the UI to + request counts only when needed. Until then the default preserves existing + behavior so no frontend changes are required. """ # Sentinel used internally when COUNT(*) is skipped. @@ -104,7 +106,8 @@ def get_paginated_response(self, data): def get_paginated_response_schema(self, schema): paginated_schema = super().get_paginated_response_schema(schema) - # Allow count to be null when WITH_TOTAL_COUNT_PARAM is not requested. + # count is null when the caller passes with_counts=false, or when a + # with_counts=true request exceeds LARGE_QUERYSET_THRESHOLD. paginated_schema["properties"]["count"]["nullable"] = True return paginated_schema @@ -130,14 +133,14 @@ def _get_capped_count(self, queryset): return self._SKIP_COUNT def _should_skip_count(self, request) -> bool: - """Return True when the caller has not opted in to receiving the total count.""" + """Return True when the caller has explicitly opted out of the total count.""" raw = request.query_params.get(WITH_TOTAL_COUNT_PARAM, None) if raw is None: - return True + return False try: return not BooleanField(required=False).clean(raw) except ValidationError: - return True + return False def _get_current_model(self): """ diff --git a/ami/main/tests.py b/ami/main/tests.py index 0296845e1..62685e378 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -810,7 +810,7 @@ def setUp(self) -> None: def test_occurrences_for_project(self): # Test that occurrences are specific to each project for project in [self.project_one, self.project_two]: - response = self.client.get(f"/api/v2/occurrences/?project_id={project.pk}&with_counts=true") + response = self.client.get(f"/api/v2/occurrences/?project_id={project.pk}") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["count"], Occurrence.objects.filter(project=project).count()) @@ -853,7 +853,7 @@ def _test_taxa_for_project(self, project: Project): """ from ami.main.models import Taxon - response = self.client.get(f"/api/v2/taxa/?project_id={project.pk}&with_counts=true") + response = self.client.get(f"/api/v2/taxa/?project_id={project.pk}") self.assertEqual(response.status_code, 200) project_occurred_taxa = Taxon.objects.filter(occurrences__project=project).distinct() # project_any_taxa = Taxon.objects.filter(projects=project) @@ -3786,8 +3786,13 @@ def test_list_pipelines_public_project_non_member(self): class TestPaginationWithCounts(APITestCase): """ - Verify that list endpoints skip the COUNT(*) query by default and include - it only when ``with_counts=true`` is passed. + Verify the ``with_counts`` opt-out on list endpoints. + + Default behavior preserves DRF's count field (so existing UI code keeps + working). Callers can pass ``with_counts=false`` to skip the COUNT(*) + query and receive ``count: null``. A capped count (see + ``LARGE_QUERYSET_THRESHOLD``) caps the worst-case scan even on the + default path. """ def setUp(self) -> None: @@ -3810,61 +3815,63 @@ def _captures_url(self, **params): base += "&" + urlencode(params) return base - def test_default_response_has_null_count(self): - """Without with_counts, the response count field is null.""" + def test_default_response_includes_integer_count(self): + """By default, count is an integer (preserves existing behavior).""" response = self.client.get(self._captures_url(limit=5)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - self.assertIn("count", data) - self.assertIsNone(data["count"]) - self.assertIn("results", data) + self.assertIsInstance(data["count"], int) + self.assertGreater(data["count"], 0) def test_with_counts_true_returns_integer_count(self): - """with_counts=true causes count to be an integer.""" + """Explicit with_counts=true also returns an integer count.""" response = self.client.get(self._captures_url(with_counts="true", limit=5)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - self.assertIsNotNone(data["count"]) self.assertIsInstance(data["count"], int) self.assertGreater(data["count"], 0) - def test_next_link_present_when_more_results(self): + def test_with_counts_false_returns_null_count(self): + """with_counts=false skips COUNT(*) and returns count: null.""" + response = self.client.get(self._captures_url(with_counts="false", limit=5)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("count", data) + self.assertIsNone(data["count"]) + self.assertIn("results", data) + + def test_with_counts_false_next_link_present_when_more_results(self): """next link is returned even without count when more results exist.""" total = SourceImage.objects.filter(deployment__project=self.project).count() limit = max(1, total - 1) - response = self.client.get(self._captures_url(limit=limit)) + response = self.client.get(self._captures_url(with_counts="false", limit=limit)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIsNone(data["count"]) self.assertIsNotNone(data["next"]) - def test_next_link_absent_on_last_page(self): + def test_with_counts_false_next_link_absent_on_last_page(self): """next is None when the current page is the last page.""" total = SourceImage.objects.filter(deployment__project=self.project).count() - response = self.client.get(self._captures_url(limit=total)) + response = self.client.get(self._captures_url(with_counts="false", limit=total)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIsNone(data["count"]) self.assertIsNone(data["next"]) - def test_previous_link_present_with_nonzero_offset(self): + def test_with_counts_false_previous_link_present_with_nonzero_offset(self): """previous link is returned correctly without count.""" - response = self.client.get(self._captures_url(limit=2, offset=2)) + response = self.client.get(self._captures_url(with_counts="false", limit=2, offset=2)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIsNone(data["count"]) self.assertIsNotNone(data["previous"]) - def test_with_counts_false_explicit(self): - """Explicitly passing with_counts=false also returns null count.""" - response = self.client.get(self._captures_url(with_counts="false", limit=5)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNone(response.json()["count"]) - - def test_with_counts_true_falls_back_when_large(self): + def test_count_falls_back_to_null_when_result_set_exceeds_threshold(self): """ - When with_counts=true is requested but the result set meets or exceeds - LARGE_QUERYSET_THRESHOLD, count is null and next/previous links still work. + When the (default) count path is taken but the result set meets or + exceeds LARGE_QUERYSET_THRESHOLD, count is null and next/previous + links still work via the probe-based path. """ from unittest.mock import patch @@ -3875,8 +3882,7 @@ def test_with_counts_true_falls_back_when_large(self): total = SourceImage.objects.filter(deployment__project=self.project).count() self.assertGreater(total, 1, "Need at least 2 captures for this test") - # Page 1 – should see next link but null count. - response = self.client.get(self._captures_url(with_counts="true", limit=1)) + response = self.client.get(self._captures_url(limit=1)) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() self.assertIsNone(data["count"], "count must be null when threshold is exceeded")