Working on improving form logic.

This commit is contained in:
Yaro Kasear 2025-09-23 10:17:32 -05:00
parent 86c4f88b78
commit 260411d4ee
5 changed files with 158 additions and 77 deletions

View file

@ -6,14 +6,16 @@ from flask import current_app, url_for
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
from sqlalchemy import inspect
from sqlalchemy.orm import Load, RelationshipProperty, class_mapper, load_only, selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
from typing import Any, Dict, List, Optional, Tuple
_ALLOWED_ATTRS = {
"class", "placeholder", "autocomplete", "inputmode", "pattern",
"min", "max", "step", "maxlength", "minlength",
"required", "readonly", "disabled",
"multiple", "size",
"multiple", "size", "rows",
"id", "name", "value",
}
@ -26,6 +28,24 @@ def get_env():
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
)
def _is_column_attr(attr) -> bool:
try:
return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, ColumnProperty)
except Exception:
return False
def _is_relationship_attr(attr) -> bool:
try:
return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, RelationshipProperty)
except Exception:
return False
def _get_attr_deps(model_cls, attr_name: str, extra_deps: Optional[dict] = None) -> list[str]:
"""Merge model-level and per-field declared deps for a computed attr."""
model_deps = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
field_deps = (extra_deps or {})
return list(model_deps.get(attr_name, [])) + list(field_deps.get(attr_name, []))
def _get_loaded_attr(obj: Any, name: str) -> Any:
"""
Return obj.<name> only if it is already loaded.
@ -230,26 +250,48 @@ def _value_label_for_field(field: dict, mapper, values_map: dict, instance, sess
or "id"
)
if rel_obj is not None and not _has_label_bits_loaded(rel_obj, label_spec) and session is not None and rid is not None:
mdl = rel_prop.mapper.class_
simple_cols, rel_paths = _extract_label_requirements(label_spec)
if rel_obj is not None and session is not None and rid is not None:
mdl = rel_prop.mapper.class_
# Work out exactly what the label needs (columns + rel paths),
# expanding model-level and per-field deps (for hybrids etc.)
simple_cols, rel_paths = _extract_label_requirements(
label_spec,
model_cls=mdl,
extra_deps=field.get("label_deps")
)
# If the currently-attached object doesn't have what we need, do one lean requery
if not _has_label_bits_loaded(rel_obj, label_spec):
q = session.query(mdl)
cols = [getattr(mdl, "id")]
# only real columns in load_only
cols = []
id_attr = getattr(mdl, "id", None)
if _is_column_attr(id_attr):
cols.append(id_attr)
for c in simple_cols:
if hasattr(mdl, c):
cols.append(getattr(mdl, c))
a = getattr(mdl, c, None)
if _is_column_attr(a):
cols.append(a)
if cols:
q = q.options(load_only(*cols))
# selectinload relationships; "__all__" means just eager the relationship object
for rel_name, col_name in rel_paths:
try:
t_rel = mdl.__mapper__.relationships[rel_name]
t_cls = t_rel.mapper.class_
col_attr = getattr(t_cls, col_name, None)
opt = selectinload(getattr(mdl, rel_name))
q = q.options(opt.load_only(col_attr) if col_attr is not None else opt)
except Exception:
q = q.options(selectinload(getattr(mdl, rel_name)))
rel_ia = getattr(mdl, rel_name, None)
if rel_ia is None:
continue
opt = selectinload(rel_ia)
if col_name == "__all__":
q = q.options(opt)
else:
t_cls = mdl.__mapper__.relationships[rel_name].mapper.class_
t_attr = getattr(t_cls, col_name, None)
q = q.options(opt.load_only(t_attr) if _is_column_attr(t_attr) else opt)
rel_obj = q.get(rid)
if rel_obj is not None:
return _label_from_obj(rel_obj, label_spec)
return str(rid) if rid is not None else None
@ -333,31 +375,33 @@ def _rel_for_id_name(mapper, name: str) -> tuple[Optional[str], Optional[Relatio
return (name, prop) if prop else (None, None)
def _fk_options(session, related_model, label_spec):
simple_cols, rel_paths = _extract_label_requirements(label_spec)
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
q = session.query(related_model)
col_attrs = []
if hasattr(related_model, "id"):
col_attrs.append(getattr(related_model, "id"))
id_attr = getattr(related_model, "id")
if _is_column_attr(id_attr):
col_attrs.append(id_attr)
for name in simple_cols:
if hasattr(related_model, name):
col_attrs.append(getattr(related_model, name))
attr = getattr(related_model, name, None)
if _is_column_attr(attr):
col_attrs.append(attr)
if col_attrs:
q = q.options(load_only(*col_attrs))
for rel_name, col_name in rel_paths:
rel_prop = getattr(related_model, rel_name, None)
if rel_prop is None:
rel_attr = getattr(related_model, rel_name, None)
if rel_attr is None:
continue
try:
opt = selectinload(rel_attr)
if col_name == "__all__":
q = q.options(opt)
else:
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:
q = q.options(selectinload(rel_prop))
q = q.options(opt.load_only(col_attr) if _is_column_attr(col_attr) else opt)
if simple_cols:
first = simple_cols[0]
@ -366,10 +410,7 @@ def _fk_options(session, related_model, label_spec):
rows = q.all()
return [
{
'value': getattr(opt, 'id'),
'label': _label_from_obj(opt, label_spec),
}
{'value': getattr(opt, 'id'), 'label': _label_from_obj(opt, label_spec)}
for opt in rows
]
@ -427,46 +468,68 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
return field
def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, str]]]:
def _extract_label_requirements(
spec: Any,
model_cls: Any = None,
extra_deps: Optional[Dict[str, List[str]]] = None
) -> tuple[list[str], list[tuple[str, str]]]:
"""
From a label spec, return:
- simple_cols: ["name", "code"]
- rel_paths: [("room_function", "description"), ("owner", "last_name")]
Returns:
simple_cols: ["name", "code", "label", ...] (non-dotted names; may include non-columns)
rel_paths: [("room_function", "description"), ("brand", "__all__"), ...]
- ("rel", "__all__") means: just eager the relationship (no specific column)
Also expands dependencies declared by the model or the field (extra_deps).
"""
simple_cols: list[str] = []
rel_paths: list[tuple[str, str]] = []
seen: set[str] = set()
def ingest(token: str) -> None:
token = str(token).strip()
if not token:
def add_dep_token(token: str) -> None:
"""Add a concrete dependency token (column or 'rel' or 'rel.col')."""
if not token or token in seen:
return
seen.add(token)
if "." in token:
rel, col = token.split(".", 1)
if rel and col:
rel_paths.append((rel, col))
return
# bare token: could be column, relationship, or computed
simple_cols.append(token)
# If this is not obviously a column, try pulling declared deps.
if model_cls is not None:
attr = getattr(model_cls, token, None)
if _is_column_attr(attr):
return
# If it's a relationship, we want to eager the relationship itself.
if _is_relationship_attr(attr):
rel_paths.append((token, "__all__"))
return
# Not a column/relationship => computed (hybrid/descriptor/etc.)
for dep in _get_attr_deps(model_cls, token, extra_deps):
add_dep_token(dep)
def add_from_spec(piece: Any) -> None:
if piece is None or callable(piece):
return
if isinstance(piece, (list, tuple)):
for a in piece:
add_from_spec(a)
return
s = str(piece)
if "{" in s and "}" in s:
for n in re.findall(r"{\s*([^}:\s]+)", s):
add_dep_token(n)
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
add_dep_token(s)
add_from_spec(spec)
return simple_cols, rel_paths
def _label_from_obj(obj: Any, spec: Any) -> str:
if obj is None:
return ""
@ -633,26 +696,36 @@ def _format_value(val: Any, fmt: Optional[str]) -> Any:
return val
return val
def _has_label_bits_loaded(obj: Any, label_spec: Any) -> bool:
def _has_label_bits_loaded(obj, label_spec) -> bool:
try:
st = inspect(obj)
except Exception:
return True
simple_cols, rel_paths = _extract_label_requirements(label_spec)
for c in simple_cols:
if c not in st.dict:
simple_cols, rel_paths = _extract_label_requirements(label_spec, type(obj))
# concrete columns on the object
for name in simple_cols:
a = getattr(type(obj), name, None)
if _is_column_attr(a) and name not in st.dict:
return False
for rel, col in rel_paths:
ra = st.attrs.get(rel)
if ra is None or ra.loaded_value is NO_VALUE or ra.loaded_value is None:
# non-column tokens (hybrids/descriptors) are satisfied by their deps above
# relationships
for rel_name, col_name in rel_paths:
ra = st.attrs.get(rel_name)
if ra is None or ra.loaded_value in (NO_VALUE, None):
return False
if col_name == "__all__":
continue # relationship object present is enough
try:
t_st = inspect(ra.loaded_value)
if col not in t_st.dict:
return False
except Exception:
return False
t_attr = getattr(type(ra.loaded_value), col_name, None)
if _is_column_attr(t_attr) and col_name not in t_st.dict:
return False
return True
def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]:

View file

@ -1,4 +1,5 @@
{# show label unless hidden/custom #}
<!-- {{ field_name }} (field) -->
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %}

View file

@ -1,6 +1,6 @@
<form method="POST">
{% macro render_row(row) %}
<!-- {{ row.name }} -->
<!-- {{ row.name }} (row) -->
{% if row.fields or row.children or row.legend %}
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
<fieldset

View file

@ -9,6 +9,9 @@ from crudkit.core.base import Base, CRUDMixin
class Inventory(Base, CRUDMixin):
__tablename__ = "inventory"
__crudkit_field_requires__ = {
"label": ["name", "barcode", "serial"],
}
barcode: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)

View file

@ -21,7 +21,7 @@ def init_entry_routes(app):
fields_spec = []
layout = []
if model == "inventory":
fields["fields"] = ["label", "name", "serial", "barcode", "brand", "model", "device_type", "owner", "location", "condition", "image"]
fields["fields"] = ["label", "name", "serial", "barcode", "brand", "model", "device_type", "owner", "location", "condition", "image", "notes"]
fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label",
"attrs": {"class": "display-6 mb-3"}},
@ -50,7 +50,10 @@ def init_entry_routes(app):
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail"}}
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail"}},
{"name": "notes", "type": "textarea", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
"attrs": {"class": "form-control", "rows": 10}},
]
layout = [
{"name": "label", "order": 5},
@ -59,7 +62,8 @@ def init_entry_routes(app):
{"name": "names", "order": 20, "attrs": {"class": "row"}, "parent": "everything"},
{"name": "device", "order": 30, "attrs": {"class": "row mt-2"}, "parent": "everything"},
{"name": "status", "order": 40, "attrs": {"class": "row mt-2"}, "parent": "everything"},
{"name": "image", "order": 50, "attrs": {"class": "col-4"}, "parent": "kitchen_sink"}
{"name": "notes", "order": 45, "attrs": {"class": "row mt-2"}, "parent": "everything"},
{"name": "image", "order": 50, "attrs": {"class": "col-4"}, "parent": "kitchen_sink"},
]
elif model.lower() == 'user':
fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"]
@ -91,7 +95,7 @@ def init_entry_routes(app):
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}
]
layout = [
{"name": "label", "order": 0},
@ -109,13 +113,13 @@ def init_entry_routes(app):
"label_spec": "{first_name} {last_name}", "attrs": {"class": "form-control"},
"label_attrs": {"class": "form-label"}},
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
"label_spec": "{name}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}},
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"},
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}},
{"name": "complete", "label": "Complete", "label-attrs": {"class": "form-check-label"},
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"},
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"},
@ -123,7 +127,7 @@ def init_entry_routes(app):
]
layout = [
{"name": "label", "order": 0},
{"name": "ownership", "order": 10, "attrs": {"class": "row"}},
{"name": "ownership", "order": 10, "attrs": {"class": "row mb-2"}},
{"name": "timestamps", "order": 20, "attrs": {"class": "row d-flex align-items-center"}},
{"name": "updates", "order": 30, "attrs": {"class": "row"}}
]