From 2fb662080b6526e2d0f1727a3ebef68a4e10040c Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:52:09 -0300 Subject: [PATCH] Adding pervasive column sorting --- .claude/settings.local.json | 19 +- app/crud/augmented_part.py | 49 +++- app/crud/imported_part.py | 46 ++- app/static/css/style.css | 31 ++ app/static/js/sortable-tables.js | 77 +++++ app/templates/_scopes_table_card.html | 4 +- .../admin/_attachment_point_names_table.html | 2 +- .../admin/_attachment_point_row.html | 1 - .../admin/_attachment_points_table.html | 3 +- .../admin/_augmented_parts_table.html | 2 + .../admin/_augmented_parts_table_card.html | 17 +- .../admin/_clamp_connections_table.html | 2 +- .../admin/_imported_parts_table.html | 2 + .../admin/_imported_parts_table_card.html | 17 +- app/templates/admin/_manufacturers_table.html | 4 +- app/templates/admin/_part_colours_table.html | 2 +- app/templates/admin/_part_types_table.html | 2 +- app/templates/admin/_roles_table.html | 4 +- app/templates/admin/_scope_models_table.html | 2 +- app/templates/admin/_sort_macros.html | 13 + .../admin/_static_connections_table.html | 2 +- .../admin/_sub_part_types_table.html | 2 +- app/templates/admin/_users_table.html | 4 +- app/templates/admin/audit_log.html | 2 +- app/templates/base.html | 1 + app/web/admin.py | 267 +++++++++++++----- tests/test_admin_sorting.py | 148 ++++++++++ 27 files changed, 614 insertions(+), 111 deletions(-) create mode 100644 app/static/js/sortable-tables.js create mode 100644 app/templates/admin/_sort_macros.html create mode 100644 tests/test_admin_sorting.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d309f38..f3fbedd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,24 @@ "Bash(docker build *)", "Bash(git status *)", "Bash(git -C /home/iroddis/dev/armament/fitguide status --short)", - "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)" + "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)", + "Bash(node --check /Users/roddis/dev/armament/fitguide/app/static/js/sortable-tables.js)", + "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from users;\")", + "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from part_colours;\")", + "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from manufacturers;\")", + "Bash(curl -s -c /tmp/fg_cookies.txt -o /dev/null -w \"login:%{http_code}\\\\n\" -d \"email=admin@armament.com&password=changethis\" http://localhost:8000/login)", + "Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/admin/manufacturers)", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\\\\n\" http://localhost:8000/static/js/sortable-tables.js)", + "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts\")", + "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts/filter?sort=colour&dir=desc\")", + "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/roles\")", + "Bash(curl -s http://localhost:8000/scopes)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/scopes)", + "Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/scopes)", + "Bash(lsof -ti:8000 -sTCP:LISTEN)", + "Bash(xargs -r kill)", + "Bash(rm -f /tmp/fg_cookies.txt /private/tmp/claude-501/-Users-roddis-dev-armament-fitguide/7873fd9c-2f2b-4583-877e-72fc88718f24/scratchpad/smoke_sort.py)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/login)" ] } } diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index 7fce239..7f06de3 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -4,9 +4,47 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.associations import augmented_part_imported_parts from app.models.augmented_part import AugmentedPart from app.models.imported_part import ImportedPart +from app.models.manufacturer import Manufacturer +from app.models.part_colour import PartColour +from app.models.part_type import PartType +from app.models.scope_model import ScopeModel +from app.models.sub_part_type import SubPartType _RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"] +# Sortable columns for the admin list, keyed by the query-string "sort" value. +# Columns backed by a related table's name require an outer join (the FK may be +# nullable, e.g. scope_model_id) so unrelated rows still appear, ordered last. +SORT_COLUMNS = { + "id": (AugmentedPart.id, None), + "sku": (AugmentedPart.sku, None), + "description": (AugmentedPart.description, None), + "colour": (PartColour.name, (PartColour, AugmentedPart.colour_id == PartColour.id)), + "manufacturer": ( + Manufacturer.name, + (Manufacturer, AugmentedPart.manufacturer_id == Manufacturer.id), + ), + "scope_model": ( + ScopeModel.name, + (ScopeModel, AugmentedPart.scope_model_id == ScopeModel.id), + ), + "part_type": (PartType.name, (PartType, AugmentedPart.part_type_id == PartType.id)), + "sub_part_type": ( + SubPartType.name, + (SubPartType, AugmentedPart.sub_part_type_id == SubPartType.id), + ), +} + + +def _apply_sort(q, sort: str, direction: str): + column, join = SORT_COLUMNS.get(sort, SORT_COLUMNS["description"]) + if join is not None: + table, cond = join + q = q.outerjoin(table, cond) + order = column.desc() if direction == "desc" else column.asc() + # AugmentedPart.id as a tiebreaker keeps paginated ordering stable. + return q.order_by(order, AugmentedPart.id) + async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None: return await db.get(AugmentedPart, augmented_part_id) @@ -35,9 +73,7 @@ async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]: """Fetch augmented parts by id in one query (selectin relationships loaded).""" if not ids: return [] - result = await db.execute( - select(AugmentedPart).where(AugmentedPart.id.in_(ids)) - ) + result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids))) return list(result.scalars().all()) @@ -49,6 +85,8 @@ async def list_filtered( scope_model_id: int | None = None, part_type_id: int | None = None, sub_part_type_id: int | None = None, + sort: str = "description", + direction: str = "asc", skip: int = 0, limit: int = 100, ) -> list[AugmentedPart]: @@ -65,9 +103,8 @@ async def list_filtered( 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) - ) + q = _apply_sort(q, sort, direction) + result = await db.execute(q.offset(skip).limit(limit)) return list(result.scalars().all()) diff --git a/app/crud/imported_part.py b/app/crud/imported_part.py index 8085277..1184aed 100644 --- a/app/crud/imported_part.py +++ b/app/crud/imported_part.py @@ -16,14 +16,37 @@ _FIELDS = frozenset( } ) +# Sortable columns for the admin list, keyed by the query-string "sort" value. +SORT_COLUMNS = { + "InventoryID": ImportedPart.InventoryID, + "Description": ImportedPart.Description, + "TBXColour": ImportedPart.TBXColour, + "ProductMaxThreadDepthmm": ImportedPart.ProductMaxThreadDepthmm, + "ProductMaxThreadDepthin": ImportedPart.ProductMaxThreadDepthin, + "ProductODmm": ImportedPart.ProductODmm, + "ProductODin": ImportedPart.ProductODin, + "TBXThreadAttribute": ImportedPart.TBXThreadAttribute, +} + + +def _apply_sort(q, sort: str, direction: str): + column = SORT_COLUMNS.get(sort, ImportedPart.InventoryID) + order = column.desc() if direction == "desc" else column.asc() + return q.order_by(order, ImportedPart.InventoryID) + async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None: return await db.get(ImportedPart, inventory_id) -async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]: +async def list_all( + db: AsyncSession, skip: int = 0, limit: int = 500 +) -> list[ImportedPart]: result = await db.execute( - select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit) + select(ImportedPart) + .order_by(ImportedPart.InventoryID) + .offset(skip) + .limit(limit) ) return list(result.scalars().all()) @@ -31,13 +54,16 @@ async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[Im async def list_filtered( db: AsyncSession, description: str = "", + sort: str = "InventoryID", + direction: str = "asc", 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)) + q = _apply_sort(q, sort, direction) + result = await db.execute(q.offset(skip).limit(limit)) return list(result.scalars().all()) @@ -78,7 +104,11 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]: return 0, 0 clean = [ - {k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS} + { + k: str(v).strip() if v is not None else None + for k, v in r.items() + if k in _FIELDS + } for r in records if r.get("InventoryID") ] @@ -88,7 +118,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]: incoming_ids = {r["InventoryID"] for r in clean} result = await db.execute( - select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids)) + select(ImportedPart.InventoryID).where( + ImportedPart.InventoryID.in_(incoming_ids) + ) ) existing_ids = {row[0] for row in result.all()} @@ -103,7 +135,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]: vals = {k: v for k, v in record.items() if k != "InventoryID"} if vals: await db.execute( - update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals) + update(ImportedPart) + .where(ImportedPart.InventoryID == inv_id) + .values(vals) ) await db.commit() diff --git a/app/static/css/style.css b/app/static/css/style.css index 6642b3f..c9a65b9 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -247,6 +247,37 @@ h2 { gap: 0.4rem; } +/* Sortable table headers */ +table.sortable thead th:not([data-sort-off]) { + cursor: pointer; + user-select: none; +} + +table.sortable thead th:not([data-sort-off]):hover { + color: var(--text); +} + +table.sortable thead th[aria-sort]::after { + margin-left: 0.3rem; +} + +table.sortable thead th[aria-sort="ascending"]::after { + content: "\25B2"; +} + +table.sortable thead th[aria-sort="descending"]::after { + content: "\25BC"; +} + +.sort-link { + color: inherit; + text-decoration: none; +} + +.sort-link:hover { + text-decoration: underline; +} + /* Tags / definition lists */ .tag { display: inline-block; diff --git a/app/static/js/sortable-tables.js b/app/static/js/sortable-tables.js new file mode 100644 index 0000000..6437988 --- /dev/null +++ b/app/static/js/sortable-tables.js @@ -0,0 +1,77 @@ +(function () { + "use strict"; + + function getCellText(row, index) { + var cell = row.children[index]; + return cell ? cell.textContent.trim() : ""; + } + + function isEmptyValue(text) { + return text === "" || text === "—"; + } + + function isPureNumber(text) { + return /^-?\d+(\.\d+)?$/.test(text); + } + + function columnIsNumeric(rows, index) { + var sawValue = false; + for (var i = 0; i < rows.length; i++) { + var text = getCellText(rows[i], index); + if (isEmptyValue(text)) continue; + sawValue = true; + if (!isPureNumber(text)) return false; + } + return sawValue; + } + + function compareRows(a, b, index, numeric, dir) { + var textA = getCellText(a, index); + var textB = getCellText(b, index); + var emptyA = isEmptyValue(textA); + var emptyB = isEmptyValue(textB); + if (emptyA && emptyB) return 0; + if (emptyA) return 1; + if (emptyB) return -1; + + var result = numeric + ? parseFloat(textA) - parseFloat(textB) + : textA.localeCompare(textB, undefined, { numeric: true, sensitivity: "base" }); + return dir === "desc" ? -result : result; + } + + function sortTable(table, th) { + var thead = table.tHead; + var tbody = table.tBodies[0]; + if (!thead || !tbody) return; + + var headerRow = thead.rows[0]; + var index = Array.prototype.indexOf.call(headerRow.children, th); + if (index === -1) return; + + var rows = Array.prototype.slice.call(tbody.rows); + if (rows.length < 2) return; + + var nextDir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc"; + Array.prototype.forEach.call(headerRow.children, function (cell) { + cell.removeAttribute("data-sort-dir"); + cell.removeAttribute("aria-sort"); + }); + th.setAttribute("data-sort-dir", nextDir); + th.setAttribute("aria-sort", nextDir === "asc" ? "ascending" : "descending"); + + var numeric = columnIsNumeric(rows, index); + rows.sort(function (a, b) { + return compareRows(a, b, index, numeric, nextDir); + }); + rows.forEach(function (row) { + tbody.appendChild(row); + }); + } + + document.addEventListener("click", function (event) { + var th = event.target.closest("table.sortable thead th"); + if (!th || th.hasAttribute("data-sort-off") || th.textContent.trim() === "") return; + sortTable(th.closest("table"), th); + }); +})(); diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html index 8b43b32..dc10170 100644 --- a/app/templates/_scopes_table_card.html +++ b/app/templates/_scopes_table_card.html @@ -11,7 +11,7 @@ {%- endif -%} {%- endmacro -%}
- +
@@ -21,7 +21,7 @@ {% for ct in column_types %} - + {% endfor %} diff --git a/app/templates/admin/_attachment_point_names_table.html b/app/templates/admin/_attachment_point_names_table.html index 3c6fce8..0d567fc 100644 --- a/app/templates/admin/_attachment_point_names_table.html +++ b/app/templates/admin/_attachment_point_names_table.html @@ -1,5 +1,5 @@
-
ScopeMagnification Objective thread{{ ct.name }}{{ ct.name }}
+
diff --git a/app/templates/admin/_attachment_point_row.html b/app/templates/admin/_attachment_point_row.html index e09a8de..c661fe8 100644 --- a/app/templates/admin/_attachment_point_row.html +++ b/app/templates/admin/_attachment_point_row.html @@ -20,7 +20,6 @@ {% endif %} - diff --git a/app/templates/admin/_attachment_points_table.html b/app/templates/admin/_attachment_points_table.html index ae18778..094349c 100644 --- a/app/templates/admin/_attachment_points_table.html +++ b/app/templates/admin/_attachment_points_table.html @@ -1,9 +1,8 @@
-
ID
{{ ap.id }}{{ ap.augmented_part.description | truncate(40) }} {{ ap.attachment_point_name.name }} {{ conn_type }} {{ conn_label }}
+
- diff --git a/app/templates/admin/_augmented_parts_table.html b/app/templates/admin/_augmented_parts_table.html index 034f175..22e4da2 100644 --- a/app/templates/admin/_augmented_parts_table.html +++ b/app/templates/admin/_augmented_parts_table.html @@ -10,6 +10,8 @@ hx-target="#augmented-parts-table-card" hx-trigger="input delay:300ms, change" hx-swap="outerHTML"> + +
IDAugmented Part Point Name Connection Type Connection
- - - - - - - - + {{ sort_th("id", "ID", sort_headers) }} + {{ sort_th("sku", "SKU", sort_headers) }} + {{ sort_th("description", "Description", sort_headers) }} + {{ sort_th("colour", "Colour", sort_headers) }} + {{ sort_th("manufacturer", "Manufacturer", sort_headers) }} + {{ sort_th("scope_model", "Model", sort_headers) }} + {{ sort_th("part_type", "Type", sort_headers) }} + {{ sort_th("sub_part_type", "Sub Type", sort_headers) }} diff --git a/app/templates/admin/_clamp_connections_table.html b/app/templates/admin/_clamp_connections_table.html index f665530..288d219 100644 --- a/app/templates/admin/_clamp_connections_table.html +++ b/app/templates/admin/_clamp_connections_table.html @@ -1,5 +1,5 @@
-
IDSKUDescriptionColourManufacturerModelTypeSub Type
+
diff --git a/app/templates/admin/_imported_parts_table.html b/app/templates/admin/_imported_parts_table.html index d686908..8840340 100644 --- a/app/templates/admin/_imported_parts_table.html +++ b/app/templates/admin/_imported_parts_table.html @@ -11,6 +11,8 @@ hx-target="#imported-parts-table-card" hx-trigger="input delay:300ms, change" hx-swap="outerHTML"> + + diff --git a/app/templates/admin/_imported_parts_table_card.html b/app/templates/admin/_imported_parts_table_card.html index 18c6719..3d0c256 100644 --- a/app/templates/admin/_imported_parts_table_card.html +++ b/app/templates/admin/_imported_parts_table_card.html @@ -1,16 +1,17 @@ +{% from "admin/_sort_macros.html" import sort_th %}
{% include "admin/_pagination.html" %}
ID
- - - - - - - - + {{ sort_th("InventoryID", "InventoryID", sort_headers) }} + {{ sort_th("Description", "Description", sort_headers) }} + {{ sort_th("TBXColour", "TBX Colour", sort_headers) }} + {{ sort_th("ProductMaxThreadDepthmm", "Max Thread Depth (mm)", sort_headers) }} + {{ sort_th("ProductMaxThreadDepthin", "Max Thread Depth (in)", sort_headers) }} + {{ sort_th("ProductODmm", "OD (mm)", sort_headers) }} + {{ sort_th("ProductODin", "OD (in)", sort_headers) }} + {{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }} diff --git a/app/templates/admin/_manufacturers_table.html b/app/templates/admin/_manufacturers_table.html index 600a46a..26768f9 100644 --- a/app/templates/admin/_manufacturers_table.html +++ b/app/templates/admin/_manufacturers_table.html @@ -1,8 +1,8 @@
-
InventoryIDDescriptionTBX ColourMax Thread Depth (mm)Max Thread Depth (in)OD (mm)OD (in)Thread Attribute
+
- + diff --git a/app/templates/admin/_part_colours_table.html b/app/templates/admin/_part_colours_table.html index c0d15ce..7a92e3c 100644 --- a/app/templates/admin/_part_colours_table.html +++ b/app/templates/admin/_part_colours_table.html @@ -1,5 +1,5 @@
-
IDNameHomepageLogoIDNameHomepageLogo
+
diff --git a/app/templates/admin/_part_types_table.html b/app/templates/admin/_part_types_table.html index 482ba15..9b3535f 100644 --- a/app/templates/admin/_part_types_table.html +++ b/app/templates/admin/_part_types_table.html @@ -1,5 +1,5 @@
-
ID
+
diff --git a/app/templates/admin/_roles_table.html b/app/templates/admin/_roles_table.html index afed32d..d171817 100644 --- a/app/templates/admin/_roles_table.html +++ b/app/templates/admin/_roles_table.html @@ -1,7 +1,7 @@
-
IDName
+
- + {% for r in roles %} diff --git a/app/templates/admin/_scope_models_table.html b/app/templates/admin/_scope_models_table.html index f21a981..f786aaa 100644 --- a/app/templates/admin/_scope_models_table.html +++ b/app/templates/admin/_scope_models_table.html @@ -1,5 +1,5 @@
-
IDNameDescriptionPermissionsDefault
IDNameDescriptionPermissionsDefault
+
diff --git a/app/templates/admin/_sort_macros.html b/app/templates/admin/_sort_macros.html new file mode 100644 index 0000000..252749c --- /dev/null +++ b/app/templates/admin/_sort_macros.html @@ -0,0 +1,13 @@ +{% macro sort_th(key, label, sort_headers) %} + {% if sort_headers and key in sort_headers %} + {% set h = sort_headers[key] %} + + {% else %} + + {% endif %} +{% endmacro %} diff --git a/app/templates/admin/_static_connections_table.html b/app/templates/admin/_static_connections_table.html index 4f7002d..5aeb6fe 100644 --- a/app/templates/admin/_static_connections_table.html +++ b/app/templates/admin/_static_connections_table.html @@ -1,5 +1,5 @@
-
IDNameManufacturerProduct Page + {{ label }}{% if h.active %}{% endif %} + {{ label }}
+
diff --git a/app/templates/admin/_sub_part_types_table.html b/app/templates/admin/_sub_part_types_table.html index ff05ffb..d6deb7e 100644 --- a/app/templates/admin/_sub_part_types_table.html +++ b/app/templates/admin/_sub_part_types_table.html @@ -1,5 +1,5 @@
-
ID
+
diff --git a/app/templates/admin/_users_table.html b/app/templates/admin/_users_table.html index 7b98a1f..bc09886 100644 --- a/app/templates/admin/_users_table.html +++ b/app/templates/admin/_users_table.html @@ -1,8 +1,8 @@
-
IDNameParent Type
+
- + diff --git a/app/templates/admin/audit_log.html b/app/templates/admin/audit_log.html index a1521d7..4ee9276 100644 --- a/app/templates/admin/audit_log.html +++ b/app/templates/admin/audit_log.html @@ -7,7 +7,7 @@
{% if entries %} -
IDEmailNameActiveSuperuserRolesIDEmailNameActiveSuperuserRoles
+
diff --git a/app/templates/base.html b/app/templates/base.html index 762bf87..b251e53 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,6 +8,7 @@ + {% block body %}{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 5f9ee0d..29c4b5f 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -53,6 +53,11 @@ 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, @@ -66,10 +71,9 @@ def _paginate( 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 _query_url( + base_path, {**filter_params, "page": p, "page_size": page_size} + ) return { "page": page, @@ -85,6 +89,33 @@ def _paginate( } +def _sort_headers( + base_path: str, + filter_params: dict, + columns: list[str], + sort: str, + direction: str, + target_id: str, +) -> dict: + """Per-column header link context for click-to-sort table headers. + + ``filter_params`` should NOT include "sort"/"dir"/"page" — those are added here. + """ + headers = {} + for key in columns: + next_dir = "desc" if sort == key and direction == "asc" else "asc" + headers[key] = { + "url": _query_url( + base_path, + {**filter_params, "sort": key, "dir": next_dir, "page": 1}, + ), + "active": sort == key, + "dir": direction if sort == key else None, + "target_id": target_id, + } + return headers + + def web_require(*perms: str): """Web dependency: allow superusers or holders of all given permissions.""" @@ -1206,10 +1237,50 @@ async def delete_sub_part_type( # --- Imported Parts ---------------------------------------------------------- +_IMPORTED_PARTS_SORT_COLUMNS = [ + "InventoryID", + "Description", + "TBXColour", + "ProductMaxThreadDepthmm", + "ProductMaxThreadDepthin", + "ProductODmm", + "ProductODin", + "TBXThreadAttribute", +] + + +async def _default_imported_parts_table_ctx(db: AsyncSession) -> dict: + """Table-card context (default sort, no filter, page 1) after a refresh.""" + total = await imported_part_crud.count(db) + pagination = _paginate( + "/admin/imported-parts/filter", + {}, + 1, + _PAGE_SIZE, + total, + "#imported-parts-table-card", + ) + return { + "parts": await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE), + "pagination": pagination, + "sort_headers": _sort_headers( + "/admin/imported-parts/filter", + {}, + _IMPORTED_PARTS_SORT_COLUMNS, + "InventoryID", + "asc", + "#imported-parts-table-card", + ), + "sort": "InventoryID", + "dir": "asc", + } + @router.get("/imported-parts", response_class=HTMLResponse) async def imported_parts_page( request: Request, + sort: str = "InventoryID", + dir: str = "asc", db: AsyncSession = Depends(get_db), user: User = Depends(web_require("imported_parts:read")), ): @@ -1220,19 +1291,37 @@ async def imported_parts_page( "has_password": bool(config and config.password), } total = await imported_part_crud.count(db) - parts = await imported_part_crud.list_filtered(db, limit=_PAGE_SIZE) + parts = await imported_part_crud.list_filtered( + db, sort=sort, direction=dir, limit=_PAGE_SIZE + ) pagination = _paginate( "/admin/imported-parts/filter", - {}, + {"sort": sort, "dir": dir}, 1, _PAGE_SIZE, total, "#imported-parts-table-card", ) + sort_headers = _sort_headers( + "/admin/imported-parts/filter", + {}, + _IMPORTED_PARTS_SORT_COLUMNS, + sort, + dir, + "#imported-parts-table-card", + ) return templates.TemplateResponse( request, "admin/imported_parts.html", - {"user": user, "config": config_view, "parts": parts, "pagination": pagination}, + { + "user": user, + "config": config_view, + "parts": parts, + "pagination": pagination, + "sort_headers": sort_headers, + "sort": sort, + "dir": dir, + }, ) @@ -1274,19 +1363,8 @@ async def refresh_imported_parts( config = await imported_part_config_crud.get(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, + **await _default_imported_parts_table_ctx(db), "sync_result": sync_result, "sync_error": sync_error, } @@ -1329,15 +1407,18 @@ async def refresh_imported_parts( async def filter_imported_parts( request: Request, description: str = "", + sort: str = "InventoryID", + dir: str = "asc", page: int = 1, page_size: int = _PAGE_SIZE, db: AsyncSession = Depends(get_db), _: User = Depends(web_require("imported_parts:read")), ): total = await imported_part_crud.count_filtered(db, description=description) + filter_params = {"description": description} pagination = _paginate( "/admin/imported-parts/filter", - {"description": description}, + {**filter_params, "sort": sort, "dir": dir}, page, page_size, total, @@ -1346,13 +1427,29 @@ async def filter_imported_parts( parts = await imported_part_crud.list_filtered( db, description=description, + sort=sort, + direction=dir, skip=pagination["skip"], limit=page_size, ) + sort_headers = _sort_headers( + "/admin/imported-parts/filter", + filter_params, + _IMPORTED_PARTS_SORT_COLUMNS, + sort, + dir, + "#imported-parts-table-card", + ) return templates.TemplateResponse( request, "admin/_imported_parts_table_card.html", - {"parts": parts, "pagination": pagination}, + { + "parts": parts, + "pagination": pagination, + "sort_headers": sort_headers, + "sort": sort, + "dir": dir, + }, ) @@ -1431,18 +1528,22 @@ async def _augmented_part_form_ctx(db: AsyncSession) -> dict: } -@router.get("/augmented-parts", response_class=HTMLResponse) -async def augmented_parts_page( - request: Request, - db: AsyncSession = Depends(get_db), - user: User = Depends(web_require("augmented_parts:read")), -): - ctx = await _augmented_part_form_ctx(db) +_AUGMENTED_PARTS_SORT_COLUMNS = [ + "id", + "sku", + "description", + "colour", + "manufacturer", + "scope_model", + "part_type", + "sub_part_type", +] + + +async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict: + """Table-card context (default sort, no filters, page 1) after create/sync actions.""" total = await augmented_part_crud.count_filtered(db) - ctx["augmented_parts"] = await augmented_part_crud.list_filtered( - db, limit=_PAGE_SIZE - ) - ctx["pagination"] = _paginate( + pagination = _paginate( "/admin/augmented-parts/filter", {}, 1, @@ -1450,6 +1551,55 @@ async def augmented_parts_page( total, "#augmented-parts-table-card", ) + return { + "augmented_parts": await augmented_part_crud.list_filtered( + db, limit=_PAGE_SIZE + ), + "pagination": pagination, + "sort_headers": _sort_headers( + "/admin/augmented-parts/filter", + {}, + _AUGMENTED_PARTS_SORT_COLUMNS, + "description", + "asc", + "#augmented-parts-table-card", + ), + "sort": "description", + "dir": "asc", + } + + +@router.get("/augmented-parts", response_class=HTMLResponse) +async def augmented_parts_page( + request: Request, + sort: str = "description", + dir: str = "asc", + db: AsyncSession = Depends(get_db), + 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, sort=sort, direction=dir, limit=_PAGE_SIZE + ) + ctx["pagination"] = _paginate( + "/admin/augmented-parts/filter", + {"sort": sort, "dir": dir}, + 1, + _PAGE_SIZE, + total, + "#augmented-parts-table-card", + ) + ctx["sort_headers"] = _sort_headers( + "/admin/augmented-parts/filter", + {}, + _AUGMENTED_PARTS_SORT_COLUMNS, + sort, + dir, + "#augmented-parts-table-card", + ) + ctx["sort"] = sort + ctx["dir"] = dir ctx["user"] = user return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx) @@ -1488,25 +1638,10 @@ 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_filtered( - db, limit=_PAGE_SIZE - ), - "pagination": pagination, - }, + {**form_ctx, **await _default_augmented_parts_table_ctx(db)}, ) @@ -1543,24 +1678,12 @@ 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_filtered( - db, limit=_PAGE_SIZE - ), - "pagination": pagination, + **await _default_augmented_parts_table_ctx(db), "sync_result": sync_result, "sync_error": sync_error, }, @@ -1576,6 +1699,8 @@ async def filter_augmented_parts( scope_model_id: str = "", part_type_id: str = "", sub_part_type_id: str = "", + sort: str = "description", + dir: str = "asc", page: int = 1, page_size: int = _PAGE_SIZE, db: AsyncSession = Depends(get_db), @@ -1605,7 +1730,7 @@ async def filter_augmented_parts( } pagination = _paginate( "/admin/augmented-parts/filter", - filter_params, + {**filter_params, "sort": sort, "dir": dir}, page, page_size, total, @@ -1619,13 +1744,29 @@ async def filter_augmented_parts( scope_model_id=scope_model_id_int, part_type_id=part_type_id_int, sub_part_type_id=sub_part_type_id_int, + sort=sort, + direction=dir, skip=pagination["skip"], limit=page_size, ) + sort_headers = _sort_headers( + "/admin/augmented-parts/filter", + filter_params, + _AUGMENTED_PARTS_SORT_COLUMNS, + sort, + dir, + "#augmented-parts-table-card", + ) return templates.TemplateResponse( request, "admin/_augmented_parts_table_card.html", - {"augmented_parts": parts, "pagination": pagination}, + { + "augmented_parts": parts, + "pagination": pagination, + "sort_headers": sort_headers, + "sort": sort, + "dir": dir, + }, ) diff --git a/tests/test_admin_sorting.py b/tests/test_admin_sorting.py new file mode 100644 index 0000000..63223ad --- /dev/null +++ b/tests/test_admin_sorting.py @@ -0,0 +1,148 @@ +import pytest + +from app.crud import augmented_part as augmented_part_crud +from app.models.augmented_part import AugmentedPart +from app.models.imported_part import ImportedPart +from app.models.manufacturer import Manufacturer +from app.models.part_colour import PartColour +from app.models.part_type import PartType + + +async def _web_login(client, email, password="secret123"): + resp = await client.post("/login", data={"email": email, "password": password}) + assert resp.status_code == 303 + + +async def _seed_augmented_parts(db): + part_t = PartType(name="Scope") + db.add(part_t) + await db.flush() + parts = [ + AugmentedPart( + description=desc, + sku=sku, + colour_id=1, + manufacturer_id=1, + part_type_id=part_t.id, + ) + for desc, sku in [ + ("Alpha scope", "SKU-C"), + ("Bravo scope", "SKU-A"), + ("Charlie scope", "SKU-B"), + ] + ] + db.add_all(parts) + await db.commit() + + +async def _seed_imported_parts(db): + db.add_all( + [ + ImportedPart(InventoryID="INV-2", Description="Charlie widget"), + ImportedPart(InventoryID="INV-1", Description="Alpha widget"), + ImportedPart(InventoryID="INV-3", Description="Bravo widget"), + ] + ) + await db.commit() + + +def _order_of(body: str, needles: list[str]) -> list[str]: + positions = {n: body.find(n) for n in needles} + return sorted(positions, key=lambda n: positions[n]) + + +@pytest.mark.asyncio +async def test_augmented_parts_page_sorts_by_description_desc( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + await _seed_augmented_parts(db) + await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader@example.com") + + resp = await client.get("/admin/augmented-parts?sort=description&dir=desc") + assert resp.status_code == 200 + body = resp.text + assert _order_of(body, ["Charlie scope", "Bravo scope", "Alpha scope"]) == [ + "Charlie scope", + "Bravo scope", + "Alpha scope", + ] + + +@pytest.mark.asyncio +async def test_augmented_parts_filter_sorts_by_sku_asc( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + await _seed_augmented_parts(db) + await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader2@example.com") + + resp = await client.get("/admin/augmented-parts/filter?sort=sku&dir=asc") + assert resp.status_code == 200 + body = resp.text + # SKU-A (Bravo), SKU-B (Charlie), SKU-C (Alpha) + assert _order_of(body, ["Bravo scope", "Charlie scope", "Alpha scope"]) == [ + "Bravo scope", + "Charlie scope", + "Alpha scope", + ] + + +@pytest.mark.asyncio +async def test_imported_parts_page_sorts_by_description_desc( + client, seeded, make_user, db_sessionmaker +): + async with db_sessionmaker() as db: + await _seed_imported_parts(db) + await make_user("reader3@example.com", role_ids=[seeded["roles"]["user"].id]) + await _web_login(client, "reader3@example.com") + + resp = await client.get("/admin/imported-parts?sort=Description&dir=desc") + assert resp.status_code == 200 + body = resp.text + assert _order_of(body, ["Charlie widget", "Bravo widget", "Alpha widget"]) == [ + "Charlie widget", + "Bravo widget", + "Alpha widget", + ] + + +@pytest.mark.asyncio +async def test_list_filtered_sorts_by_joined_colour_name(db_sessionmaker): + async with db_sessionmaker() as db: + red = PartColour(name="Red") + blue = PartColour(name="Blue") + manufacturer = Manufacturer(name="Acme", url="https://acme.example") + part_t = PartType(name="Scope") + db.add_all([red, blue, manufacturer, part_t]) + await db.flush() + + db.add_all( + [ + AugmentedPart( + description="Red one", + colour_id=red.id, + manufacturer_id=manufacturer.id, + part_type_id=part_t.id, + ), + AugmentedPart( + description="Blue one", + colour_id=blue.id, + manufacturer_id=manufacturer.id, + part_type_id=part_t.id, + ), + ] + ) + await db.commit() + + parts = await augmented_part_crud.list_filtered( + db, sort="colour", direction="asc" + ) + assert [p.description for p in parts] == ["Blue one", "Red one"] + + parts_desc = await augmented_part_crud.list_filtered( + db, sort="colour", direction="desc" + ) + assert [p.description for p in parts_desc] == ["Red one", "Blue one"]
Time