Added in all the things related to the HTML layer.
This commit is contained in:
parent
ed1038e5dd
commit
34928e794d
8 changed files with 331 additions and 0 deletions
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .ui_fragments import make_fragments_blueprint
|
||||||
|
|
||||||
|
__all__ = ["make_fragments_blueprint"]
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from sqlalchemy.inspection import inspect
|
||||||
|
from sqlalchemy.orm import Mapper, RelationshipProperty
|
||||||
|
from sqlalchemy.sql.schema import Column
|
||||||
|
from sqlalchemy.sql.sqltypes import (
|
||||||
|
String, Text, Unicode, UnicodeText,
|
||||||
|
Integer, BigInteger, SmallInteger, Float, Numeric, Boolean,
|
||||||
|
Date, DateTime, Time, JSON, Enum
|
||||||
|
)
|
||||||
|
|
||||||
|
CANDIDATE_LABELS = ("name", "title", "label", "display_name")
|
||||||
|
|
||||||
|
def _guess_label_attr(model_cls) -> str:
|
||||||
|
for cand in CANDIDATE_LABELS:
|
||||||
|
if hasattr(model_cls, cand):
|
||||||
|
return cand
|
||||||
|
return "id"
|
||||||
|
|
||||||
|
def _column_input_type(col: Column) -> str:
|
||||||
|
t = col.type
|
||||||
|
if isinstance(t, (String, Unicode)):
|
||||||
|
return "text"
|
||||||
|
if isinstance(t, (Text, UnicodeText, JSON)):
|
||||||
|
return "textarea"
|
||||||
|
if isinstance(t, (Integer, SmallInteger, BigInteger)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(t, (Float, Numeric)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(t, Boolean):
|
||||||
|
return "checkbox"
|
||||||
|
if isinstance(t, Date):
|
||||||
|
return "date"
|
||||||
|
if isinstance(t, DateTime):
|
||||||
|
return "datetime-local"
|
||||||
|
if isinstance(t, Time):
|
||||||
|
return "time"
|
||||||
|
if isinstance(t, Enum):
|
||||||
|
return "select"
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
def _enum_choices(col: Column) -> Optional[List[Tuple[str, str]]]:
|
||||||
|
t = col.type
|
||||||
|
if isinstance(t, Enum):
|
||||||
|
if t.enum_class:
|
||||||
|
return [(e.name, e.value) for e in t.enum_class]
|
||||||
|
if t.enums:
|
||||||
|
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?}
|
||||||
|
"""
|
||||||
|
mapper: Mapper = inspect(model_cls)
|
||||||
|
include = set(include or [])
|
||||||
|
exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for attr in mapper.column_attrs:
|
||||||
|
col: Column = attr.columns[0]
|
||||||
|
name = col.key
|
||||||
|
if include and name not in include:
|
||||||
|
continue
|
||||||
|
if name in exclude:
|
||||||
|
continue
|
||||||
|
|
||||||
|
field: Dict[str, Any] = {
|
||||||
|
"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": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
enum_choices = _enum_choices(col)
|
||||||
|
if enum_choices:
|
||||||
|
field["type"] = "select"
|
||||||
|
field["choices"] = enum_choices
|
||||||
|
|
||||||
|
if name in fk_map:
|
||||||
|
rel: RelationshipProperty = 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()]
|
||||||
|
field["type"] = "select"
|
||||||
|
field["choices"] = choices
|
||||||
|
field["rel"] = {"target": target.__name__, "label_attr": label_attr}
|
||||||
|
|
||||||
|
if hasattr(col.type, "length") and col.type.length:
|
||||||
|
field["maxlength"] = col.type.length
|
||||||
|
|
||||||
|
fields.append(field)
|
||||||
|
|
||||||
|
if include:
|
||||||
|
fields.sort(key=lambda f: list(include).index(f["name"]) if f["name"] in include else 10**9)
|
||||||
|
return fields
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
from math import ceil
|
||||||
|
from flask import Blueprint, request, render_template, abort
|
||||||
|
from sqlalchemy.orm import scoped_session
|
||||||
|
from sqlalchemy.inspection import inspect
|
||||||
|
|
||||||
|
from ..dsl import QuerySpec
|
||||||
|
from ..service import CrudService
|
||||||
|
from ..eager import default_eager_policy
|
||||||
|
from .type_map import build_form_schema
|
||||||
|
|
||||||
|
def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, name="frags"):
|
||||||
|
"""
|
||||||
|
HTML fragments for HTMX/Alpine. No base pages. Pure partials:
|
||||||
|
GET /<model>/frag/options -> <option>...</option>
|
||||||
|
GET /<model>/frag/lis -> <li>...</li>
|
||||||
|
GET /<model>/frag/rows -> <tr>...</tr> + pager markup if wanted
|
||||||
|
GET /<model>/frag/form -> <form>...</form> (auto-generated)
|
||||||
|
"""
|
||||||
|
bp = Blueprint(name, __name__, template_folder="../templates/crudkit")
|
||||||
|
def session(): return scoped_session(db_session_factory)()
|
||||||
|
|
||||||
|
def _parse_filters(args):
|
||||||
|
reserved = {"page", "per_page", "sort", "expand", "fields", "value", "label", "label_tpl", "fields_csv", "li_label", "li_sublabel"}
|
||||||
|
out = {}
|
||||||
|
for k, v in args.items():
|
||||||
|
if k not in reserved and v != "":
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _paths_from_csv(csv: str) -> List[str]:
|
||||||
|
return [p.strip() for p in csv.split(",") if p.strip()]
|
||||||
|
|
||||||
|
def _collect_expand_from_paths(paths: List[str]) -> List[str]:
|
||||||
|
rels = set()
|
||||||
|
for p in paths:
|
||||||
|
bits = p.split(".")
|
||||||
|
if len(bits) > 1:
|
||||||
|
rels.add(bits[0])
|
||||||
|
return list(rels)
|
||||||
|
|
||||||
|
def _getp(obj, path: str):
|
||||||
|
cur = obj
|
||||||
|
for part in path.split("."):
|
||||||
|
cur = getattr(cur, part, None) if cur is not None else None
|
||||||
|
return cur
|
||||||
|
|
||||||
|
@bp.get("/<model>/frag/options")
|
||||||
|
def options(model):
|
||||||
|
Model = registry.get(model) or abort(404)
|
||||||
|
value_attr = request.args.get("value", default="id")
|
||||||
|
label_path = request.args.get("label", default="name")
|
||||||
|
filters = _parse_filters(request.args)
|
||||||
|
|
||||||
|
expand = _collect_expand_from_paths([label_path])
|
||||||
|
spec = QuerySpec(filters=filters, order_by=[], page=None, per_page=None, expand=expand)
|
||||||
|
s = session(); svc = CrudService(s, default_eager_policy)
|
||||||
|
items, _ = svc.list(Model, spec)
|
||||||
|
|
||||||
|
return render_template("options.html", items=items, value_attr=value_attr, label_path=label_path, getp=_getp)
|
||||||
|
|
||||||
|
@bp.get("/<model>/frag/lis")
|
||||||
|
def lis(model):
|
||||||
|
Model = registry.get(model) or abort(404)
|
||||||
|
label_path = request.args.get("li_label", default="name")
|
||||||
|
sublabel_path = request.args.get("li_sublabel")
|
||||||
|
filters = _parse_filters(request.args)
|
||||||
|
sort = request.args.get("sort")
|
||||||
|
page = request.args.get("page", type=int)
|
||||||
|
per_page = request.args.get("per_page", type=int)
|
||||||
|
|
||||||
|
expand = _collect_expand_from_paths([p for p in (label_path, sublabel_path) if p])
|
||||||
|
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
|
||||||
|
s = session(); svc = CrudService(s, default_eager_policy)
|
||||||
|
rows, total = svc.list(Model, spec)
|
||||||
|
pages = (ceil(total / per_page) if page and per_page else 1)
|
||||||
|
return render_template("lis.html", items=rows, label_path=label_path, sublabel_path=sublabel_path, page=page or 1, per_page=per_page or 1, total=total, model=model, sort=sort, filters=filters, getp=_getp)
|
||||||
|
|
||||||
|
@bp.get("/<model>/frag/rows")
|
||||||
|
def rows(model):
|
||||||
|
Model = registry.get(model) or abort(404)
|
||||||
|
fields_csv = request.args.get("fields_csv") or "id,name"
|
||||||
|
fields = _paths_from_csv(fields_csv)
|
||||||
|
filters = _parse_filters(request.args)
|
||||||
|
sort = request.args.get("sort")
|
||||||
|
page = request.args.get("page", type=int) or 1
|
||||||
|
per_page = request.args.get("per_page", type=int) or 20
|
||||||
|
|
||||||
|
expand = _collect_expand_from_paths(fields)
|
||||||
|
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
|
||||||
|
s = session(); svc = CrudService(s, default_eager_policy)
|
||||||
|
rows, total = svc.list(Model, spec)
|
||||||
|
pages = max(1, ceil(total / per_page))
|
||||||
|
return render_template("rows.html", items=rows, fields=fields, page=page, pages=pages, per_page=per_page, total=total, model=model, sort=sort, filters=filters, getp=_getp)
|
||||||
|
|
||||||
|
@bp.get("/<model>/frag/form")
|
||||||
|
def form(model):
|
||||||
|
Model = registry.get(model) or abort(404)
|
||||||
|
id = request.args.get("id", type=int)
|
||||||
|
include_csv = request.args.get("include")
|
||||||
|
include = [s.strip() for s in include_csv.split(",")] if include_csv else None
|
||||||
|
|
||||||
|
s = session(); svc = CrudService(s, default_eager_policy)
|
||||||
|
obj = svc.get(Model, id) if id else None
|
||||||
|
|
||||||
|
schema = build_form_schema(Model, s, obj=obj, include=include)
|
||||||
|
|
||||||
|
hx = request.args.get("hx", type=int) == 1
|
||||||
|
return render_template("form.html", model=model, obj=obj, schema=schema, hx=hx)
|
||||||
|
return bp
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
{% macro options(items, value_attr="id", label_path="name", getp=None) -%}
|
||||||
|
{%- for obj in items -%}
|
||||||
|
<option value="{{ getattr(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 -%}
|
||||||
|
{% 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>
|
||||||
|
{%- 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>
|
||||||
|
{%- 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>
|
||||||
|
{%- 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 -%}
|
||||||
|
|
||||||
|
|
||||||
|
{%- 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>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% import "crudkit/_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 %}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% import "crudkit/_macros.html" as ui %}
|
||||||
|
{{ ui.lis(items, label_path=label_path, sublabel_path=sublabel_path, getp=getp) }}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{# Renders only <option>...</option> rows #}
|
||||||
|
{% import "crudkit/_macros.html" as ui %}
|
||||||
|
{{ ui.options(items, value_attr=value_attr, label_path=label_path, getp=getp) }}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{% import "crudkit/_macros.html" as ui %}
|
||||||
|
{{ ui.rows(items, fields, getp=getp) }}
|
||||||
|
{{ ui.pager(model, page, pages, per_page, sort, filters) }}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue