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 -->
{#
<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>
#}
<!-- fragments/_table_data_fragment.html -->
{% for r in rows %}
<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' %}
@ -23,7 +6,11 @@
{% endfor %}
</tr>
{% endfor %}
{#
</tbody>
</table>
#}
{% if pages > 1 %}
<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 .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("/<model_name>/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)
stmt: Select | None = None
total: int
if rows_any is None:
rows = default_query(db.session, Model, **qkwargs)
elif isinstance(rows_any, list):
rows = rows_any
# 2) default: build a Select
stmt = default_select(Model, text=text, sort=sort, direction=direction)
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:
# If someone returns a Result or other iterable of models
try:
# Try SQLAlchemy Result-like
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):
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:
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:
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("/<model_name>/create")
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.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,