Fixing render_form's sins.
This commit is contained in:
parent
27431a7150
commit
7f6cbf66fb
7 changed files with 211 additions and 11 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from flask import current_app, url_for
|
from flask import current_app, url_for
|
||||||
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.orm import class_mapper, RelationshipProperty
|
from sqlalchemy.orm import class_mapper, RelationshipProperty, load_only, selectinload
|
||||||
from sqlalchemy.orm.attributes import NO_VALUE
|
from sqlalchemy.orm.attributes import NO_VALUE
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
@ -16,6 +17,118 @@ def get_env():
|
||||||
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class _SafeObj:
|
||||||
|
"""Attribute access that returns '' for missing/None instead of exploding."""
|
||||||
|
__slots__ = ("_obj",)
|
||||||
|
def __init__(self, obj): self._obj = obj
|
||||||
|
def __str__(self): return "" if self._obj is None else str(self._obj)
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if self._obj is None:
|
||||||
|
return ""
|
||||||
|
val = getattr(self._obj, name, None)
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
return _SafeObj(val)
|
||||||
|
|
||||||
|
def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, str]]]:
|
||||||
|
"""
|
||||||
|
From a label spec, return:
|
||||||
|
- simple_cols: ["name", "code"]
|
||||||
|
- rel_paths: [("room_function", "description"), ("owner", "last_name")]
|
||||||
|
"""
|
||||||
|
simple_cols: list[str] = []
|
||||||
|
rel_paths: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def ingest(token: str) -> None:
|
||||||
|
token = str(token).strip()
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
if "." in token:
|
||||||
|
rel, col = token.split(".", 1)
|
||||||
|
if rel and col:
|
||||||
|
rel_paths.append((rel, col))
|
||||||
|
else:
|
||||||
|
simple_cols.append(token)
|
||||||
|
|
||||||
|
if spec is None or callable(spec):
|
||||||
|
return simple_cols, rel_paths
|
||||||
|
|
||||||
|
if isinstance(spec, (list, tuple)):
|
||||||
|
for a in spec:
|
||||||
|
ingest(a)
|
||||||
|
return simple_cols, rel_paths
|
||||||
|
|
||||||
|
if isinstance(spec, str):
|
||||||
|
# format string like "{first} {last}" or "{room_function.description} · {name}"
|
||||||
|
if "{" in spec and "}" in spec:
|
||||||
|
names = re.findall(r"{\s*([^}:\s]+)", spec)
|
||||||
|
for n in names:
|
||||||
|
ingest(n)
|
||||||
|
else:
|
||||||
|
ingest(spec)
|
||||||
|
return simple_cols, rel_paths
|
||||||
|
|
||||||
|
return simple_cols, rel_paths
|
||||||
|
|
||||||
|
def _attrs_from_label_spec(spec: Any) -> list[str]:
|
||||||
|
"""
|
||||||
|
Return a list of attribute names needed from the related model to compute the label.
|
||||||
|
Only simple attribute names are returned; dotted paths return just the first segment.
|
||||||
|
"""
|
||||||
|
if spec is None:
|
||||||
|
return []
|
||||||
|
if callable(spec):
|
||||||
|
return []
|
||||||
|
if isinstance(spec, (list, tuple)):
|
||||||
|
return [str(a).split(".", 1)[0] for a in spec]
|
||||||
|
if isinstance(spec, str):
|
||||||
|
if "{" in spec and "}" in spec:
|
||||||
|
names = re.findall(r"{\s*([^}:\s]+)", spec)
|
||||||
|
return [n.split(".", 1)[0] for n in names]
|
||||||
|
return [spec.split(".", 1)[0]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _label_from_obj(obj: Any, spec: Any) -> str:
|
||||||
|
if spec is None:
|
||||||
|
return str(obj)
|
||||||
|
if callable(spec):
|
||||||
|
try:
|
||||||
|
return str(spec(obj))
|
||||||
|
except Exception:
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
if isinstance(spec, (list, tuple)):
|
||||||
|
parts = []
|
||||||
|
for a in spec:
|
||||||
|
cur = obj
|
||||||
|
for part in str(a).split("."):
|
||||||
|
cur = getattr(cur, part, None)
|
||||||
|
if cur is None:
|
||||||
|
break
|
||||||
|
parts.append("" if cur is None else str(cur))
|
||||||
|
return " ".join(p for p in parts if p)
|
||||||
|
|
||||||
|
if isinstance(spec, str) and "{" in spec and "}" in spec:
|
||||||
|
fields = re.findall(r"{\s*([^}:\s]+)", spec)
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for f in fields:
|
||||||
|
root = f.split(".", 1)[0]
|
||||||
|
if root not in data:
|
||||||
|
val = getattr(obj, root, None)
|
||||||
|
data[root] = _SafeObj(val)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return spec.format(**data)
|
||||||
|
except Exception:
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
cur = obj
|
||||||
|
for part in str(spec).split("."):
|
||||||
|
cur = getattr(cur, part, None)
|
||||||
|
if cur is None:
|
||||||
|
return ""
|
||||||
|
return str(cur)
|
||||||
|
|
||||||
def _val_from_row_or_obj(row: Dict[str, Any], obj: Any, dotted: str) -> Any:
|
def _val_from_row_or_obj(row: Dict[str, Any], obj: Any, dotted: str) -> Any:
|
||||||
"""Best-effort deep get: try the projected row first, then the ORM object."""
|
"""Best-effort deep get: try the projected row first, then the ORM object."""
|
||||||
val = _deep_get(row, dotted)
|
val = _deep_get(row, dotted)
|
||||||
|
|
@ -252,33 +365,81 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N
|
||||||
|
|
||||||
return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts)
|
return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts)
|
||||||
|
|
||||||
def render_form(model_cls, values, session=None):
|
def render_form(model_cls, values, session=None, *, label_specs: Optional[Dict[str, Any]] = None):
|
||||||
env = get_env()
|
env = get_env()
|
||||||
template = get_crudkit_template(env, 'form.html')
|
template = get_crudkit_template(env, 'form.html')
|
||||||
fields = []
|
fields = []
|
||||||
fk_fields = set()
|
fk_fields = set()
|
||||||
|
label_specs = label_specs or {}
|
||||||
|
|
||||||
mapper = class_mapper(model_cls)
|
mapper = class_mapper(model_cls)
|
||||||
for prop in mapper.iterate_properties:
|
for prop in mapper.iterate_properties:
|
||||||
# FK Relationship fields (many-to-one)
|
|
||||||
if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE':
|
if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE':
|
||||||
if session is None:
|
if session is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
related_model = prop.mapper.class_
|
related_model = prop.mapper.class_
|
||||||
options = session.query(related_model).all()
|
rel_label_spec = (
|
||||||
|
label_specs.get(prop.key)
|
||||||
|
or getattr(related_model, "__crud_label__", None)
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Figure out what we must load
|
||||||
|
simple_cols, rel_paths = _extract_label_requirements(rel_label_spec)
|
||||||
|
|
||||||
|
q = session.query(related_model)
|
||||||
|
|
||||||
|
# id is always needed
|
||||||
|
col_attrs = []
|
||||||
|
if hasattr(related_model, "id"):
|
||||||
|
col_attrs.append(getattr(related_model, "id"))
|
||||||
|
for name in simple_cols:
|
||||||
|
if hasattr(related_model, name):
|
||||||
|
col_attrs.append(getattr(related_model, name))
|
||||||
|
if col_attrs:
|
||||||
|
q = q.options(load_only(*col_attrs))
|
||||||
|
|
||||||
|
# Load related bits minimally
|
||||||
|
for rel_name, col_name in rel_paths:
|
||||||
|
rel_prop = getattr(related_model, rel_name, None)
|
||||||
|
if rel_prop is None:
|
||||||
|
continue
|
||||||
|
# grab target class to resolve column attr
|
||||||
|
try:
|
||||||
|
target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_
|
||||||
|
col_attr = getattr(target_cls, col_name, None)
|
||||||
|
if col_attr is None:
|
||||||
|
q = q.options(selectinload(rel_prop))
|
||||||
|
else:
|
||||||
|
q = q.options(selectinload(rel_prop).load_only(col_attr))
|
||||||
|
except Exception:
|
||||||
|
# fallback if mapper lookup is weird
|
||||||
|
q = q.options(selectinload(rel_prop))
|
||||||
|
|
||||||
|
# Gentle ordering: use first simple col if any, else skip
|
||||||
|
if simple_cols:
|
||||||
|
first = simple_cols[0]
|
||||||
|
if hasattr(related_model, first):
|
||||||
|
q = q.order_by(getattr(related_model, first))
|
||||||
|
|
||||||
|
options = q.all()
|
||||||
|
|
||||||
fields.append({
|
fields.append({
|
||||||
'name': f"{prop.key}_id",
|
'name': f"{prop.key}_id",
|
||||||
'label': prop.key,
|
'label': prop.key,
|
||||||
'type': 'select',
|
'type': 'select',
|
||||||
'options': [
|
'options': [
|
||||||
{'value': getattr(obj, 'id'), 'label': str(obj)}
|
{
|
||||||
for obj in options
|
'value': getattr(opt, 'id'),
|
||||||
|
'label': _label_from_obj(opt, rel_label_spec),
|
||||||
|
}
|
||||||
|
for opt in options
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
fk_fields.add(f"{prop.key}_id")
|
fk_fields.add(f"{prop.key}_id")
|
||||||
|
|
||||||
# Now add basic columns — excluding FKs already covered
|
# Base columns
|
||||||
for col in model_cls.__table__.columns:
|
for col in model_cls.__table__.columns:
|
||||||
if col.name in fk_fields:
|
if col.name in fk_fields:
|
||||||
continue
|
continue
|
||||||
|
|
@ -293,4 +454,3 @@ def render_form(model_cls, values, session=None):
|
||||||
})
|
})
|
||||||
|
|
||||||
return template.render(fields=fields, values=values, render_field=render_field)
|
return template.render(fields=fields, values=values, render_field=render_field)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
{{ render_field(field, values.get(field.name, '')) }}
|
{{ render_field(field, values.get(field.name, '')) | safe }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit">Create</button>
|
<button type="submit">Create</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr class="{{ row.class or '' }}">
|
||||||
{% for cell in row.cells %}
|
{% for cell in row.cells %}
|
||||||
{% if cell.href %}
|
{% if cell.href %}
|
||||||
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from crudkit.integrations.flask import init_app
|
||||||
|
|
||||||
from .routes.index import init_index_routes
|
from .routes.index import init_index_routes
|
||||||
from .routes.listing import init_listing_routes
|
from .routes.listing import init_listing_routes
|
||||||
|
from .routes.entry import init_entry_routes
|
||||||
|
|
||||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
@ -44,6 +45,7 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
|
|
||||||
init_index_routes(app)
|
init_index_routes(app)
|
||||||
init_listing_routes(app)
|
init_listing_routes(app)
|
||||||
|
init_entry_routes(app)
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def _remove_session(_exc):
|
def _remove_session(_exc):
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class Room(Base, CRUDMixin):
|
||||||
is_deleted: Mapped[Boolean] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
is_deleted: Mapped[Boolean] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Room(id={self.id}, name={repr(self.name)}, area={repr(self.area.name)}, function={repr(self.room_function.description)})>"
|
return f"<Room id={self.id} name={self.name!r} area_id={self.area_id!r} function_id={self.function_id!r}>"
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def label(self):
|
def label(self):
|
||||||
|
|
|
||||||
33
inventory/routes/entry.py
Normal file
33
inventory/routes/entry.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from flask import Blueprint, render_template, abort, request, jsonify
|
||||||
|
|
||||||
|
import crudkit
|
||||||
|
|
||||||
|
from crudkit.api._cursor import decode_cursor, encode_cursor
|
||||||
|
from crudkit.ui.fragments import render_form
|
||||||
|
|
||||||
|
bp_entry = Blueprint("entry", __name__)
|
||||||
|
|
||||||
|
def init_entry_routes(app):
|
||||||
|
|
||||||
|
@bp_entry.get("/entry/<model>/<int:id>")
|
||||||
|
def entry(model: str, id: int):
|
||||||
|
cls = crudkit.crud.get_model(model)
|
||||||
|
if cls is None:
|
||||||
|
abort(404)
|
||||||
|
obj = crudkit.crud.get_service(cls).get(id, {"fields": ["first_name", "last_name", "title", "supervisor"]})
|
||||||
|
if obj is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
form = render_form(
|
||||||
|
cls,
|
||||||
|
obj.as_dict(),
|
||||||
|
crudkit.crud.get_service(cls).session,
|
||||||
|
label_specs={
|
||||||
|
"supervisor": "{first_name} {last_name}",
|
||||||
|
"location": "{name} - {room_function.description}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("entry.html", form=form)
|
||||||
|
|
||||||
|
app.register_blueprint(bp_entry)
|
||||||
5
inventory/templates/entry.html
Normal file
5
inventory/templates/entry.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{{ form | safe }}
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue