Adding pagination

This commit is contained in:
Ian Roddis committed 2026-07-02 14:48:21 -03:00
1 parent a50fccd56d
commit e923250177
8 files changed
+269 -46

No files matched your search

+69 -2
View File
@@ -1,4 +1,4 @@
from sqlalchemy import select from sqlalchemy import distinct, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts from app.models.associations import augmented_part_imported_parts
@@ -52,10 +52,39 @@ async def list_filtered(
return list(result.scalars().all()) return list(result.scalars().all())
async def count_filtered(
db: AsyncSession,
description: str = "",
colour_id: int | None = None,
manufacturer_id: int | None = None,
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
) -> int:
from sqlalchemy import func
q = select(func.count()).select_from(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
if scope_model_id is not None:
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
if part_type_id is not None:
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
result = await db.execute(q)
return result.scalar_one()
async def get_unlinked_imported_parts_filtered( async def get_unlinked_imported_parts_filtered(
db: AsyncSession, db: AsyncSession,
description: str = "", description: str = "",
colour: str = "", colour: str = "",
skip: int = 0,
limit: int = 50,
) -> list[ImportedPart]: ) -> list[ImportedPart]:
q = ( q = (
select(ImportedPart) select(ImportedPart)
@@ -69,10 +98,48 @@ async def get_unlinked_imported_parts_filtered(
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%")) q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip(): if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%")) q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(q.order_by(ImportedPart.InventoryID)) result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
async def count_unlinked_filtered(
db: AsyncSession,
description: str = "",
colour: str = "",
) -> int:
from sqlalchemy import func
q = (
select(func.count())
.select_from(ImportedPart)
.where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(q)
return result.scalar_one()
async def distinct_unlinked_colours(db: AsyncSession) -> list[str]:
q = (
select(distinct(ImportedPart.TBXColour))
.where(
ImportedPart.TBXColour.isnot(None),
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
),
)
.order_by(ImportedPart.TBXColour)
)
result = await db.execute(q)
return [row[0] for row in result.all()]
async def create( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
+9
View File
@@ -51,6 +51,15 @@ async def count(db: AsyncSession) -> int:
return result.scalar_one() return result.scalar_one()
async def count_filtered(db: AsyncSession, description: str = "") -> int:
from sqlalchemy import func
q = select(func.count()).select_from(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
result = await db.execute(q)
return result.scalar_one()
async def delete(db: AsyncSession, part: ImportedPart) -> None: async def delete(db: AsyncSession, part: ImportedPart) -> None:
await db.delete(part) await db.delete(part)
await db.commit() await db.commit()
+21
View File
@@ -400,6 +400,27 @@ input[type=password] {
.ap-item-conn { flex: 1; } .ap-item-conn { flex: 1; }
.ap-item .row-actions { margin-left: auto; } .ap-item .row-actions { margin-left: auto; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.pagination-nav {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
/* Filter bar */ /* Filter bar */
.filter-bar { .filter-bar {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -1,4 +1,5 @@
<div id="augmented-parts-table-card" class="card"> <div id="augmented-parts-table-card" class="card">
{% include "admin/_pagination.html" %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@@ -1,7 +1,5 @@
<div id="imported-parts-table-card" class="card" style="overflow-x:auto"> <div id="imported-parts-table-card" class="card" style="overflow-x:auto">
<p class="muted" style="margin-bottom:.5rem"> {% include "admin/_pagination.html" %}
Showing {{ parts|length }} record{{ '' if parts|length == 1 else 's' }}{% if total and total > parts|length %} of {{ total }} total{% endif %}.
</p>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
+34
View File
@@ -0,0 +1,34 @@
{% if pagination %}
<div class="pagination">
<span class="muted pagination-info">
{% if pagination.total == 0 %}
No records found.
{% elif pagination.total_pages == 1 %}
Showing {{ pagination.total }} record{{ '' if pagination.total == 1 else 's' }}.
{% else %}
Showing {{ pagination.showing_from }}–{{ pagination.showing_to }} of {{ pagination.total }}.
{% endif %}
</span>
{% if pagination.total_pages > 1 %}
<div class="pagination-nav">
{% if pagination.prev_url %}
<button class="btn btn-ghost btn-sm"
hx-get="{{ pagination.prev_url }}"
hx-target="{{ pagination.target_id }}"
hx-swap="outerHTML">← Prev</button>
{% else %}
<button class="btn btn-ghost btn-sm" disabled>← Prev</button>
{% endif %}
<span class="muted">{{ pagination.page }} / {{ pagination.total_pages }}</span>
{% if pagination.next_url %}
<button class="btn btn-ghost btn-sm"
hx-get="{{ pagination.next_url }}"
hx-target="{{ pagination.target_id }}"
hx-swap="outerHTML">Next →</button>
{% else %}
<button class="btn btn-ghost btn-sm" disabled>Next →</button>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
@@ -1,4 +1,5 @@
<div id="skipped-parts-table-card" class="card" style="overflow-x:auto"> <div id="skipped-parts-table-card" class="card" style="overflow-x:auto">
{% include "admin/_pagination.html" %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
+133 -41
View File
@@ -1,5 +1,8 @@
"""HTMX-driven admin dashboard for users, roles, and thread standards.""" """HTMX-driven admin dashboard for users, roles, and thread standards."""
import math
import urllib.parse
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,6 +34,40 @@ from app.web.templates import templates
router = APIRouter(prefix="/admin", include_in_schema=False) router = APIRouter(prefix="/admin", include_in_schema=False)
_PAGE_SIZE = 50
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:
params = {k: v for k, v in filter_params.items() if v not in (None, "", 0)}
params["page"] = p
params["page_size"] = page_size
return f"{base_path}?{urllib.parse.urlencode(params)}"
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 web_require(*perms: str): def web_require(*perms: str):
"""Web dependency: allow superusers or holders of all given permissions.""" """Web dependency: allow superusers or holders of all given permissions."""
@@ -1045,12 +1082,13 @@ async def imported_parts_page(
"username": config.username if config else None, "username": config.username if config else None,
"has_password": bool(config and config.password), "has_password": bool(config and config.password),
} }
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db) total = await imported_part_crud.count(db)
parts = await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE)
pagination = _paginate("/admin/imported-parts/filter", {}, 1, _PAGE_SIZE, total, "#imported-parts-table-card")
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/imported_parts.html", "admin/imported_parts.html",
{"user": user, "config": config_view, "parts": parts, "total": total}, {"user": user, "config": config_view, "parts": parts, "pagination": pagination},
) )
@@ -1087,13 +1125,17 @@ async def refresh_imported_parts(
actor: User = Depends(web_require("imported_parts:write")), actor: User = Depends(web_require("imported_parts:write")),
): ):
config = await imported_part_config_crud.get(db) config = await imported_part_config_crud.get(db)
if not config or not config.odata_url:
parts = await imported_part_crud.list_all(db) async def _imported_table_ctx(sync_result=None, sync_error=None):
total = await imported_part_crud.count(db) total = await imported_part_crud.count(db)
parts = await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE)
pagination = _paginate("/admin/imported-parts/filter", {}, 1, _PAGE_SIZE, total, "#imported-parts-table-card")
return {"parts": parts, "pagination": pagination, "sync_result": sync_result, "sync_error": sync_error}
if not config or not config.odata_url:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request, "admin/_imported_parts_table.html",
"admin/_imported_parts_table.html", await _imported_table_ctx(sync_error="OData URL is not configured."),
{"parts": parts, "total": total, "sync_error": "OData URL is not configured."},
) )
try: try:
records = await odata_service.fetch(config.odata_url, config.username, config.password) records = await odata_service.fetch(config.odata_url, config.username, config.password)
@@ -1103,24 +1145,14 @@ async def refresh_imported_parts(
resource_id=None, resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)", description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
) )
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request, "admin/_imported_parts_table.html",
"admin/_imported_parts_table.html", await _imported_table_ctx(sync_result=f"Sync complete: {created} created, {updated} updated."),
{
"parts": parts,
"total": total,
"sync_result": f"Sync complete: {created} created, {updated} updated.",
},
) )
except Exception as exc: except Exception as exc:
parts = await imported_part_crud.list_all(db)
total = await imported_part_crud.count(db)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request, "admin/_imported_parts_table.html",
"admin/_imported_parts_table.html", await _imported_table_ctx(sync_error=f"Sync failed: {exc}"),
{"parts": parts, "total": total, "sync_error": f"Sync failed: {exc}"},
) )
@@ -1128,20 +1160,35 @@ async def refresh_imported_parts(
async def filter_imported_parts( async def filter_imported_parts(
request: Request, request: Request,
description: str = "", description: str = "",
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("imported_parts:read")), _: User = Depends(web_require("imported_parts:read")),
): ):
parts = await imported_part_crud.list_filtered(db, description=description) total = await imported_part_crud.count_filtered(db, description=description)
pagination = _paginate(
"/admin/imported-parts/filter",
{"description": description},
page, page_size, total,
"#imported-parts-table-card",
)
parts = await imported_part_crud.list_filtered(
db, description=description, skip=pagination["skip"], limit=page_size,
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_imported_parts_table_card.html", "admin/_imported_parts_table_card.html",
{"parts": parts}, {"parts": parts, "pagination": pagination},
) )
# --- Augmented Parts --------------------------------------------------------- # --- Augmented Parts ---------------------------------------------------------
async def _skipped_colours(db: AsyncSession) -> list[str]:
return await augmented_part_crud.distinct_unlinked_colours(db)
async def _augmented_part_form_ctx(db: AsyncSession) -> dict: async def _augmented_part_form_ctx(db: AsyncSession) -> dict:
return { return {
"part_colours": await part_colour_crud.list_all(db), "part_colours": await part_colour_crud.list_all(db),
@@ -1159,8 +1206,10 @@ async def augmented_parts_page(
user: User = Depends(web_require("augmented_parts:read")), user: User = Depends(web_require("augmented_parts:read")),
): ):
ctx = await _augmented_part_form_ctx(db) ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(db, limit=_PAGE_SIZE)
ctx["pagination"] = _paginate("/admin/augmented-parts/filter", {}, 1, _PAGE_SIZE, total, "#augmented-parts-table-card")
ctx["user"] = user ctx["user"] = user
ctx["augmented_parts"] = await augmented_part_crud.list_all(db)
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx) return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@@ -1195,10 +1244,12 @@ async def create_augmented_part(
), ),
) )
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
pagination = _paginate("/admin/augmented-parts/filter", {}, 1, _PAGE_SIZE, total, "#augmented-parts-table-card")
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table.html", "admin/_augmented_parts_table.html",
{**form_ctx, "augmented_parts": await augmented_part_crud.list_all(db)}, {**form_ctx, "augmented_parts": await augmented_part_crud.list_filtered(db, limit=_PAGE_SIZE), "pagination": pagination},
) )
@@ -1228,12 +1279,15 @@ async def sync_augmented_parts(
sync_result = None sync_result = None
sync_error = f"Sync failed: {exc}" sync_error = f"Sync failed: {exc}"
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
pagination = _paginate("/admin/augmented-parts/filter", {}, 1, _PAGE_SIZE, total, "#augmented-parts-table-card")
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table.html", "admin/_augmented_parts_table.html",
{ {
**form_ctx, **form_ctx,
"augmented_parts": await augmented_part_crud.list_all(db), "augmented_parts": await augmented_part_crud.list_filtered(db, limit=_PAGE_SIZE),
"pagination": pagination,
"sync_result": sync_result, "sync_result": sync_result,
"sync_error": sync_error, "sync_error": sync_error,
}, },
@@ -1249,22 +1303,49 @@ async def filter_augmented_parts(
scope_model_id: str = "", scope_model_id: str = "",
part_type_id: str = "", part_type_id: str = "",
sub_part_type_id: str = "", sub_part_type_id: str = "",
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:read")), _: User = Depends(web_require("augmented_parts:read")),
): ):
colour_id_int = int(colour_id) if colour_id else None
manufacturer_id_int = int(manufacturer_id) if manufacturer_id else None
scope_model_id_int = int(scope_model_id) if scope_model_id else None
part_type_id_int = int(part_type_id) if part_type_id else None
sub_part_type_id_int = int(sub_part_type_id) if sub_part_type_id else None
total = await augmented_part_crud.count_filtered(
db,
description=description,
colour_id=colour_id_int,
manufacturer_id=manufacturer_id_int,
scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
)
filter_params = {
"description": description,
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"scope_model_id": scope_model_id,
"part_type_id": part_type_id,
"sub_part_type_id": sub_part_type_id,
}
pagination = _paginate("/admin/augmented-parts/filter", filter_params, page, page_size, total, "#augmented-parts-table-card")
parts = await augmented_part_crud.list_filtered( parts = await augmented_part_crud.list_filtered(
db, db,
description=description, description=description,
colour_id=int(colour_id) if colour_id else None, colour_id=colour_id_int,
manufacturer_id=int(manufacturer_id) if manufacturer_id else None, manufacturer_id=manufacturer_id_int,
scope_model_id=int(scope_model_id) if scope_model_id else None, scope_model_id=scope_model_id_int,
part_type_id=int(part_type_id) if part_type_id else None, part_type_id=part_type_id_int,
sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None, sub_part_type_id=sub_part_type_id_int,
skip=pagination["skip"],
limit=page_size,
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_augmented_parts_table_card.html", "admin/_augmented_parts_table_card.html",
{"augmented_parts": parts}, {"augmented_parts": parts, "pagination": pagination},
) )
@@ -1275,8 +1356,10 @@ async def skipped_parts_page(
user: User = Depends(web_require("augmented_parts:read")), user: User = Depends(web_require("augmented_parts:read")),
): ):
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
unlinked = await augmented_part_crud.get_unlinked_imported_parts(db) total_unlinked = await augmented_part_crud.count_unlinked_filtered(db)
skipped_colours = sorted({p.TBXColour for p in unlinked if p.TBXColour}) skipped_colours = await _skipped_colours(db)
pagination = _paginate("/admin/augmented-parts/skipped/filter", {}, 1, _PAGE_SIZE, total_unlinked, "#skipped-parts-table-card")
unlinked = await augmented_part_crud.get_unlinked_imported_parts_filtered(db, limit=_PAGE_SIZE)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/augmented_parts_skipped.html", "admin/augmented_parts_skipped.html",
@@ -1284,10 +1367,11 @@ async def skipped_parts_page(
"user": user, "user": user,
**form_ctx, **form_ctx,
"unlinked_parts": unlinked, "unlinked_parts": unlinked,
"any_unlinked": bool(unlinked), "any_unlinked": bool(total_unlinked),
"skipped_colours": skipped_colours, "skipped_colours": skipped_colours,
"filter_description": "", "filter_description": "",
"filter_colour": "", "filter_colour": "",
"pagination": pagination,
}, },
) )
@@ -1340,18 +1424,21 @@ async def sync_skipped_parts(
sync_result = f"{synced} part(s) synced successfully." sync_result = f"{synced} part(s) synced successfully."
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
unlinked = await augmented_part_crud.get_unlinked_imported_parts(db) total_unlinked = await augmented_part_crud.count_unlinked_filtered(db)
skipped_colours = sorted({p.TBXColour for p in unlinked if p.TBXColour}) colours = await _skipped_colours(db)
pagination = _paginate("/admin/augmented-parts/skipped/filter", {}, 1, _PAGE_SIZE, total_unlinked, "#skipped-parts-table-card")
unlinked = await augmented_part_crud.get_unlinked_imported_parts_filtered(db, limit=_PAGE_SIZE)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_skipped_parts_table.html", "admin/_skipped_parts_table.html",
{ {
**form_ctx, **form_ctx,
"unlinked_parts": unlinked, "unlinked_parts": unlinked,
"any_unlinked": bool(unlinked), "any_unlinked": bool(total_unlinked),
"skipped_colours": skipped_colours, "skipped_colours": colours,
"filter_description": "", "filter_description": "",
"filter_colour": "", "filter_colour": "",
"pagination": pagination,
"sync_result": sync_result, "sync_result": sync_result,
"sync_error": sync_error, "sync_error": sync_error,
}, },
@@ -1363,16 +1450,21 @@ async def filter_skipped_parts(
request: Request, request: Request,
description: str = "", description: str = "",
colour: str = "", colour: str = "",
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:read")), _: User = Depends(web_require("augmented_parts:read")),
): ):
total = await augmented_part_crud.count_unlinked_filtered(db, description=description, colour=colour)
filter_params = {"description": description, "colour": colour}
pagination = _paginate("/admin/augmented-parts/skipped/filter", filter_params, page, page_size, total, "#skipped-parts-table-card")
unlinked = await augmented_part_crud.get_unlinked_imported_parts_filtered( unlinked = await augmented_part_crud.get_unlinked_imported_parts_filtered(
db, description=description, colour=colour db, description=description, colour=colour, skip=pagination["skip"], limit=page_size,
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_skipped_parts_table_card.html", "admin/_skipped_parts_table_card.html",
{"unlinked_parts": unlinked}, {"unlinked_parts": unlinked, "pagination": pagination},
) )