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 %} - - {% endfor %} - {% else %} - {% for col in rows[0].keys() %} - - {% endfor %} - {% endif %} - - - - #} - {% for r in rows %} - - {% for key, val in r.items() if not key == 'id' %} - - {% endfor %} - - {% endfor %} - {# - -
{{ header }}{{ col }}
{{ val if val else '-' }}
- #} \ 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,