Refactor table data fragment and enhance pagination support in list_items function

This commit is contained in:
Yaro Kasear 2025-08-21 15:15:52 -05:00
parent ce4164d77c
commit c4400764e5
3 changed files with 185 additions and 57 deletions

View file

@ -1,21 +1,4 @@
<!-- Table Data Fragment --> <!-- fragments/_table_data_fragment.html -->
{#
<table>
<thead>
{% if headers %}
<tr>
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
{% else %}
{% for col in rows[0].keys() %}
<th>{{ col }}</th>
{% endfor %}
{% endif %}
</tr>
</thead>
<tbody>
#}
{% for r in rows %} {% for r in rows %}
<tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.' + model_name + '_item', id=r.id) }}'"> <tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.' + model_name + '_item', id=r.id) }}'">
{% for key, val in r.items() if not key == 'id' %} {% for key, val in r.items() if not key == 'id' %}
@ -23,7 +6,11 @@
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
{#
</tbody> {% if pages > 1 %}
</table> <tr>
#} <td colspan="{{ rows[0]|length - 1 }}">
Page {{ page }} of {{ pages }} ({{ total }} total)
</td>
</tr>
{% endif %}

View file

@ -5,7 +5,7 @@ from sqlalchemy.sql import Select
from typing import Any, List, cast from typing import Any, List, cast
from .defaults import ( 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 from .. import db
@ -43,24 +43,43 @@ def call(Model: type, name: str, *args: Any, **kwargs: Any) -> Any:
fn = getattr(Model, name, None) fn = getattr(Model, name, None)
return fn(*args, **kwargs) if callable(fn) else 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("/<model_name>/list") @bp.get("/<model_name>/list")
def list_items(model_name): def list_items(model_name):
Model = get_model_class(model_name) Model = get_model_class(model_name)
text = (request.args.get("q") or "").strip() or None text = (request.args.get("q") or "").strip() or None
fields_raw = (request.args.get("fields") or "").strip() fields_raw = (request.args.get("fields") or "").strip()
fields = [f.strip() for f in fields_raw.split(",") if f.strip()] fields = [f.strip() for f in fields_raw.split(",") if f.strip()]
fields.extend(request.args.getlist("field")) fields.extend(request.args.getlist("field"))
# legacy params
limit_param = request.args.get("limit") limit_param = request.args.get("limit")
# 0 / -1 / blank => unlimited (pass 0)
if limit_param in (None, "", "0", "-1"): if limit_param in (None, "", "0", "-1"):
effective_limit = 0 effective_limit = 0
else: else:
effective_limit = min(int(limit_param), 500) effective_limit = min(int(limit_param), 500)
offset = int(request.args.get("offset", 0)) 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 sort = (request.args.get("sort") or "").strip() or None
direction = (request.args.get("dir") or request.args.get("direction") or "asc").lower() direction = (request.args.get("dir") or request.args.get("direction") or "asc").lower()
if direction not in ("asc", "desc"): if direction not in ("asc", "desc"):
@ -68,32 +87,74 @@ def list_items(model_name):
qkwargs: dict[str, Any] = { qkwargs: dict[str, Any] = {
"text": text, "text": text,
"limit": effective_limit, # these are irrelevant for stmt-building; keep for ui_query compatibility
"offset": offset, "limit": 0 if unlimited else per_page,
"offset": 0 if unlimited else (page - 1) * per_page if per_page else 0,
"sort": sort, "sort": sort,
"direction": direction, "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) rows_any: Any = call(Model, "ui_query", db.session, **qkwargs)
stmt: Select | None = None
total: int
if rows_any is None: if rows_any is None:
rows = default_query(db.session, Model, **qkwargs) # 2) default: build a Select
elif isinstance(rows_any, list): stmt = default_select(Model, text=text, sort=sort, direction=direction)
rows = rows_any
elif isinstance(rows_any, Select): elif isinstance(rows_any, Select):
rows = list(cast(ScalarResult[Any], db.session.execute(rows_any).scalars())) 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: else:
# If someone returns a Result or other iterable of models start = (page - 1) * per_page
try: end = start + per_page
# Try SQLAlchemy Result-like rows = rows_any[start:end]
else:
# SQLAlchemy Result-like?
scalars = getattr(rows_any, "scalars", None) scalars = getattr(rows_any, "scalars", None)
if callable(scalars): if callable(scalars):
rows = list(cast(ScalarResult[Any], scalars())) # execute now, then paginate in Python
all_rows = list(cast(ScalarResult[Any], scalars()))
total = len(all_rows)
if unlimited:
rows = all_rows
else: else:
rows = list(rows_any) 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: except TypeError:
total = 1
rows = [rows_any] 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: if fields:
items = [] items = []
for r in rows: for r in rows:
@ -112,17 +173,32 @@ def list_items(model_name):
for r in rows for r in rows
] ]
print(items) # Views
want_option = (request.args.get("view") == "option") want_option = (request.args.get("view") == "option")
want_list = (request.args.get("view") == "list") want_list = (request.args.get("view") == "list")
want_table = (request.args.get("view") == "table") want_table = (request.args.get("view") == "table")
if want_option: if want_option:
return render_template("fragments/_option_fragment.html", options=items) return render_template("fragments/_option_fragment.html", options=items)
if want_list: if want_list:
return render_template("fragments/_list_fragment.html", options=items) return render_template("fragments/_list_fragment.html", options=items)
if want_table: if want_table:
return render_template("fragments/_table_data_fragment.html", rows=items, model_name=model_name) return render_template("fragments/_table_data_fragment.html",
return jsonify({"items": items}) 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("/<model_name>/create") @bp.post("/<model_name>/create")
def create_item(model_name): def create_item(model_name):

View file

@ -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.inspection import inspect
from sqlalchemy.orm import aliased
from sqlalchemy.sql import Select from sqlalchemy.sql import Select
from sqlalchemy.sql.sqltypes import String, Unicode, Text from sqlalchemy.sql.sqltypes import String, Unicode, Text
from typing import Any, Optional, cast, Iterable from typing import Any, Optional, cast, Iterable
@ -38,6 +37,72 @@ def infer_label_attr(Model):
return a return a
raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})") 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( def default_query(
session, session,
Model, Model,