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