Compare commits

...
2 Commits
Author SHA1 Message Date
Ian Roddis 2318a6daeb Making scope filter by product id 2026-08-11 13:11:15 -03:00
Ian Roddis 6d7c5c5604 Adding pagination to scopes page 2026-08-11 11:33:56 -03:00
8 changed files with 196 additions and 56 deletions

No files matched your search

+11 -2
View File
@@ -1,4 +1,4 @@
from sqlalchemy import distinct, select
from sqlalchemy import distinct, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
@@ -89,10 +89,19 @@ async def list_filtered(
direction: str = "asc",
skip: int = 0,
limit: int = 100,
match_sku: bool = False,
) -> list[AugmentedPart]:
q = select(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
term = f"%{description.strip()}%"
if match_sku:
q = q.where(
or_(
AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term)
)
)
else:
q = q.where(AugmentedPart.description.ilike(term))
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
+7 -3
View File
@@ -33,9 +33,12 @@ async def build(
"""Return the context for ``scopes.html``.
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.
matches either the scope's description or its SKU/product ID; the ``*_id``
filters match exactly). ``fits_part_type_id`` keeps only scopes that have
at least one (non-``none``)
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::
@@ -69,6 +72,7 @@ async def build(
sub_part_type_id=sub_part_type_id,
part_type_id=scope_type.id,
limit=1000,
match_sku=True,
)
scope_ids = {s.id for s in scopes}
if not scope_ids:
+1
View File
@@ -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>
+1 -1
View File
@@ -15,7 +15,7 @@
hx-target="#scopes-table-card"
hx-trigger="input delay:300ms, change"
hx-swap="outerHTML">
<input type="text" name="description" placeholder="Search scopes…"
<input type="text" name="description" placeholder="Search by description or product ID…"
value="{{ filters.description or '' }}">
<select name="manufacturer_id">
<option value="">All manufacturers</option>
+7 -44
View File
@@ -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,
+40
View File
@@ -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
View File
@@ -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),
},
)
+87
View File
@@ -147,6 +147,26 @@ async def test_build_filters_by_description(db_sessionmaker):
assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio
async def test_build_filters_by_sku(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
ids["s1"].sku = "ARM-12345"
await db.commit()
guide = await scope_guide.build(db, description="ARM-12345")
assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio
async def test_build_filters_by_partial_sku(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
ids["s1"].sku = "ARM-12345"
await db.commit()
guide = await scope_guide.build(db, description="12345")
assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio
async def test_build_filters_by_manufacturer(db_sessionmaker):
async with db_sessionmaker() as db:
@@ -404,6 +424,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