Refactor table data fragment and enhance pagination support in list_items function
This commit is contained in:
parent
ce4164d77c
commit
c4400764e5
3 changed files with 185 additions and 57 deletions
|
|
@ -1,29 +1,16 @@
|
|||
<!-- 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>
|
||||
#}
|
||||
{% 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' %}
|
||||
<td class="text-nowrap">{{ val if val else '-' }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{#
|
||||
</tbody>
|
||||
</table>
|
||||
#}
|
||||
<!-- 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' %}
|
||||
<td class="text-nowrap">{{ val if val else '-' }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if pages > 1 %}
|
||||
<tr>
|
||||
<td colspan="{{ rows[0]|length - 1 }}">
|
||||
Page {{ page }} of {{ pages }} ({{ total }} total)
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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("/<model_name>/create")
|
||||
def create_item(model_name):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue