From 6d7c5c5604987a6bb72f2697500fc47d77ed9b2f Mon Sep 17 00:00:00 2001
From: Ian Roddis <31021769+iroddis@users.noreply.github.com>
Date: Tue, 11 Aug 2026 11:33:56 -0300
Subject: [PATCH] Adding pagination to scopes page
---
app/services/scope_guide.py | 4 +-
app/templates/_scopes_table_card.html | 1 +
app/web/admin.py | 51 +++-----------------
app/web/pagination.py | 40 ++++++++++++++++
app/web/scopes.py | 48 ++++++++++++++++---
tests/test_scope_guide.py | 67 +++++++++++++++++++++++++++
6 files changed, 160 insertions(+), 51 deletions(-)
create mode 100644 app/web/pagination.py
diff --git a/app/services/scope_guide.py b/app/services/scope_guide.py
index 3df7864..daa6504 100644
--- a/app/services/scope_guide.py
+++ b/app/services/scope_guide.py
@@ -35,7 +35,9 @@ async def build(
The scope rows are narrowed by the scope-attribute filters (``description``
matches the scope's description; the ``*_id`` filters match exactly).
``fits_part_type_id`` keeps only scopes that have at least one (non-``none``)
- fit for that part type.
+ fit for that part type. Callers that paginate should slice ``rows``
+ themselves — this always returns the full filtered set, since
+ ``fits_part_type_id`` is a post-filter that can't be pushed into the DB query.
Shape::
diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html
index 8c87345..aa19d56 100644
--- a/app/templates/_scopes_table_card.html
+++ b/app/templates/_scopes_table_card.html
@@ -18,6 +18,7 @@
{%- endif -%}
{%- endmacro -%}
+ {% include "admin/_pagination.html" %}
diff --git a/app/web/admin.py b/app/web/admin.py
index 3c99771..1d0a5ff 100644
--- a/app/web/admin.py
+++ b/app/web/admin.py
@@ -1,8 +1,5 @@
"""HTMX-driven admin dashboard for users, roles, and thread standards."""
-import math
-import urllib.parse
-
from fastapi import (
APIRouter,
Depends,
@@ -17,9 +14,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal, get_db
+from app.crud import attachment_point as attachment_point_crud
+from app.crud import attachment_point_name as apn_crud
from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud
from app.crud import augmented_part_note as augmented_part_note_crud
+from app.crud import clamp_connection as clamp_connection_crud
+from app.crud import fit as fit_crud
+from app.crud import fit_note as fit_note_crud
from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_crud
@@ -29,11 +31,6 @@ from app.crud import part_type as part_type_crud
from app.crud import role as role_crud
from app.crud import scope_info as scope_info_crud
from app.crud import scope_model as scope_model_crud
-from app.crud import attachment_point as attachment_point_crud
-from app.crud import attachment_point_name as apn_crud
-from app.crud import clamp_connection as clamp_connection_crud
-from app.crud import fit as fit_crud
-from app.crud import fit_note as fit_note_crud
from app.crud import static_connection as static_connection_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.crud import unc_thread as unc_thread_crud
@@ -48,6 +45,8 @@ from app.services import fit_matching as fit_matching_service
from app.services import jobs
from app.services import odata as odata_service
from app.services import scope_import as scope_import_service
+from app.web.pagination import paginate as _paginate
+from app.web.pagination import query_url as _query_url
from app.web.templates import templates
router = APIRouter(prefix="/admin", include_in_schema=False)
@@ -55,42 +54,6 @@ router = APIRouter(prefix="/admin", include_in_schema=False)
_PAGE_SIZE = 50
-def _query_url(base_path: str, params: dict) -> str:
- clean = {k: v for k, v in params.items() if v not in (None, "", 0)}
- return f"{base_path}?{urllib.parse.urlencode(clean)}"
-
-
-def _paginate(
- base_path: str,
- filter_params: dict,
- page: int,
- page_size: int,
- total: int,
- target_id: str,
-) -> dict:
- total_pages = max(1, math.ceil(total / page_size))
- page = max(1, min(page, total_pages))
- skip = (page - 1) * page_size
-
- def _url(p: int) -> str:
- return _query_url(
- base_path, {**filter_params, "page": p, "page_size": page_size}
- )
-
- return {
- "page": page,
- "page_size": page_size,
- "total": total,
- "total_pages": total_pages,
- "skip": skip,
- "showing_from": skip + 1 if total > 0 else 0,
- "showing_to": min(skip + page_size, total),
- "prev_url": _url(page - 1) if page > 1 else None,
- "next_url": _url(page + 1) if page < total_pages else None,
- "target_id": target_id,
- }
-
-
def _sort_headers(
base_path: str,
filter_params: dict,
diff --git a/app/web/pagination.py b/app/web/pagination.py
new file mode 100644
index 0000000..5d41877
--- /dev/null
+++ b/app/web/pagination.py
@@ -0,0 +1,40 @@
+"""Shared pagination helpers for HTMX table cards (admin screens, scopes page)."""
+
+import math
+import urllib.parse
+
+
+def query_url(base_path: str, params: dict) -> str:
+ clean = {k: v for k, v in params.items() if v not in (None, "", 0)}
+ return f"{base_path}?{urllib.parse.urlencode(clean)}"
+
+
+def paginate(
+ base_path: str,
+ filter_params: dict,
+ page: int,
+ page_size: int,
+ total: int,
+ target_id: str,
+) -> dict:
+ total_pages = max(1, math.ceil(total / page_size))
+ page = max(1, min(page, total_pages))
+ skip = (page - 1) * page_size
+
+ def _url(p: int) -> str:
+ return query_url(
+ base_path, {**filter_params, "page": p, "page_size": page_size}
+ )
+
+ return {
+ "page": page,
+ "page_size": page_size,
+ "total": total,
+ "total_pages": total_pages,
+ "skip": skip,
+ "showing_from": skip + 1 if total > 0 else 0,
+ "showing_to": min(skip + page_size, total),
+ "prev_url": _url(page - 1) if page > 1 else None,
+ "next_url": _url(page + 1) if page < total_pages else None,
+ "target_id": target_id,
+ }
diff --git a/app/web/scopes.py b/app/web/scopes.py
index ee76f73..73e2be7 100644
--- a/app/web/scopes.py
+++ b/app/web/scopes.py
@@ -19,10 +19,15 @@ from app.crud import sub_part_type as sub_part_type_crud
from app.models.user import User
from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require
+from app.web.pagination import paginate
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
+_PAGE_SIZE = 50
+_TABLE_TARGET = "#scopes-table-card"
+_FILTER_PATH = "/scopes/filter"
+
def _to_int(value: str) -> int | None:
return int(value) if value.strip() else None
@@ -33,7 +38,11 @@ def _can_write_scopes(user: User) -> bool:
async def _build(db: AsyncSession, filters: dict) -> dict:
- """Run the guide query for the given raw (string) filter values."""
+ """Run the guide query for the given raw (string) filter values.
+
+ Always returns the full matching row set — pagination is applied by the
+ caller by slicing ``rows`` (see ``_paginated_guide``).
+ """
return await scope_guide_service.build(
db,
description=filters["description"],
@@ -44,6 +53,22 @@ async def _build(db: AsyncSession, filters: dict) -> dict:
)
+async def _paginated_guide(
+ db: AsyncSession, filters: dict, page: int, page_size: int
+) -> tuple[dict, dict]:
+ """Run the guide query, then slice its rows to the requested page.
+
+ Returns ``(guide, pagination)`` where ``guide["rows"]`` is already sliced.
+ """
+ guide = await _build(db, filters)
+ all_rows = guide["rows"]
+ pagination = paginate(
+ _FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET
+ )
+ guide["rows"] = all_rows[pagination["skip"] : pagination["skip"] + page_size]
+ return guide, pagination
+
+
async def _filter_options(db: AsyncSession) -> dict:
"""Dropdown option lists for the filter bar."""
scope_type = await part_type_crud.get_by_name(
@@ -79,7 +104,7 @@ async def scopes_page(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
- guide = await _build(db, filters)
+ guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE)
return templates.TemplateResponse(
request,
"scopes.html",
@@ -87,6 +112,7 @@ async def scopes_page(
"user": user,
"filters": filters,
"can_write_scopes": _can_write_scopes(user),
+ "pagination": pagination,
**guide,
**(await _filter_options(db)),
},
@@ -101,6 +127,8 @@ async def scopes_filter(
scope_model_id: str = "",
sub_part_type_id: str = "",
fits_part_type_id: str = "",
+ page: int = 1,
+ page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
):
@@ -111,11 +139,15 @@ async def scopes_filter(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
- guide = await _build(db, filters)
+ guide, pagination = await _paginated_guide(db, filters, page, page_size)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",
- {**guide, "can_write_scopes": _can_write_scopes(user)},
+ {
+ **guide,
+ "pagination": pagination,
+ "can_write_scopes": _can_write_scopes(user),
+ },
)
@@ -154,9 +186,13 @@ async def bulk_delete_scopes(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
- guide = await _build(db, filters)
+ guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",
- {**guide, "can_write_scopes": _can_write_scopes(actor)},
+ {
+ **guide,
+ "pagination": pagination,
+ "can_write_scopes": _can_write_scopes(actor),
+ },
)
diff --git a/tests/test_scope_guide.py b/tests/test_scope_guide.py
index 7e28590..60a5462 100644
--- a/tests/test_scope_guide.py
+++ b/tests/test_scope_guide.py
@@ -404,6 +404,73 @@ async def test_bulk_delete_scopes_leaves_unselected_rows(
assert await augmented_part_crud.get(db, ids["s2"].id) is not None
+@pytest.mark.asyncio
+async def test_scopes_page_shows_single_page_summary(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ await _seed_guide_data(db) # 2 scopes, well under the default page size
+ await make_user("pager1@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "pager1@example.com")
+ resp = await client.get("/scopes")
+ assert resp.status_code == 200
+ assert "Showing 2 records." in resp.text
+ assert "Next" not in resp.text
+
+
+@pytest.mark.asyncio
+async def test_scopes_filter_paginates_results(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ scope_t = PartType(name="Scope")
+ db.add(scope_t)
+ await db.flush()
+ for i in range(5):
+ db.add(
+ AugmentedPart(
+ description=f"Scope {i:02d}",
+ colour_id=1,
+ manufacturer_id=1,
+ part_type_id=scope_t.id,
+ )
+ )
+ await db.commit()
+ await make_user("pager2@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "pager2@example.com")
+
+ resp = await client.get("/scopes/filter", params={"page_size": 2})
+ assert resp.status_code == 200
+ body = resp.text
+ assert "Scope 00" in body
+ assert "Scope 01" in body
+ assert "Scope 02" not in body
+ assert "Showing 1–2 of 5." in body
+
+ resp2 = await client.get("/scopes/filter", params={"page_size": 2, "page": 3})
+ assert resp2.status_code == 200
+ body2 = resp2.text
+ assert "Scope 04" in body2
+ assert "Scope 00" not in body2
+ assert "Showing 5–5 of 5." in body2
+
+
+@pytest.mark.asyncio
+async def test_scopes_filter_clamps_out_of_range_page(
+ client, seeded, make_user, db_sessionmaker
+):
+ async with db_sessionmaker() as db:
+ await _seed_guide_data(db) # 2 scopes
+ await make_user("pager3@example.com", role_ids=[seeded["roles"]["user"].id])
+ await _web_login(client, "pager3@example.com")
+
+ resp = await client.get("/scopes/filter", params={"page_size": 1, "page": 99})
+ assert resp.status_code == 200
+ # Clamped to the last real page (2), not an empty page 99.
+ assert "Zeta 1-6x24" in resp.text
+ assert "Alpha 3-18x50" not in resp.text
+
+
@pytest.mark.asyncio
async def test_bulk_delete_scopes_forbidden_without_write_permission(
client, seeded, make_user, db_sessionmaker