diff --git a/crudkit/eager.py b/crudkit/eager.py index 7a6ee8b..f32efc2 100644 --- a/crudkit/eager.py +++ b/crudkit/eager.py @@ -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 diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html index 38a1a4e..f713e81 100644 --- a/crudkit/html/templates/crudkit/_macros.html +++ b/crudkit/html/templates/crudkit/_macros.html @@ -1,86 +1,93 @@ {% macro options(items, value_attr="id", label_path="name", getp=None) -%} - {%- for obj in items -%} - - {%- endfor -%} +{%- for obj in items -%} + +{%- endfor -%} {% endmacro %} {% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%} - {%- for obj in items -%} -
  • -
    {{ getp(obj, label_path) }}
    - {%- if sublabel_path %} -
    {{ getp(obj, sublabel_path) }}
    - {%- endif %} -
  • - {%- else -%} -
  • No results.
  • - {%- endfor -%} +{%- for obj in items -%} +
  • +
    {{ getp(obj, label_path) }}
    + {%- if sublabel_path %} +
    {{ getp(obj, sublabel_path) }}
    + {%- endif %} +
  • +{%- else -%} +
  • No results.
  • +{%- endfor -%} {% endmacro %} {% macro rows(items, fields, getp=None) -%} - {%- for obj in items -%} - - {%- for f in fields -%} - {{ getp(obj, f) }} - {%- endfor -%} - - {%- else -%} - No results. +{%- for obj in items -%} + + {%- for f in fields -%} + {{ getp(obj, f) }} {%- endfor -%} + +{%- else -%} + + No results. + +{%- endfor -%} {%- endmacro %} {% macro pager(model, page, pages, per_page, sort, filters) -%} - {%- endmacro %} {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} -
    - {%- if csrf_token %}{% endif -%} - {%- if obj_id %}{% endif -%} + + {%- if csrf_token %}{% endif -%} + {%- if obj_id %}{% endif -%} + + {%- for f in schema -%} +
    + {% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} + + {%- if f.type == "textarea" -%} + + {%- elif f.type == "select" -%} + + {%- elif f.type == "checkbox" -%} + + + {%- else -%} + + {%- endif -%} + {%- if f.help %}
    {{ f.help }}
    {% endif -%} +
    + {%- endfor -%} - {%- for f in schema -%} -
    - - {%- if f.help %}
    {{ f.help }}
    {% endif -%} -
    - {%- endfor -%} - -
    - -
    -
    -{%- endmacro %} +
    + +
    + +{%- endmacro %} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/form.html b/crudkit/html/templates/crudkit/form.html index 0c2c226..5f3bfb3 100644 --- a/crudkit/html/templates/crudkit/form.html +++ b/crudkit/html/templates/crudkit/form.html @@ -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) }} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/row.html b/crudkit/html/templates/crudkit/row.html new file mode 100644 index 0000000..a3ac629 --- /dev/null +++ b/crudkit/html/templates/crudkit/row.html @@ -0,0 +1,2 @@ +{% import "_macros.html" as ui %} +{{ ui.rows([obj], fields, getp=getp) }} \ No newline at end of file diff --git a/crudkit/html/type_map.py b/crudkit/html/type_map.py index c942bf8..5e582c2 100644 --- a/crudkit/html/type_map.py +++ b/crudkit/html/type_map.py @@ -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 diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py index 37d0743..525c6e5 100644 --- a/crudkit/html/ui_fragments.py +++ b/crudkit/html/ui_fragments.py @@ -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 _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("//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("//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//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 + diff --git a/example_app/app.py b/example_app/app.py index b7f9909..4061fcd 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -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__": diff --git a/example_app/templates/demo.html b/example_app/templates/demo.html new file mode 100644 index 0000000..56491fb --- /dev/null +++ b/example_app/templates/demo.html @@ -0,0 +1,17 @@ + + + + + + + +
    IDTitleAuthor
    + + + + +