Some additional HTML layer stuff.
This commit is contained in:
parent
d6ba934955
commit
026f7aff64
8 changed files with 302 additions and 114 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</tr>
|
</tr>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<tr><td colspan="{{ fields|length }}"><em>No results.</em></td></tr>
|
<tr>
|
||||||
|
<td colspan="{{ fields|length }}"><em>No results.</em></td>
|
||||||
|
</tr>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|
@ -36,45 +38,50 @@
|
||||||
hx-target="#rows" hx-push-url="true">Prev</a>
|
hx-target="#rows" hx-push-url="true">Prev</a>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
<span>Page {{ page }} / {{ pages }}</span>
|
<span>Page {{ page }} / {{ pages }}</span>
|
||||||
{%- if page < pages -%}
|
{%- if page < pages -%} <a
|
||||||
<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-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>
|
hx-target="#rows" hx-push-url="true">Next</a>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</nav>
|
</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"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
{%- endif -%}>
|
|
||||||
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% 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 -%}
|
{%- 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 -%}
|
{%- for f in schema -%}
|
||||||
<div class="field" data-name="{{ f.name }}">
|
<div class="field" data-name="{{ f.name }}">
|
||||||
<label>{{ f.name|replace("_", " ")|title }}
|
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
|
||||||
|
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
|
||||||
{%- if f.type == "textarea" -%}
|
{%- if f.type == "textarea" -%}
|
||||||
<textarea name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %} maxlength="{{ f.maxlength }}"{% endif %}>{{ f.value or "" }}</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" -%}
|
{%- elif f.type == "select" -%}
|
||||||
<select name="{{ f.name }}" {%- if f.required %} required{% endif %}>
|
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
|
||||||
<option value="">---</option>
|
<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 %}
|
{% for val, lbl in f.choices %}
|
||||||
<option value="{{ val }}" {{ "selected" if (f.value == val) else "" }}>{{ lbl }}</option>
|
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for val, lbl in f.choices %}
|
||||||
|
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
{%- elif f.type == "checkbox" -%}
|
{%- elif f.type == "checkbox" -%}
|
||||||
<input type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
|
<input type="hidden" name="{{ f.name }}" value="0">
|
||||||
|
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<input type="{{ f.type }}" name="{{ f.name }}"
|
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
|
||||||
value="{{ f.value if f.value is not none else '' }}"
|
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
|
||||||
{%- if f.required %} required{% endif %}
|
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
|
||||||
{%- if f.maxlength %} maxlength="{{ f.maxlength }}"{% endif %}>
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</label>
|
|
||||||
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
|
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
|
||||||
</div>
|
</div>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
2
crudkit/html/templates/crudkit/row.html
Normal file
2
crudkit/html/templates/crudkit/row.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% import "_macros.html" as ui %}
|
||||||
|
{{ ui.rows([obj], fields, getp=getp) }}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
17
example_app/templates/demo.html
Normal file
17
example_app/templates/demo.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue