Adding pagination to scopes page
This commit is contained in:
1 parent
7a15d9f7c0
commit
6d7c5c5604
6 files changed
+160
-51
No files matched your search
@@ -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::
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
<div id="scopes-table-card" class="card" style="overflow-x:auto">
|
||||
{% include "admin/_pagination.html" %}
|
||||
<table class="table sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
+7
-44
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+42
-6
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user