Some additional HTML layer stuff.

This commit is contained in:
Yaro Kasear 2025-08-26 15:30:55 -05:00
parent d6ba934955
commit 026f7aff64
8 changed files with 302 additions and 114 deletions

View file

@ -1,22 +1,42 @@
from typing import List
from sqlalchemy.orm import RelationshipProperty, selectinload, joinedload, Load, class_mapper
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Load, joinedload, selectinload
def default_eager_policy(Model, expand: List[str]) -> List[Load]:
"""
Heuristic:
- many-to-one or one-to-one: joinedload
- one-to-many or many-to-many: selectinload
Only for fields explicitly requested in ?expand=
- many-to-one / one-to-one: joinedload
- one-to-many / many-to-many: selectinload
Accepts dotted paths like "author.publisher".
"""
opts = []
mapper = class_mapper(Model)
rels = {r.key: r for r in mapper.relationships}
for name in expand:
rel: RelationshipProperty = rels.get(name)
if not expand:
return []
opts: List[Load] = []
for path in expand:
parts = path.split(".")
current_model = Model
current_inspect = inspect(current_model)
# first hop
rel = current_inspect.relationships.get(parts[0])
if not rel:
continue
if rel.uselist:
opts.append(selectinload(name))
else:
opts.append(joinedload(name))
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:
break
attr = getattr(current_model, name)
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
current_model = rel.mapper.class_
opts.append(loader)
return opts

View file

@ -1,86 +1,93 @@
{% macro options(items, value_attr="id", label_path="name", getp=None) -%}
{%- for obj in items -%}
<option value="{{ getp(obj, value_attr) }}">{{ getp(obj, label_path) }}</option>
{%- endfor -%}
{%- for obj in items -%}
<option value="{{ getp(obj, value_attr) }}">{{ getp(obj, label_path) }}</option>
{%- endfor -%}
{% endmacro %}
{% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%}
{%- for obj in items -%}
<li data-id="{{ obj.id }}">
<div class="li-main">{{ getp(obj, label_path) }}</div>
{%- if sublabel_path %}
<div class="li-sub">{{ getp(obj, sublabel_path) }}</div>
{%- endif %}
</li>
{%- else -%}
<li class="empty"><em>No results.</em></li>
{%- endfor -%}
{%- for obj in items -%}
<li data-id="{{ obj.id }}">
<div class="li-main">{{ getp(obj, label_path) }}</div>
{%- if sublabel_path %}
<div class="li-sub">{{ getp(obj, sublabel_path) }}</div>
{%- endif %}
</li>
{%- else -%}
<li class="empty"><em>No results.</em></li>
{%- endfor -%}
{% endmacro %}
{% macro rows(items, fields, getp=None) -%}
{%- for obj in items -%}
<tr id="row-{{ obj.id }}">
{%- for f in fields -%}
<td data-field="{{ f }}">{{ getp(obj, f) }}</td>
{%- endfor -%}
</tr>
{%- else -%}
<tr><td colspan="{{ fields|length }}"><em>No results.</em></td></tr>
{%- for obj in items -%}
<tr id="row-{{ obj.id }}">
{%- for f in fields -%}
<td data-field="{{ f }}">{{ getp(obj, f) }}</td>
{%- endfor -%}
</tr>
{%- else -%}
<tr>
<td colspan="{{ fields|length }}"><em>No results.</em></td>
</tr>
{%- endfor -%}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters) -%}
<nav class="pager">
{%- if page > 1 -%}
<a hx-get="/{{ model }}/frag/rows?page={{ page-1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Prev</a>
<nav class="pager">
{%- if page > 1 -%}
<a hx-get="/{{ model }}/frag/rows?page={{ page-1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Prev</a>
{%- endif -%}
<span>Page {{ page }} / {{ pages }}</span>
{%- if page < pages -%} <a
hx-get="/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Next</a>
{%- endif -%}
<span>Page {{ page }} / {{ pages }}</span>
{%- if page < pages -%}
<a hx-get="/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{v}}{% endfor %}"
hx-target="#rows" hx-push-url="true">Next</a>
{%- endif -%}
</nav>
</nav>
{%- 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"
{%- 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 -%}
<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 -%}
{%- for f in schema -%}
<div class="field" data-name="{{ f.name }}">
<label>{{ f.name|replace("_", " ")|title }}
{%- if f.type == "textarea" -%}
<textarea name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %} maxlength="{{ f.maxlength }}"{% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<select name="{{ f.name }}" {%- if f.required %} required{% endif %}>
<option value="">---</option>
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ "selected" if (f.value == val) else "" }}>{{ lbl }}</option>
{% endfor %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input 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 -%}
</label>
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
{%- endmacro %}

View file

@ -1,9 +1,3 @@
{% import "_macros.html" as ui %}
{# The action points at your JSON endpoints. Adjust 'crud' if you named it differently. #}
{% if obj %}
{% set action = url_for('crud.update_item', model=model, id=obj.id) %}
{{ ui.form(schema, action, method="POST", obj_id=obj.id, hx=hx, csrf_token=csrf_token if csrf_token is defined else None) }}
{% else %}
{% set action = url_for('crud.create_item', model=model) %}
{{ ui.form(schema, action, method="POST", obj_id=None, hx=hx, csrf_token=csrf_token if csrf_token is defined else None) }}
{% endif %}
{% set action = url_for('frags.save', model=model) %}
{{ ui.form(schema, action, method="POST", obj_id=obj.id if obj else None, hx=true) }}

View file

@ -0,0 +1,2 @@
{% import "_macros.html" as ui %}
{{ ui.rows([obj], fields, getp=getp) }}

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Mapper, RelationshipProperty
from sqlalchemy.sql.schema import Column
@ -17,6 +18,9 @@ def _guess_label_attr(model_cls) -> str:
return cand
return "id"
def _pretty(label: str) -> str:
return label.replace("_", " ").title()
def _column_input_type(col: Column) -> str:
t = col.type
if isinstance(t, (String, Unicode)):
@ -48,45 +52,36 @@ def _enum_choices(col: Column) -> Optional[List[Tuple[str, str]]]:
return [(v, v) for v in t.enums]
return None
def build_form_schema(
model_cls, session, obj=None, *,
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
fk_limit: int = 200
) -> List[Dict[str, Any]]:
"""
Returns a list of field dicts:
{name, type, required, value, placeholder, help, choices?, rel?}
"""
def build_form_schema(model_cls, session, obj=None, *, include=None, exclude=None, fk_limit=200):
mapper: Mapper = inspect(model_cls)
include = set(include or [])
exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"})
fields = []
fields: List[Dict[str, Any]] = []
fk_map = {}
for rel in mapper.relationships:
if rel.primaryjoin is None:
continue
for lc in rel.local_columns:
if any(fk.column.table is rel.entity.entity for fk in lc.foreign_keys):
fk_map[lc.key] = rel
fk_map[lc.key] = rel
for attr in mapper.column_attrs:
col: Column = attr.columns[0]
col = attr.columns[0]
name = col.key
if include and name not in include:
continue
if name in exclude:
continue
field: Dict[str, Any] = {
field = {
"name": name,
"type": _column_input_type(col),
"required": not col.nullable,
"value": getattr(obj, name, None) if obj is not None else None,
"placeholder": "",
"help": "",
# default label from column name
"label": _pretty(name),
}
enum_choices = _enum_choices(col)
@ -95,20 +90,48 @@ def build_form_schema(
field["choices"] = enum_choices
if name in fk_map:
rel: RelationshipProperty = fk_map[name]
rel = fk_map[name]
target = rel.mapper.class_
label_attr = _guess_label_attr(target)
q = session.query(target).limit(fk_limit)
choices = [(getattr(row, "id"), getattr(row, label_attr)) for row in q.all()]
rows = session.execute(select(target).limit(fk_limit)).scalars().all()
field["type"] = "select"
field["choices"] = choices
field["choices"] = [(getattr(r, "id"), getattr(r, label_attr)) for r in rows]
field["rel"] = {"target": target.__name__, "label_attr": label_attr}
field["label"] = _pretty(rel.key)
if hasattr(col.type, "length") and col.type.length:
if getattr(col.type, "length", None):
field["maxlength"] = col.type.length
fields.append(field)
for rel in mapper.relationships:
if not rel.uselist or rel.secondary is None:
continue # only true many-to-many
if include and f"{rel.key}_ids" not in include:
continue
target = rel.mapper.class_
label_attr = _guess_label_attr(target)
choices = session.execute(select(target).limit(fk_limit)).scalars().all()
current = []
if obj is not None:
current = [getattr(x, "id") for x in getattr(obj, rel.key, []) or []]
fields.append({
"name": f"{rel.key}_ids", # e.g. "tags_ids"
"label": rel.key.replace("_"," ").title(),
"type": "select",
"multiple": True,
"required": False,
"choices": [(getattr(r,"id"), getattr(r,label_attr)) for r in choices],
"value": current, # list of selected IDs
"placeholder": f"Choose {rel.key.replace('_',' ').title()}",
"help": "",
})
if include:
fields.sort(key=lambda f: list(include).index(f["name"]) if f["name"] in include else 10**9)
order = list(include)
fields.sort(key=lambda f: order.index(f["name"]) if f["name"] in include else 10**9)
return fields

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from math import ceil
from flask import Blueprint, request, render_template, abort
from flask import Blueprint, request, render_template, abort, make_response
from sqlalchemy import select
from sqlalchemy.orm import scoped_session
from sqlalchemy.inspection import inspect
from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric
from ..dsl import QuerySpec
from ..service import CrudService
@ -46,6 +48,20 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
cur = getattr(cur, part, None) if cur is not None else None
return cur
def _extract_m2m_lists(Model, req_form) -> dict[str, list[int]]:
"""Return {'tags': [1,2]} for any <rel>_ids fields; caller removes keys from main form."""
mapper = inspect(Model)
out = {}
for rel in mapper.relationships:
if not rel.uselist or rel.secondary is None:
continue
key = f"{rel.key}_ids"
ids = req_form.getlist(key)
if ids is None:
continue
out[rel.key] = [int(i) for i in ids if i]
return out
@bp.get("/<model>/frag/options")
def options(model):
Model = registry.get(model) or abort(404)
@ -108,4 +124,110 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
hx = request.args.get("hx", type=int) == 1
return render_template("form.html", model=model, obj=obj, schema=schema, hx=hx)
def coerce_form_types(Model, data: dict) -> dict:
"""Turn HTML string inputs into the Python types your columns expect."""
mapper = inspect(Model)
for attr in mapper.column_attrs:
col = attr.columns[0]
name = col.key
if name not in data:
continue
v = data[name]
if v == "":
data[name] = None
continue
t = col.type
try:
if isinstance(t, Boolean):
data[name] = v in ("1", "true", "on", "yes", True)
elif isinstance(t, Integer):
data[name] = int(v)
elif isinstance(t, (Float, Numeric)):
data[name] = float(v)
elif isinstance(t, DateTime):
from datetime import datetime
data[name] = datetime.fromisoformat(v)
elif isinstance(t, Date):
from datetime import date
data[name] = date.fromisoformat(v)
except Exception:
# Leave as string; your validator can complain later.
pass
return data
@bp.post("/<model>/frag/save")
def save(model):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
# grab the raw form and fields to re-render
raw = request.form
form = raw.to_dict(flat=True)
fields_csv = form.pop("fields_csv", "id,name")
# many-to-many lists first
m2m = _extract_m2m_lists(Model, raw)
for rel_name in list(m2m.keys()):
form.pop(f"{rel_name}_ids", None)
# coerce primitives for regular columns
form = coerce_form_types(Model, form)
id_val = form.pop("id", None)
if id_val:
obj = svc.get(Model, int(id_val)) or abort(404)
svc.update(obj, form)
else:
obj = svc.create(Model, form)
# apply many-to-many selections
mapper = inspect(Model)
for rel_name, id_list in m2m.items():
rel = mapper.relationships[rel_name]
target = rel.mapper.class_
selected = []
if id_list:
selected = s.execute(select(target).where(target.id.in_(id_list))).scalars().all()
coll = getattr(obj, rel_name)
coll.clear()
coll.extend(selected)
s.commit()
rows_html = render_template(
"crudkit/row.html",
obj=obj,
fields=[p.strip() for p in fields_csv.split(",") if p.strip()],
getp=_getp,
)
resp = make_response(rows_html)
if id_val:
resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Updated"}}'
resp.headers["HX-Retarget"] = f"#row-{obj.id}"
resp.headers["HX-Reswap"] = "outerHTML"
else:
resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Created"}}'
resp.headers["HX-Retarget"] = "#rows"
resp.headers["HX-Reswap"] = "beforeend"
return resp
@bp.get("/_debug/<model>/schema")
def debug_model(model):
Model = registry[model]
from sqlalchemy.inspection import inspect
m = inspect(Model)
return {
"columns": [c.key for c in m.columns],
"relationships": [
{
"key": r.key,
"target": r.mapper.class_.__name__,
"uselist": r.uselist,
"local_cols": [c.key for c in r.local_columns],
} for r in m.relationships
],
}
return bp

View file

@ -18,6 +18,9 @@ def create_app():
Base.metadata.create_all(engine)
app.register_blueprint(make_json_blueprint(session_factory, registry), url_prefix="/api")
app.register_blueprint(make_fragments_blueprint(session_factory, registry), url_prefix="/ui")
@app.get("/demo")
def demo():
return render_template("demo.html")
return app
if __name__ == "__main__":

View file

@ -0,0 +1,17 @@
<!-- templates/demo.html -->
<!doctype html><meta charset="utf-8">
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<body>
<table class="table-auto w-full border">
<thead><tr><th class="px-3 py-2">ID</th><th class="px-3 py-2">Title</th><th class="px-3 py-2">Author</th><th></th></tr></thead>
<tbody id="rows"
hx-get="/ui/book/frag/rows?fields_csv=id,title,author.name&page=1&per_page=20"
hx-trigger="load" hx-target="this" hx-swap="innerHTML"></tbody>
</table>
<button hx-get="/ui/book/frag/form?hx=1&fields_csv=id,title,author.name"
hx-target="#modal-body" hx-swap="innerHTML"
onclick="document.getElementById('modal').showModal()">New Book</button>
<dialog id="modal"><div id="modal-body"></div></dialog>
</body>