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}, )