diff --git a/inventory/templates/fragments/_table_data_fragment.html b/inventory/templates/fragments/_table_data_fragment.html
index 928a69c..710fcab 100644
--- a/inventory/templates/fragments/_table_data_fragment.html
+++ b/inventory/templates/fragments/_table_data_fragment.html
@@ -1,29 +1,16 @@
-
- {#
-
-
- {% if headers %}
-
- {% for header in headers %}
- | {{ header }} |
- {% endfor %}
- {% else %}
- {% for col in rows[0].keys() %}
- {{ col }} |
- {% endfor %}
- {% endif %}
-
-
-
- #}
- {% for r in rows %}
-
- {% for key, val in r.items() if not key == 'id' %}
- | {{ val if val else '-' }} |
- {% endfor %}
-
- {% endfor %}
- {#
-
-
- #}
\ No newline at end of file
+
+{% for r in rows %}
+
+ {% for key, val in r.items() if not key == 'id' %}
+ | {{ val if val else '-' }} |
+ {% endfor %}
+
+{% endfor %}
+
+{% if pages > 1 %}
+
+ |
+ Page {{ page }} of {{ pages }} ({{ total }} total)
+ |
+
+{% endif %}
diff --git a/inventory/ui/blueprint.py b/inventory/ui/blueprint.py
index e662658..88387e8 100644
--- a/inventory/ui/blueprint.py
+++ b/inventory/ui/blueprint.py
@@ -5,7 +5,7 @@ from sqlalchemy.sql import Select
from typing import Any, List, cast
from .defaults import (
- default_query, default_create, default_update, default_delete, default_serialize, default_values, default_value
+ default_query, default_create, default_update, default_delete, default_serialize, default_values, default_value, default_select, ensure_order_by
)
from .. import db
@@ -43,24 +43,43 @@ def call(Model: type, name: str, *args: Any, **kwargs: Any) -> Any:
fn = getattr(Model, name, None)
return fn(*args, **kwargs) if callable(fn) else None
+from flask import request, jsonify, render_template
+from sqlalchemy.sql import Select
+from sqlalchemy.engine import ScalarResult
+from typing import Any, cast
+
@bp.get("//list")
def list_items(model_name):
Model = get_model_class(model_name)
+
text = (request.args.get("q") or "").strip() or None
fields_raw = (request.args.get("fields") or "").strip()
fields = [f.strip() for f in fields_raw.split(",") if f.strip()]
fields.extend(request.args.getlist("field"))
+ # legacy params
limit_param = request.args.get("limit")
- # 0 / -1 / blank => unlimited (pass 0)
if limit_param in (None, "", "0", "-1"):
effective_limit = 0
else:
effective_limit = min(int(limit_param), 500)
offset = int(request.args.get("offset", 0))
- view = (request.args.get("view") or "json").strip()
+ # new-school params
+ page = request.args.get("page", type=int)
+ per_page = request.args.get("per_page", type=int)
+
+ # map legacy limit/offset to page/per_page if new params not provided
+ if per_page is None:
+ per_page = effective_limit or 20 # default page size if not unlimited
+ if page is None:
+ page = (offset // per_page) + 1 if per_page else 1
+
+ # unlimited: treat as "no pagination"
+ unlimited = (per_page == 0)
+
+ view = (request.args.get("view") or "json").strip()
sort = (request.args.get("sort") or "").strip() or None
direction = (request.args.get("dir") or request.args.get("direction") or "asc").lower()
if direction not in ("asc", "desc"):
@@ -68,32 +87,74 @@ def list_items(model_name):
qkwargs: dict[str, Any] = {
"text": text,
- "limit": effective_limit,
- "offset": offset,
+ # these are irrelevant for stmt-building; keep for ui_query compatibility
+ "limit": 0 if unlimited else per_page,
+ "offset": 0 if unlimited else (page - 1) * per_page if per_page else 0,
"sort": sort,
"direction": direction,
}
- # Prefer per-model override. Contract: return list[Model] OR a Select (SA 2.x).
+ # 1) Try per-model override first
rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
- if rows_any is None:
- rows = default_query(db.session, Model, **qkwargs)
- elif isinstance(rows_any, list):
- rows = rows_any
- elif isinstance(rows_any, Select):
- rows = list(cast(ScalarResult[Any], db.session.execute(rows_any).scalars()))
- else:
- # If someone returns a Result or other iterable of models
- try:
- # Try SQLAlchemy Result-like
- scalars = getattr(rows_any, "scalars", None)
- if callable(scalars):
- rows = list(cast(ScalarResult[Any], scalars()))
- else:
- rows = list(rows_any)
- except TypeError:
- rows = [rows_any]
+ stmt: Select | None = None
+ total: int
+
+ if rows_any is None:
+ # 2) default: build a Select
+ stmt = default_select(Model, text=text, sort=sort, direction=direction)
+ elif isinstance(rows_any, Select):
+ stmt = rows_any
+ elif isinstance(rows_any, list):
+ # Someone returned a materialized list. Paginate in Python.
+ total = len(rows_any)
+ if unlimited:
+ rows = rows_any
+ else:
+ start = (page - 1) * per_page
+ end = start + per_page
+ rows = rows_any[start:end]
+ else:
+ # SQLAlchemy Result-like?
+ scalars = getattr(rows_any, "scalars", None)
+ if callable(scalars):
+ # execute now, then paginate in Python
+ all_rows = list(cast(ScalarResult[Any], scalars()))
+ total = len(all_rows)
+ if unlimited:
+ rows = all_rows
+ else:
+ start = (page - 1) * per_page
+ end = start + per_page
+ rows = all_rows[start:end]
+ else:
+ # single object or generic iterable
+ try:
+ all_rows = list(rows_any)
+ total = len(all_rows)
+ rows = all_rows if unlimited else all_rows[(page - 1) * per_page : (page * per_page)]
+ except TypeError:
+ total = 1
+ rows = [rows_any]
+
+ # If we have a real Select, use db.paginate for proper COUNT and slicing
+ if stmt is not None:
+ if unlimited:
+ rows = list(db.session.execute(stmt).scalars())
+ total = count_for(db.session, stmt)
+ else:
+ stmt = default_select(Model, text=text, sort=sort, direction=direction)
+ stmt = ensure_order_by(stmt, Model, sort=sort, direction=direction)
+ pagination = db.paginate(
+ stmt,
+ page=page,
+ per_page=per_page,
+ error_out=False
+ )
+ rows = pagination.items
+ total = pagination.total
+
+ # Serialize
if fields:
items = []
for r in rows:
@@ -112,17 +173,32 @@ def list_items(model_name):
for r in rows
]
- print(items)
+ # Views
want_option = (request.args.get("view") == "option")
want_list = (request.args.get("view") == "list")
want_table = (request.args.get("view") == "table")
+
if want_option:
return render_template("fragments/_option_fragment.html", options=items)
if want_list:
return render_template("fragments/_list_fragment.html", options=items)
if want_table:
- return render_template("fragments/_table_data_fragment.html", rows=items, model_name=model_name)
- return jsonify({"items": items})
+ return render_template("fragments/_table_data_fragment.html",
+ rows=items,
+ model_name=model_name,
+ total=total,
+ page=page,
+ per_page=per_page,
+ pages=(0 if unlimited else ((total + per_page - 1) // per_page)),
+ )
+
+ return jsonify({
+ "items": items,
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": (0 if unlimited else ((total + per_page - 1) // per_page))
+ })
@bp.post("//create")
def create_item(model_name):
diff --git a/inventory/ui/defaults.py b/inventory/ui/defaults.py
index 62cdce5..6d4b4be 100644
--- a/inventory/ui/defaults.py
+++ b/inventory/ui/defaults.py
@@ -1,6 +1,5 @@
-from sqlalchemy import select, asc as sa_asc, desc as sa_desc, or_
+from sqlalchemy import select, asc as sa_asc, desc as sa_desc, or_, func
from sqlalchemy.inspection import inspect
-from sqlalchemy.orm import aliased
from sqlalchemy.sql import Select
from sqlalchemy.sql.sqltypes import String, Unicode, Text
from typing import Any, Optional, cast, Iterable
@@ -38,6 +37,72 @@ def infer_label_attr(Model):
return a
raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})")
+def count_for(stmt: Select) -> int:
+ subq = stmt.order_by(None).subquery()
+ return stmt.bind.execute(select(func.count()).select_from(subq)).scalar_one()
+
+def ensure_order_by(stmt, Model, sort=None, direction="asc"):
+ try:
+ has_order = bool(getattr(stmt, '_order_by_clauses', None))
+ except Exception:
+ has_order = False
+ if has_order:
+ return stmt
+
+ cols = []
+
+ if sort and hasattr(Model, sort):
+ col = getattr(Model, sort)
+ cols.append(col.desc() if direction == "desc" else col.asc())
+
+ if not cols:
+ ui_order_cols = getattr(Model, 'ui_order_cols', ())
+ for name in ui_order_cols or ():
+ c = getattr(Model, name, None)
+ if c is not None:
+ cols.append(c.asc())
+
+ if not cols:
+ for pk_col in inspect(Model).primary_key:
+ cols.append(pk_col.asc())
+
+ return stmt.order_by(*cols)
+
+def default_select(
+ Model,
+ *,
+ text: Optional[str] = None,
+ sort: Optional[str] = None,
+ direction: str = "asc"
+) -> Select[Any]:
+ stmt: Select[Any] = select(Model)
+
+ ui_search = getattr(Model, "ui_search", None)
+ if callable(ui_search) and text:
+ stmt = cast(Select[Any], ui_search(stmt, text))
+
+ if sort:
+ ui_sort = getattr(Model, "ui_sort", None)
+ if callable(ui_sort):
+ stmt = cast(Select[Any], ui_sort(stmt, sort, direction))
+ else:
+ col = getattr(Model, sort, None)
+ if col is not None:
+ stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col))
+
+ else:
+ ui_order_cols = getattr(Model, "ui_order_cols", ())
+ if ui_order_cols:
+ order_cols = []
+ for name in ui_order_cols:
+ col = getattr(Model, name, None)
+ if col is not None:
+ order_cols.append(sa_asc(col))
+ if order_cols:
+ stmt = stmt.order_by(*order_cols)
+
+ return stmt
+
def default_query(
session,
Model,