diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py
index 51a0bf7..0c54c81 100644
--- a/app/crud/augmented_part.py
+++ b/app/crud/augmented_part.py
@@ -1,4 +1,4 @@
-from sqlalchemy import select
+from sqlalchemy import distinct, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
@@ -52,10 +52,39 @@ async def list_filtered(
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(
db: AsyncSession,
description: str = "",
colour: str = "",
+ skip: int = 0,
+ limit: int = 50,
) -> list[ImportedPart]:
q = (
select(ImportedPart)
@@ -69,10 +98,48 @@ async def get_unlinked_imported_parts_filtered(
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.order_by(ImportedPart.InventoryID))
+ result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
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(
db: AsyncSession,
*,
diff --git a/app/crud/imported_part.py b/app/crud/imported_part.py
index f595b88..b21f59b 100644
--- a/app/crud/imported_part.py
+++ b/app/crud/imported_part.py
@@ -51,6 +51,15 @@ async def count(db: AsyncSession) -> int:
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:
await db.delete(part)
await db.commit()
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 1dd886a..6deee9d 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -400,6 +400,27 @@ input[type=password] {
.ap-item-conn { flex: 1; }
.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 {
margin-bottom: 0.75rem;
diff --git a/app/templates/admin/_augmented_parts_table_card.html b/app/templates/admin/_augmented_parts_table_card.html
index c45e06a..5e95de0 100644
--- a/app/templates/admin/_augmented_parts_table_card.html
+++ b/app/templates/admin/_augmented_parts_table_card.html
@@ -1,4 +1,5 @@
+ {% include "admin/_pagination.html" %}
diff --git a/app/templates/admin/_imported_parts_table_card.html b/app/templates/admin/_imported_parts_table_card.html
index 11f47e0..18c6719 100644
--- a/app/templates/admin/_imported_parts_table_card.html
+++ b/app/templates/admin/_imported_parts_table_card.html
@@ -1,7 +1,5 @@
-
- Showing {{ parts|length }} record{{ '' if parts|length == 1 else 's' }}{% if total and total > parts|length %} of {{ total }} total{% endif %}.
-
+ {% include "admin/_pagination.html" %}
diff --git a/app/templates/admin/_pagination.html b/app/templates/admin/_pagination.html
new file mode 100644
index 0000000..98bd512
--- /dev/null
+++ b/app/templates/admin/_pagination.html
@@ -0,0 +1,34 @@
+{% if pagination %}
+
+{% endif %}
diff --git a/app/templates/admin/_skipped_parts_table_card.html b/app/templates/admin/_skipped_parts_table_card.html
index 0b00309..eafb0a7 100644
--- a/app/templates/admin/_skipped_parts_table_card.html
+++ b/app/templates/admin/_skipped_parts_table_card.html
@@ -1,4 +1,5 @@
+ {% include "admin/_pagination.html" %}
diff --git a/app/web/admin.py b/app/web/admin.py
index 283ecb7..9269662 100644
--- a/app/web/admin.py
+++ b/app/web/admin.py
@@ -1,5 +1,8 @@
"""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.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,6 +34,40 @@ from app.web.templates import templates
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):
"""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,
"has_password": bool(config and config.password),
}
- parts = await imported_part_crud.list_all(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(
request,
"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")),
):
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)
+ 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(
- request,
- "admin/_imported_parts_table.html",
- {"parts": parts, "total": total, "sync_error": "OData URL is not configured."},
+ request, "admin/_imported_parts_table.html",
+ await _imported_table_ctx(sync_error="OData URL is not configured."),
)
try:
records = await odata_service.fetch(config.odata_url, config.username, config.password)
@@ -1103,24 +1145,14 @@ async def refresh_imported_parts(
resource_id=None,
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(
- request,
- "admin/_imported_parts_table.html",
- {
- "parts": parts,
- "total": total,
- "sync_result": f"Sync complete: {created} created, {updated} updated.",
- },
+ request, "admin/_imported_parts_table.html",
+ await _imported_table_ctx(sync_result=f"Sync complete: {created} created, {updated} updated."),
)
except Exception as exc:
- parts = await imported_part_crud.list_all(db)
- total = await imported_part_crud.count(db)
return templates.TemplateResponse(
- request,
- "admin/_imported_parts_table.html",
- {"parts": parts, "total": total, "sync_error": f"Sync failed: {exc}"},
+ request, "admin/_imported_parts_table.html",
+ await _imported_table_ctx(sync_error=f"Sync failed: {exc}"),
)
@@ -1128,20 +1160,35 @@ async def refresh_imported_parts(
async def filter_imported_parts(
request: Request,
description: str = "",
+ page: int = 1,
+ page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
_: 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(
request,
"admin/_imported_parts_table_card.html",
- {"parts": parts},
+ {"parts": parts, "pagination": pagination},
)
# --- 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:
return {
"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")),
):
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["augmented_parts"] = await augmented_part_crud.list_all(db)
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)
+ 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(
request,
"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_error = f"Sync failed: {exc}"
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(
request,
"admin/_augmented_parts_table.html",
{
**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_error": sync_error,
},
@@ -1249,22 +1303,49 @@ async def filter_augmented_parts(
scope_model_id: str = "",
part_type_id: str = "",
sub_part_type_id: str = "",
+ page: int = 1,
+ page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
_: 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(
db,
description=description,
- colour_id=int(colour_id) if colour_id else None,
- manufacturer_id=int(manufacturer_id) if manufacturer_id else None,
- scope_model_id=int(scope_model_id) if scope_model_id else None,
- part_type_id=int(part_type_id) if part_type_id else None,
- sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None,
+ 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,
+ skip=pagination["skip"],
+ limit=page_size,
)
return templates.TemplateResponse(
request,
"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")),
):
form_ctx = await _augmented_part_form_ctx(db)
- unlinked = await augmented_part_crud.get_unlinked_imported_parts(db)
- skipped_colours = sorted({p.TBXColour for p in unlinked if p.TBXColour})
+ total_unlinked = await augmented_part_crud.count_unlinked_filtered(db)
+ 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(
request,
"admin/augmented_parts_skipped.html",
@@ -1284,10 +1367,11 @@ async def skipped_parts_page(
"user": user,
**form_ctx,
"unlinked_parts": unlinked,
- "any_unlinked": bool(unlinked),
+ "any_unlinked": bool(total_unlinked),
"skipped_colours": skipped_colours,
"filter_description": "",
"filter_colour": "",
+ "pagination": pagination,
},
)
@@ -1340,18 +1424,21 @@ async def sync_skipped_parts(
sync_result = f"{synced} part(s) synced successfully."
form_ctx = await _augmented_part_form_ctx(db)
- unlinked = await augmented_part_crud.get_unlinked_imported_parts(db)
- skipped_colours = sorted({p.TBXColour for p in unlinked if p.TBXColour})
+ total_unlinked = await augmented_part_crud.count_unlinked_filtered(db)
+ 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(
request,
"admin/_skipped_parts_table.html",
{
**form_ctx,
"unlinked_parts": unlinked,
- "any_unlinked": bool(unlinked),
- "skipped_colours": skipped_colours,
+ "any_unlinked": bool(total_unlinked),
+ "skipped_colours": colours,
"filter_description": "",
"filter_colour": "",
+ "pagination": pagination,
"sync_result": sync_result,
"sync_error": sync_error,
},
@@ -1363,16 +1450,21 @@ async def filter_skipped_parts(
request: Request,
description: str = "",
colour: str = "",
+ page: int = 1,
+ page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
_: 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(
- db, description=description, colour=colour
+ db, description=description, colour=colour, skip=pagination["skip"], limit=page_size,
)
return templates.TemplateResponse(
request,
"admin/_skipped_parts_table_card.html",
- {"unlinked_parts": unlinked},
+ {"unlinked_parts": unlinked, "pagination": pagination},
)