Hooray for pager working now.
This commit is contained in:
parent
52bd0d4c91
commit
8100d221a1
5 changed files with 186 additions and 119 deletions
|
|
@ -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.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 don’t support eagerload
|
||||||
|
return rel.lazy != "dynamic"
|
||||||
|
|
||||||
|
def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]:
|
||||||
"""
|
"""
|
||||||
Heuristic:
|
Heuristic:
|
||||||
- many-to-one / one-to-one: joinedload
|
- many-to-one / one-to-one: joinedload
|
||||||
|
|
@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]:
|
||||||
if not expand:
|
if not expand:
|
||||||
return []
|
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] = []
|
opts: List[Load] = []
|
||||||
|
seen: Set[tuple] = set()
|
||||||
|
|
||||||
for path in expand:
|
for path in sorted(pruned):
|
||||||
parts = path.split(".")
|
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_model = Model
|
||||||
current_inspect = inspect(current_model)
|
# build the chain incrementally
|
||||||
|
loader: Load | None = None
|
||||||
|
ok = True
|
||||||
|
|
||||||
# first hop
|
for i, name in enumerate(parts):
|
||||||
rel = current_inspect.relationships.get(parts[0])
|
rel = _rel(current_model, name)
|
||||||
if not rel:
|
if not rel or not _is_expandable(rel):
|
||||||
continue # silently skip bad names
|
ok = False
|
||||||
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:
|
|
||||||
break
|
break
|
||||||
attr = getattr(current_model, name)
|
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_
|
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
|
return opts
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro rows(items, fields, getp=None) -%}
|
{% macro rows(items, fields, getp=None, model=None) -%}
|
||||||
{%- for obj in items -%}
|
{%- for obj in items -%}
|
||||||
<tr id="row-{{ obj.id }}" style="cursor: pointer;">
|
<tr id="row-{{ obj.id }}" style="cursor: pointer;">
|
||||||
{%- for f in fields -%}
|
{%- 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 -%}
|
{%- endfor -%}
|
||||||
</tr>
|
</tr>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
|
|
@ -32,101 +34,133 @@
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
|
{# helper: build hx-get URL with shared params #}
|
||||||
{#
|
{% macro _rows_url(model, page, per_page, sort, filters, fields_csv) -%}
|
||||||
<nav id="pager">
|
/ui/{{ model }}/frag/rows
|
||||||
{% if page > 1 %}
|
?page={{ page }}&per_page={{ per_page }}
|
||||||
<button type="button"
|
{%- if sort %}&sort={{ sort }}{% endif -%}
|
||||||
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 %}"
|
{%- if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif -%}
|
||||||
hx-target="#rows" hx-swap="innerHTML">First</button>
|
{%- for k, v in (filters or {}).items() %}&{{ k }}={{ v|urlencode }}{% endfor -%}
|
||||||
<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>
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
|
{# helper: render one page link or a disabled/current state #}
|
||||||
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
|
{% macro _page_li(model, label, page, current_page, per_page, sort, filters, fields_csv, attrs='') -%}
|
||||||
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif
|
{% if page is none %}
|
||||||
-%}>
|
<li class="page-item disabled"><span class="page-link">{{ label }}</span></li>
|
||||||
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
|
{% elif page == current_page and label is string and label|int(default=None) is none %}
|
||||||
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
|
{# non-numeric current like "…" shouldn't happen, but be safe #}
|
||||||
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
|
<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 -%}
|
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
|
||||||
<div class="field" data-name="{{ f.name }}">
|
{% set p = page|int %}
|
||||||
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
|
{% set pg = pages|int %}
|
||||||
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
|
{% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %}
|
||||||
{%- 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">
|
{# window of pages around current #}
|
||||||
<button type="submit">Save</button>
|
{% set win = 2 %}
|
||||||
</div>
|
{% set left = [2, p - win]|max %}
|
||||||
</form>
|
{% set right = [pg - 1, p + win]|min %}
|
||||||
{%- endmacro %}
|
|
||||||
|
<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 %}
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
{% import "crudkit/_macros.html" as ui %}
|
{% import "crudkit/_macros.html" as ui %}
|
||||||
{{ ui.rows(items, fields, getp=getp) }}
|
{{ ui.rows(items, fields, getp=getp, model=model) }}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
|
||||||
s = session(); svc = CrudService(s, default_eager_policy)
|
s = session(); svc = CrudService(s, default_eager_policy)
|
||||||
rows, _ = svc.list(Model, spec)
|
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
|
return html
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ def list_worklog():
|
||||||
return render_template(
|
return render_template(
|
||||||
'table.html',
|
'table.html',
|
||||||
header=worklog_headers,
|
header=worklog_headers,
|
||||||
model_name='worklog',
|
model_name='work_log',
|
||||||
title="Work Log",
|
title="Work Log",
|
||||||
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
|
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
|
||||||
entry_route='worklog_item',
|
entry_route='worklog_item',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue