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 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]: def default_eager_policy(Model, expand: List[str]) -> List[Load]:
""" """
Heuristic: Heuristic:
- many-to-one or one-to-one: joinedload - many-to-one / one-to-one: joinedload
- one-to-many or many-to-many: selectinload - one-to-many / many-to-many: selectinload
Only for fields explicitly requested in ?expand= Accepts dotted paths like "author.publisher".
""" """
opts = [] if not expand:
mapper = class_mapper(Model) return []
rels = {r.key: r for r in mapper.relationships}
for name in expand: opts: List[Load] = []
rel: RelationshipProperty = rels.get(name)
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: if not rel:
continue continue # silently skip bad names
if rel.uselist: attr = getattr(current_model, parts[0])
opts.append(selectinload(name)) loader: Load = selectinload(attr) if rel.uselist else joinedload(attr)
else: current_model = rel.mapper.class_
opts.append(joinedload(name))
# 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 return opts

View file

@ -1,86 +1,93 @@
{% macro options(items, value_attr="id", label_path="name", getp=None) -%} {% macro options(items, value_attr="id", label_path="name", getp=None) -%}
{%- for obj in items -%} {%- for obj in items -%}
<option value="{{ getp(obj, value_attr) }}">{{ getp(obj, label_path) }}</option> <option value="{{ getp(obj, value_attr) }}">{{ getp(obj, label_path) }}</option>
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%} {% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%}
{%- for obj in items -%} {%- for obj in items -%}
<li data-id="{{ obj.id }}"> <li data-id="{{ obj.id }}">
<div class="li-main">{{ getp(obj, label_path) }}</div> <div class="li-main">{{ getp(obj, label_path) }}</div>
{%- if sublabel_path %} {%- if sublabel_path %}
<div class="li-sub">{{ getp(obj, sublabel_path) }}</div> <div class="li-sub">{{ getp(obj, sublabel_path) }}</div>
{%- endif %} {%- endif %}
</li> </li>
{%- else -%} {%- else -%}
<li class="empty"><em>No results.</em></li> <li class="empty"><em>No results.</em></li>
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{% macro rows(items, fields, getp=None) -%} {% macro rows(items, fields, getp=None) -%}
{%- for obj in items -%} {%- for obj in items -%}
<tr id="row-{{ obj.id }}"> <tr id="row-{{ obj.id }}">
{%- for f in fields -%} {%- for f in fields -%}
<td data-field="{{ f }}">{{ getp(obj, f) }}</td> <td data-field="{{ f }}">{{ getp(obj, f) }}</td>
{%- endfor -%}
</tr>
{%- else -%}
<tr><td colspan="{{ fields|length }}"><em>No results.</em></td></tr>
{%- endfor -%} {%- endfor -%}
</tr>
{%- else -%}
<tr>
<td colspan="{{ fields|length }}"><em>No results.</em></td>
</tr>
{%- endfor -%}
{%- endmacro %} {%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters) -%} {% macro pager(model, page, pages, per_page, sort, filters) -%}
<nav class="pager"> <nav class="pager">
{%- if page > 1 -%} {%- 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 %}" <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> 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 -%} {%- endif -%}
<span>Page {{ page }} / {{ pages }}</span> </nav>
{%- 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>
{%- endmacro %} {%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" <form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
{%- if hx %} hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif
hx-{{ "patch" if obj_id else "post" }}="{{ action }}" -%}>
hx-target="closest dialog, #modal-body, body" {%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
hx-swap="innerHTML" {%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
{%- endif -%}> <input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
{%- 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 -%}
{%- 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="actions">
<div class="field" data-name="{{ f.name }}"> <button type="submit">Save</button>
<label>{{ f.name|replace("_", " ")|title }} </div>
{%- if f.type == "textarea" -%} </form>
<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>
{%- endmacro %} {%- endmacro %}

View file

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

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 __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Mapper, RelationshipProperty from sqlalchemy.orm import Mapper, RelationshipProperty
from sqlalchemy.sql.schema import Column from sqlalchemy.sql.schema import Column
@ -17,6 +18,9 @@ def _guess_label_attr(model_cls) -> str:
return cand return cand
return "id" return "id"
def _pretty(label: str) -> str:
return label.replace("_", " ").title()
def _column_input_type(col: Column) -> str: def _column_input_type(col: Column) -> str:
t = col.type t = col.type
if isinstance(t, (String, Unicode)): 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 [(v, v) for v in t.enums]
return None return None
def build_form_schema( def build_form_schema(model_cls, session, obj=None, *, include=None, exclude=None, fk_limit=200):
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?}
"""
mapper: Mapper = inspect(model_cls) mapper: Mapper = inspect(model_cls)
include = set(include or []) include = set(include or [])
exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"}) exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"})
fields = []
fields: List[Dict[str, Any]] = [] fields: List[Dict[str, Any]] = []
fk_map = {} fk_map = {}
for rel in mapper.relationships: for rel in mapper.relationships:
if rel.primaryjoin is None:
continue
for lc in rel.local_columns: 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: for attr in mapper.column_attrs:
col: Column = attr.columns[0] col = attr.columns[0]
name = col.key name = col.key
if include and name not in include: if include and name not in include:
continue continue
if name in exclude: if name in exclude:
continue continue
field: Dict[str, Any] = { field = {
"name": name, "name": name,
"type": _column_input_type(col), "type": _column_input_type(col),
"required": not col.nullable, "required": not col.nullable,
"value": getattr(obj, name, None) if obj is not None else None, "value": getattr(obj, name, None) if obj is not None else None,
"placeholder": "", "placeholder": "",
"help": "", "help": "",
# default label from column name
"label": _pretty(name),
} }
enum_choices = _enum_choices(col) enum_choices = _enum_choices(col)
@ -95,20 +90,48 @@ def build_form_schema(
field["choices"] = enum_choices field["choices"] = enum_choices
if name in fk_map: if name in fk_map:
rel: RelationshipProperty = fk_map[name] rel = fk_map[name]
target = rel.mapper.class_ target = rel.mapper.class_
label_attr = _guess_label_attr(target) label_attr = _guess_label_attr(target)
q = session.query(target).limit(fk_limit) rows = session.execute(select(target).limit(fk_limit)).scalars().all()
choices = [(getattr(row, "id"), getattr(row, label_attr)) for row in q.all()]
field["type"] = "select" 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["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 field["maxlength"] = col.type.length
fields.append(field) 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: 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 return fields

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
from math import ceil 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.orm import scoped_session
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric
from ..dsl import QuerySpec from ..dsl import QuerySpec
from ..service import CrudService 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 cur = getattr(cur, part, None) if cur is not None else None
return cur 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") @bp.get("/<model>/frag/options")
def options(model): def options(model):
Model = registry.get(model) or abort(404) 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 hx = request.args.get("hx", type=int) == 1
return render_template("form.html", model=model, obj=obj, schema=schema, hx=hx) 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 return bp

View file

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