diff --git a/crudkit/html/__init__.py b/crudkit/html/__init__.py index e69de29..a94f018 100644 --- a/crudkit/html/__init__.py +++ b/crudkit/html/__init__.py @@ -0,0 +1,3 @@ +from .ui_fragments import make_fragments_blueprint + +__all__ = ["make_fragments_blueprint"] diff --git a/crudkit/html/type_map.py b/crudkit/html/type_map.py index e69de29..c942bf8 100644 --- a/crudkit/html/type_map.py +++ b/crudkit/html/type_map.py @@ -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 diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py index e69de29..6dff4e1 100644 --- a/crudkit/html/ui_fragments.py +++ b/crudkit/html/ui_fragments.py @@ -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 //frag/options -> + GET //frag/lis ->
  • ...
  • + GET //frag/rows -> ... + pager markup if wanted + GET //frag/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("//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("//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("//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("//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 diff --git a/crudkit/templates/crudkit/_macros.html b/crudkit/templates/crudkit/_macros.html index e69de29..be86b57 100644 --- a/crudkit/templates/crudkit/_macros.html +++ b/crudkit/templates/crudkit/_macros.html @@ -0,0 +1,86 @@ +{% macro options(items, value_attr="id", label_path="name", getp=None) -%} + {%- 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 -%} +{% endmacro %} + +{% macro rows(items, fields, getp=None) -%} + {%- 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 -%} + + + {%- for f in schema -%} +
    + + {%- if f.help %}
    {{ f.help }}
    {% endif -%} +
    + {%- endfor -%} + +
    + +
    +
    +{%- endmacro %} diff --git a/crudkit/templates/crudkit/form.html b/crudkit/templates/crudkit/form.html index e69de29..aab8133 100644 --- a/crudkit/templates/crudkit/form.html +++ b/crudkit/templates/crudkit/form.html @@ -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 %} diff --git a/crudkit/templates/crudkit/lis.html b/crudkit/templates/crudkit/lis.html index e69de29..3ce62e7 100644 --- a/crudkit/templates/crudkit/lis.html +++ b/crudkit/templates/crudkit/lis.html @@ -0,0 +1,2 @@ +{% import "crudkit/_macros.html" as ui %} +{{ ui.lis(items, label_path=label_path, sublabel_path=sublabel_path, getp=getp) }} diff --git a/crudkit/templates/crudkit/options.html b/crudkit/templates/crudkit/options.html index e69de29..0f66d2d 100644 --- a/crudkit/templates/crudkit/options.html +++ b/crudkit/templates/crudkit/options.html @@ -0,0 +1,3 @@ +{# Renders only rows #} +{% import "crudkit/_macros.html" as ui %} +{{ ui.options(items, value_attr=value_attr, label_path=label_path, getp=getp) }} diff --git a/crudkit/templates/crudkit/rows.html b/crudkit/templates/crudkit/rows.html index e69de29..1c740f1 100644 --- a/crudkit/templates/crudkit/rows.html +++ b/crudkit/templates/crudkit/rows.html @@ -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) }}