From a50fccd56d296d04330c6e802340cd00bca4b941 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:36:43 -0300 Subject: [PATCH] Adding filtering to tables --- app/crud/augmented_part.py | 49 +++++++++++ app/crud/imported_part.py | 24 +++++- app/static/css/style.css | 33 ++++++++ .../admin/_augmented_parts_table.html | 60 +++++++++----- .../admin/_augmented_parts_table_card.html | 24 ++++++ .../admin/_imported_parts_table.html | 43 ++-------- .../admin/_imported_parts_table_card.html | 35 ++++++++ app/templates/admin/_skipped_parts_table.html | 36 ++++---- .../admin/_skipped_parts_table_card.html | 24 ++++++ app/web/admin.py | 82 ++++++++++++++++++- 10 files changed, 329 insertions(+), 81 deletions(-) create mode 100644 app/templates/admin/_augmented_parts_table_card.html create mode 100644 app/templates/admin/_imported_parts_table_card.html create mode 100644 app/templates/admin/_skipped_parts_table_card.html diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 3257876..51a0bf7 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -24,6 +24,55 @@ async def list_all( return list(result.scalars().all()) +async def list_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, + skip: int = 0, + limit: int = 100, +) -> list[AugmentedPart]: + q = select(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.order_by(AugmentedPart.description).offset(skip).limit(limit)) + return list(result.scalars().all()) + + +async def get_unlinked_imported_parts_filtered( + db: AsyncSession, + description: str = "", + colour: str = "", +) -> list[ImportedPart]: + q = ( + select(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.order_by(ImportedPart.InventoryID)) + return list(result.scalars().all()) + + async def create( db: AsyncSession, *, diff --git a/app/crud/imported_part.py b/app/crud/imported_part.py index 3538de3..f595b88 100644 --- a/app/crud/imported_part.py +++ b/app/crud/imported_part.py @@ -1,4 +1,4 @@ -from sqlalchemy import insert, select, update +from sqlalchemy import distinct, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.imported_part import ImportedPart @@ -23,6 +23,28 @@ async def list_all( return list(result.scalars().all()) +async def list_filtered( + db: AsyncSession, + description: str = "", + skip: int = 0, + limit: int = 500, +) -> list[ImportedPart]: + q = select(ImportedPart) + if description.strip(): + q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%")) + result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)) + return list(result.scalars().all()) + + +async def distinct_colours(db: AsyncSession) -> list[str]: + result = await db.execute( + select(distinct(ImportedPart.TBXColour)) + .where(ImportedPart.TBXColour.isnot(None)) + .order_by(ImportedPart.TBXColour) + ) + return [row[0] for row in result.all()] + + async def count(db: AsyncSession) -> int: from sqlalchemy import func result = await db.execute(select(func.count()).select_from(ImportedPart)) diff --git a/app/static/css/style.css b/app/static/css/style.css index 1e4cc20..1dd886a 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -400,6 +400,39 @@ input[type=password] { .ap-item-conn { flex: 1; } .ap-item .row-actions { margin-left: auto; } +/* Filter bar */ +.filter-bar { + margin-bottom: 0.75rem; +} + +.filter-bar form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-bar input[type="text"] { + flex: 1 1 180px; + min-width: 0; + padding: 0.35rem 0.6rem; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + font: inherit; +} + +.filter-bar select { + flex: 0 1 auto; + padding: 0.35rem 0.6rem; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + font: inherit; +} + /* Buttons */ .btn { font: inherit; diff --git a/app/templates/admin/_augmented_parts_table.html b/app/templates/admin/_augmented_parts_table.html index 1c9881c..034f175 100644 --- a/app/templates/admin/_augmented_parts_table.html +++ b/app/templates/admin/_augmented_parts_table.html @@ -5,28 +5,44 @@ {% if sync_error %}

{{ sync_error }}

{% endif %} -
- - - - - - - - - - - - - - - - {% for ap in augmented_parts %} - {% include "admin/_augmented_part_row.html" %} - {% else %} - +
+
+ +
-
IDSKUDescriptionColourManufacturerModelTypeSub Type
No augmented parts recorded.
+ + + + + +
+ +{% include "admin/_augmented_parts_table_card.html" %} diff --git a/app/templates/admin/_augmented_parts_table_card.html b/app/templates/admin/_augmented_parts_table_card.html new file mode 100644 index 0000000..c45e06a --- /dev/null +++ b/app/templates/admin/_augmented_parts_table_card.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + + + + + + {% for ap in augmented_parts %} + {% include "admin/_augmented_part_row.html" %} + {% else %} + + {% endfor %} + +
IDSKUDescriptionColourManufacturerModelTypeSub Type
No augmented parts recorded.
+
diff --git a/app/templates/admin/_imported_parts_table.html b/app/templates/admin/_imported_parts_table.html index 5007039..d686908 100644 --- a/app/templates/admin/_imported_parts_table.html +++ b/app/templates/admin/_imported_parts_table.html @@ -6,39 +6,14 @@

{{ sync_error }}

{% endif %} -
-

- Showing {{ parts|length }} record{{ '' if parts|length == 1 else 's' }}{% if total and total > parts|length %} of {{ total }} total{% endif %}. -

- - - - - - - - - - - - - - - {% for p in parts %} - - - - - - - - - - - {% else %} - - {% endfor %} - -
InventoryIDDescriptionTBX ColourMax Thread Depth (mm)Max Thread Depth (in)OD (mm)OD (in)Thread Attribute
{{ p.InventoryID }}{{ p.Description or '—' }}{{ p.TBXColour or '—' }}{{ p.ProductMaxThreadDepthmm or '—' }}{{ p.ProductMaxThreadDepthin or '—' }}{{ p.ProductODmm or '—' }}{{ p.ProductODin or '—' }}{{ p.TBXThreadAttribute or '—' }}
No imported parts. Use Refresh to sync from OData.
+
+
+ +
+ + {% include "admin/_imported_parts_table_card.html" %}
diff --git a/app/templates/admin/_imported_parts_table_card.html b/app/templates/admin/_imported_parts_table_card.html new file mode 100644 index 0000000..11f47e0 --- /dev/null +++ b/app/templates/admin/_imported_parts_table_card.html @@ -0,0 +1,35 @@ +
+

+ Showing {{ parts|length }} record{{ '' if parts|length == 1 else 's' }}{% if total and total > parts|length %} of {{ total }} total{% endif %}. +

+ + + + + + + + + + + + + + + {% for p in parts %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
InventoryIDDescriptionTBX ColourMax Thread Depth (mm)Max Thread Depth (in)OD (mm)OD (in)Thread Attribute
{{ p.InventoryID }}{{ p.Description or '—' }}{{ p.TBXColour or '—' }}{{ p.ProductMaxThreadDepthmm or '—' }}{{ p.ProductMaxThreadDepthin or '—' }}{{ p.ProductODmm or '—' }}{{ p.ProductODin or '—' }}{{ p.TBXThreadAttribute or '—' }}
No imported parts. Use Refresh to sync from OData.
+
diff --git a/app/templates/admin/_skipped_parts_table.html b/app/templates/admin/_skipped_parts_table.html index 7a80033..6333c4b 100644 --- a/app/templates/admin/_skipped_parts_table.html +++ b/app/templates/admin/_skipped_parts_table.html @@ -2,7 +2,7 @@ {% if sync_result %}

{{ sync_result }}

{% endif %} {% if sync_error %}

{{ sync_error }}

{% endif %} - {% if unlinked_parts %} + {% if any_unlinked %}
@@ -29,29 +29,23 @@ -
- - - - - - - - - - - {% for ip in unlinked_parts %} - - - - - - +
+ + +
-
Inventory IDDescriptionColour
{{ ip.InventoryID }}{{ ip.Description | truncate(80) if ip.Description else '—' }}{{ ip.TBXColour or '—' }}
+ +
+ {% include "admin/_skipped_parts_table_card.html" %} + {% else %}
diff --git a/app/templates/admin/_skipped_parts_table_card.html b/app/templates/admin/_skipped_parts_table_card.html new file mode 100644 index 0000000..0b00309 --- /dev/null +++ b/app/templates/admin/_skipped_parts_table_card.html @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + {% for ip in unlinked_parts %} + + + + + + + {% else %} + + {% endfor %} + +
Inventory IDDescriptionColour
{{ ip.InventoryID }}{{ ip.Description | truncate(80) if ip.Description else '—' }}{{ ip.TBXColour or '—' }}
No parts match the current filter.
+
diff --git a/app/web/admin.py b/app/web/admin.py index 0c6a411..283ecb7 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1124,6 +1124,21 @@ async def refresh_imported_parts( ) +@router.get("/imported-parts/filter", response_class=HTMLResponse) +async def filter_imported_parts( + request: Request, + description: str = "", + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("imported_parts:read")), +): + parts = await imported_part_crud.list_filtered(db, description=description) + return templates.TemplateResponse( + request, + "admin/_imported_parts_table_card.html", + {"parts": parts}, + ) + + # --- Augmented Parts --------------------------------------------------------- @@ -1179,10 +1194,11 @@ async def create_augmented_part( "AugmentedPart", created.id, created.sku or created.description ), ) + form_ctx = await _augmented_part_form_ctx(db) return templates.TemplateResponse( request, "admin/_augmented_parts_table.html", - {"augmented_parts": await augmented_part_crud.list_all(db)}, + {**form_ctx, "augmented_parts": await augmented_part_crud.list_all(db)}, ) @@ -1211,10 +1227,12 @@ async def sync_augmented_parts( except Exception as exc: sync_result = None sync_error = f"Sync failed: {exc}" + form_ctx = await _augmented_part_form_ctx(db) return templates.TemplateResponse( request, "admin/_augmented_parts_table.html", { + **form_ctx, "augmented_parts": await augmented_part_crud.list_all(db), "sync_result": sync_result, "sync_error": sync_error, @@ -1222,6 +1240,34 @@ async def sync_augmented_parts( ) +@router.get("/augmented-parts/filter", response_class=HTMLResponse) +async def filter_augmented_parts( + request: Request, + description: str = "", + colour_id: str = "", + manufacturer_id: str = "", + scope_model_id: str = "", + part_type_id: str = "", + sub_part_type_id: str = "", + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("augmented_parts:read")), +): + 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, + ) + return templates.TemplateResponse( + request, + "admin/_augmented_parts_table_card.html", + {"augmented_parts": parts}, + ) + + @router.get("/augmented-parts/skipped", response_class=HTMLResponse) async def skipped_parts_page( request: Request, @@ -1229,13 +1275,19 @@ 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}) return templates.TemplateResponse( request, "admin/augmented_parts_skipped.html", { "user": user, **form_ctx, - "unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db), + "unlinked_parts": unlinked, + "any_unlinked": bool(unlinked), + "skipped_colours": skipped_colours, + "filter_description": "", + "filter_colour": "", }, ) @@ -1288,18 +1340,42 @@ 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}) return templates.TemplateResponse( request, "admin/_skipped_parts_table.html", { **form_ctx, - "unlinked_parts": await augmented_part_crud.get_unlinked_imported_parts(db), + "unlinked_parts": unlinked, + "any_unlinked": bool(unlinked), + "skipped_colours": skipped_colours, + "filter_description": "", + "filter_colour": "", "sync_result": sync_result, "sync_error": sync_error, }, ) +@router.get("/augmented-parts/skipped/filter", response_class=HTMLResponse) +async def filter_skipped_parts( + request: Request, + description: str = "", + colour: str = "", + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("augmented_parts:read")), +): + unlinked = await augmented_part_crud.get_unlinked_imported_parts_filtered( + db, description=description, colour=colour + ) + return templates.TemplateResponse( + request, + "admin/_skipped_parts_table_card.html", + {"unlinked_parts": unlinked}, + ) + + @router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse) async def edit_augmented_part_row( request: Request,