Hooray for pager working now.

This commit is contained in:
Yaro Kasear 2025-08-28 14:41:16 -05:00
parent 52bd0d4c91
commit 8100d221a1
5 changed files with 186 additions and 119 deletions

View file

@ -1,8 +1,21 @@
from typing import List
from __future__ import annotations
from typing import Iterable, List, Sequence, Set
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Load, joinedload, selectinload
from sqlalchemy.orm import Load, joinedload, selectinload, RelationshipProperty
def default_eager_policy(Model, expand: List[str]) -> List[Load]:
class EagerConfig:
def __init__(self, strict: bool = False, max_depth: int = 4):
self.strict = strict
self.max_depth = max_depth
def _rel(cls, name: str) -> RelationshipProperty | None:
return inspect(cls).relationships.get(name)
def _is_expandable(rel: RelationshipProperty) -> bool:
# Skip dynamic or viewonly collections; they dont support eagerload
return rel.lazy != "dynamic"
def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]:
"""
Heuristic:
- many-to-one / one-to-one: joinedload
@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]:
if not expand:
return []
cfg = cfg or EagerConfig()
# normalize, dedupe, and prefer longer paths over their prefixes
raw: Set[str] = {p.strip() for p in expand if p and p.strip()}
# drop prefixes if a longer path exists (author, author.publisher -> keep only author.publisher)
pruned: Set[str] = set(raw)
for p in raw:
parts = p.split(".")
for i in range(1, len(parts)):
pruned.discard(".".join(parts[:i]))
opts: List[Load] = []
seen: Set[tuple] = set()
for path in expand:
for path in sorted(pruned):
parts = path.split(".")
if len(parts) > cfg.max_depth:
if cfg.strict:
raise ValueError(f"expand path too deep: {path} (max {cfg.max_depth})")
continue
current_model = Model
current_inspect = inspect(current_model)
# build the chain incrementally
loader: Load | None = None
ok = True
# first hop
rel = current_inspect.relationships.get(parts[0])
if not rel:
continue # silently skip bad names
attr = getattr(current_model, parts[0])
loader: Load = selectinload(attr) if rel.uselist else joinedload(attr)
current_model = rel.mapper.class_
# nested hops, if any
for name in parts[1:]:
current_inspect = inspect(current_model)
rel = current_inspect.relationships.get(name)
if not rel:
for i, name in enumerate(parts):
rel = _rel(current_model, name)
if not rel or not _is_expandable(rel):
ok = False
break
attr = getattr(current_model, name)
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
if loader is None:
loader = selectinload(attr) if rel.uselist else joinedload(attr)
else:
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
current_model = rel.mapper.class_
opts.append(loader)
if not ok:
if cfg.strict:
raise ValueError(f"unknown or non-expandable relationship in expand path: {path}")
continue
key = (tuple(parts),)
if loader is not None and key not in seen:
opts.append(loader)
seen.add(key)
return opts

View file

@ -18,11 +18,13 @@
{%- endfor -%}
{% endmacro %}
{% macro rows(items, fields, getp=None) -%}
{% macro rows(items, fields, getp=None, model=None) -%}
{%- for obj in items -%}
<tr id="row-{{ obj.id }}" style="cursor: pointer;">
{%- for f in fields -%}
<td data-field="{{ f }}" class="text-nowrap">{{ getp(obj, f) if getp(obj, f) is not none else '-' }}</td>
<td data-field="{{ f }}" class="text-nowrap" {% if model %}
onclick="window.location='{{ url_for('main.' + model + '_item', id=obj.id) }}'" {% endif %}>{{ getp(obj, f) if
getp(obj, f) is not none else '-' }}</td>
{%- endfor -%}
</tr>
{%- else -%}
@ -32,101 +34,133 @@
{%- endfor -%}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
{#
<nav id="pager">
{% if page > 1 %}
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page=1&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML">First</button>
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ page-1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" rel="prev" hx-swap="innerHTML">Prev</button>
{% endif %}
<span>Page {{ page }} / {{ pages }}</span>
{% if page < pages %} <button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" rel="next" hx-swap="innerHTML">Next</button>
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ pages }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML">Last</button>
{% endif %}
</nav>
#}
{% set p = page|int %}
{% set pg = pages|int %}
{% set prev = [1, p-1]|max %}
{% set nxt = [pg, p+1]|min %}
<nav class="mt-3" aria-label="Pagination">
<ul class="pagination justify-content-center bg-white">
<li class="page-item{{ ' disabled' if p <= 1 }}">
<a class="page-link"
hx-get="/ui/{{ model }}/frag/rows?page={{ prev }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ prev }}'">
Prev
</a>
</li>
<li class="page-item{{ ' disabled' if p >= pg }}">
<a class="page-link"
hx-get="/ui/{{ model }}/frag/rows?page={{ nxt }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ nxt }}'">
Next
</a>
</li>
</ul>
<span>Page {{ p }} / {{ pg }}</span>
</nav>
{# helper: build hx-get URL with shared params #}
{% macro _rows_url(model, page, per_page, sort, filters, fields_csv) -%}
/ui/{{ model }}/frag/rows
?page={{ page }}&per_page={{ per_page }}
{%- if sort %}&sort={{ sort }}{% endif -%}
{%- if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif -%}
{%- for k, v in (filters or {}).items() %}&{{ k }}={{ v|urlencode }}{% endfor -%}
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif
-%}>
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
{# helper: render one page link or a disabled/current state #}
{% macro _page_li(model, label, page, current_page, per_page, sort, filters, fields_csv, attrs='') -%}
{% if page is none %}
<li class="page-item disabled"><span class="page-link">{{ label }}</span></li>
{% elif page == current_page and label is string and label|int(default=None) is none %}
{# non-numeric current like "…" shouldn't happen, but be safe #}
<li class="page-item active" aria-current="page"><span class="page-link">{{ label }}</span></li>
{% elif page == current_page %}
<li class="page-item active" aria-current="page">
<span class="page-link">{{ label }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" hx-get="{{ _rows_url(model, page, per_page, sort, filters, fields_csv) }}" hx-target="#rows"
hx-swap="innerHTML" data-page="{{ page }}" {{ attrs|safe }}>
{{ label }}
</a>
</li>
{% endif %}
{%- endmacro %}
{%- for f in schema -%}
<div class="field" data-name="{{ f.name }}">
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
{%- if f.type == "textarea" -%}
<textarea id="{{ fid }}" name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %}
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
<option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}</option>
{% if f.multiple %}
{% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
{% endfor %}
{% else %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
{% endfor %}
{% endif %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="hidden" name="{{ f.name }}" value="0">
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
{%- endif -%}
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
{% set p = page|int %}
{% set pg = pages|int %}
{% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %}
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
{%- endmacro %}
{# window of pages around current #}
{% set win = 2 %}
{% set left = [2, p - win]|max %}
{% set right = [pg - 1, p + win]|min %}
<nav class="mt-3" aria-label="Pagination">
<ul class="pagination justify-content-center bg-white user-select-none" style="cursor: pointer;">
{{ _page_li(model, 'Prev', (None if p <= 1 else prev), p, per_page, sort, filters, fields_csv, 'rel="prev"'
) }} {# always show page 1 #} {{ _page_li(model, 1, 1, p, per_page, sort, filters, fields_csv) }} {#
left ellipsis only if gap> 1 #}
{% if left > 2 %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{# middle window inclusive #}
{% for n in range(left, right + 1) %}
{{ _page_li(model, n, n, p, per_page, sort, filters, fields_csv) }}
{% endfor %}
{# right ellipsis only if gap > 1 #}
{% if right < pg - 1 %} <li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{# last page if more than one total #}
{% if pg > 1 %}
{{ _page_li(model, pg, pg, p, per_page, sort, filters, fields_csv) }}
{% endif %}
{{ _page_li(model, 'Next', (None if p >= pg else nxt), p, per_page, sort, filters, fields_csv,
'rel="next"') }}
</ul>
</nav>
{# keep hidden pager-state in sync with one handler instead of inline click spam #}
<script>
document.currentScript?.previousElementSibling?.addEventListener?.('click', function (e) {
const a = e.target.closest('a.page-link');
if (!a) return;
const targetPage = a.getAttribute('data-page');
if (!targetPage) return;
const inp = document.querySelector('#pager-state input[name=page]');
if (inp) inp.value = targetPage;
}, { capture: true });
</script>
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%-
endif -%}>
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
{%- for f in schema -%}
<div class="field" data-name="{{ f.name }}">
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
{%- if f.type == "textarea" -%}
<textarea id="{{ fid }}" name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %}
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
<option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}
</option>
{% if f.multiple %}
{% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
{% endfor %}
{% else %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
{% endfor %}
{% endif %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="hidden" name="{{ f.name }}" value="0">
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
{%- endif -%}
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
{%- endmacro %}

View file

@ -1,2 +1,2 @@
{% import "crudkit/_macros.html" as ui %}
{{ ui.rows(items, fields, getp=getp) }}
{{ ui.rows(items, fields, getp=getp, model=model) }}

View file

@ -120,7 +120,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
s = session(); svc = CrudService(s, default_eager_policy)
rows, _ = svc.list(Model, spec)
html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp)
html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp, model=model)
return html

View file

@ -17,7 +17,7 @@ def list_worklog():
return render_template(
'table.html',
header=worklog_headers,
model_name='worklog',
model_name='work_log',
title="Work Log",
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
entry_route='worklog_item',