inventory/crudkit/ui/fragments.py

929 lines
31 KiB
Python

import os
import re
from collections import OrderedDict
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.base import NO_VALUE
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",
"id", "name", "value",
}
def get_env():
app = current_app
default_path = os.path.join(os.path.dirname(__file__), 'templates')
fallback_loader = FileSystemLoader(default_path)
return app.jinja_env.overlay(
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
)
def _get_loaded_attr(obj: Any, name: str) -> Any:
"""
Return obj.<name> only if it is already loaded.
Never triggers a lazy load. Returns None if missing/unloaded.
Works for both column and relationship attributes.
"""
try:
st = inspect(obj)
attr = st.attrs.get(name)
if attr is not None:
val = attr.loaded_value
return None if val is NO_VALUE else val
if name in st.dict:
return st.dict.get(name)
return getattr(obj, name, None)
except Exception:
return getattr(obj, name, None)
def _normalize_rows_layout(layout: Optional[List[dict]]) -> Dict[str, dict]:
"""
Create node dicts for each row and link parent->children.
Node shape:
{
'name': str,
'legend': Optional[str],
'attrs': dict, # sanitized
'order': int,
'parent': Optional[str],
'children': list, # list of node names (we'll expand later)
'fields': list, # filled later
}
Always ensures a 'main' node exists.
"""
nodes: Dict[str, dict] = {}
def make_node(name: str) -> dict:
node = nodes.get(name)
if node is None:
node = nodes[name] = {
"name": name,
"legend": None,
"attrs": {},
"order": 0,
"parent": None,
"children": [],
"fields": [],
}
return node
# seed nodes from layout
if isinstance(layout, list):
for item in layout:
name = item.get("name")
if not isinstance(name, str) or not name:
continue
node = make_node(name)
node["legend"] = item.get("legend")
node["attrs"] = _sanitize_attrs(item.get("attrs") or {})
try:
node["order"] = int(item.get("order") or 0)
except Exception:
node["order"] = 0
parent = item.get("parent")
node["parent"] = parent if isinstance(parent, str) and parent else None
# ensure main exists and is early-ordered
main = make_node("main")
if "order" not in main or main["order"] == 0:
main["order"] = -10
# default any unknown parents to main (except main itself)
for n in list(nodes.values()):
if n["name"] == "main":
n["parent"] = None
continue
p = n["parent"]
if p is None or p not in nodes or p == n["name"]:
n["parent"] = "main"
# detect cycles defensively; break by reparenting to main
visiting = set()
visited = set()
def visit(name: str):
if name in visited:
return
if name in visiting:
# cycle; break this node to main
nodes[name]["parent"] = "main"
return
visiting.add(name)
parent = nodes[name]["parent"]
if parent is not None:
visit(parent)
visiting.remove(name)
visited.add(name)
for nm in list(nodes.keys()):
visit(nm)
# compute children lists
for n in nodes.values():
n["children"] = []
for n in nodes.values():
p = n["parent"]
if p is not None:
nodes[p]["children"].append(n["name"])
# sort children by (order, name) for deterministic rendering
for n in nodes.values():
n["children"].sort(key=lambda nm: (nodes[nm]["order"], nodes[nm]["name"]))
return nodes
def _assign_fields_to_rows(fields: List[dict], rows: Dict[str, dict]) -> List[dict]:
"""
Put fields into their target row buckets by name (default 'main'),
then return a list of root nodes expanded with nested dicts ready for templates.
"""
# assign fields
for f in fields:
row_name = f.get("row") or "main"
node = rows.get(row_name) or rows["main"]
node["fields"].append(f)
# expand tree into nested structures
def expand(name: str) -> dict:
n = rows[name]
return {
"name": n["name"],
"legend": n["legend"],
"attrs": n["attrs"],
"order": n["order"],
"fields": n["fields"],
"children": [expand(ch) for ch in n["children"]],
}
# roots are nodes with parent == None
roots = [expand(nm) for nm, n in rows.items() if n["parent"] is None]
roots.sort(key=lambda r: (r["order"], r["name"]))
return roots
def _sanitize_attrs(attrs: Any) -> dict[str, Any]:
"""
Whitelist attributes; allow data-* and aria-*; render True as boolean attr.
Drop False/None and anything not whitelisted.
"""
if not isinstance(attrs, dict):
return {}
out: dict[str, Any] = {}
for k, v in attrs.items():
if not isinstance(k, str):
continue
elif isinstance(v, str):
if len(v) > 512:
v = v[:512]
if k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS:
if isinstance(v, bool):
if v:
out[k] = True
elif v is not None:
out[k] = str(v)
return out
def _resolve_rel_obj(values: dict, instance, base: str):
rel = None
if isinstance(values, dict) and base in values:
rel = values[base]
if isinstance(rel, dict):
class _DictObj:
def __init__(self, d): self._d = d
def __getattr__(self, k): return self._d.get(k)
rel = _DictObj(rel)
if rel is None and instance is not None:
try:
st = inspect(instance)
ra = st.attrs.get(base)
if ra is not None and ra.loaded_value is not NO_VALUE:
rel = ra.loaded_value
except Exception:
pass
return rel
def _value_label_for_field(field: dict, mapper, values_map: dict, instance, session):
"""
If field targets a MANYTOONE (foo or foo_id), compute a human-readable label.
No lazy loads. Optional single-row lean fetch if we only have the id.
"""
base, rel_prop = _rel_for_id_name(mapper, field["name"])
if not rel_prop:
return None
rid = _coerce_fk_value(values_map, instance, base)
rel_obj = _resolve_rel_obj(values_map, instance, base)
label_spec = (
field.get("label_spec")
or getattr(rel_prop.mapper.class_, "__crud_label__", None)
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)
q = session.query(mdl)
cols = [getattr(mdl, "id")]
for c in simple_cols:
if hasattr(mdl, c):
cols.append(getattr(mdl, c))
if cols:
q = q.options(load_only(*cols))
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_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
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 = _get_loaded_attr(self._obj, name)
return "" if val is None else _SafeObj(val)
def _coerce_fk_value(values: dict | None, instance: Any, base: str):
"""
Resolve current selection for relationship 'base':
1) values['<base>_id']
2) values['<base>']['id'] or values['<base>'] if it's an int or numeric string
3) instance.<base> (if already loaded) -> use its .id [safe for detached]
4) instance.<base>_id (if already loaded and not expired)
Never trigger a lazy load.
"""
# 1) explicit *_id from values
if isinstance(values, dict):
key = f"{base}_id"
if key in values:
return values.get(key)
rel = values.get(base)
# 2a) nested dict with id
if isinstance(rel, dict):
vid = rel.get("id") or rel.get(key)
if vid is not None:
return vid
# 2b) scalar id
if isinstance(rel, int):
return rel
if isinstance(rel, str):
s = rel.strip()
if s.isdigit():
return s # template compares as strings, so this is fine
# 3) use loaded relationship object (safe even if instance is detached)
if instance is not None:
try:
state = inspect(instance)
rel_attr = state.attrs.get(base)
if rel_attr is not None and rel_attr.loaded_value is not NO_VALUE:
rel_obj = rel_attr.loaded_value
if rel_obj is not None:
rid = getattr(rel_obj, "id", None)
if rid is not None:
return rid
# 4) use loaded fk column if present and not expired
id_attr = state.attrs.get(f"{base}_id")
if id_attr is not None and id_attr.loaded_value is not NO_VALUE:
return id_attr.loaded_value
except Exception:
pass
return None
def _is_many_to_one(mapper, name: str) -> Optional[RelationshipProperty]:
try:
prop = mapper.relationships[name]
except Exception:
return None
if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE':
return prop
return None
def _rel_for_id_name(mapper, name: str) -> tuple[Optional[str], Optional[RelationshipProperty]]:
if name.endswith("_id"):
base = name[:-3]
prop = _is_many_to_one(mapper, base)
return (base, prop) if prop else (None, None)
else:
prop = _is_many_to_one(mapper, name)
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)
q = session.query(related_model)
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))
for rel_name, col_name in rel_paths:
rel_prop = getattr(related_model, rel_name, None)
if rel_prop is None:
continue
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:
q = q.options(selectinload(rel_prop))
if simple_cols:
first = simple_cols[0]
if hasattr(related_model, first):
q = q.order_by(getattr(related_model, first))
rows = q.all()
return [
{
'value': getattr(opt, 'id'),
'label': _label_from_obj(opt, label_spec),
}
for opt in rows
]
def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
"""
Turn a user field spec into a concrete field dict the template understands.
"""
name = spec['name']
base_rel_name, rel_prop = _rel_for_id_name(mapper, name)
field = {
"name": name if not base_rel_name else f"{base_rel_name}_id",
"label": spec.get("label", name),
"type": spec.get("type"),
"options": spec.get("options"),
"attrs": spec.get("attrs"),
"label_attrs": spec.get("label_attrs"),
"wrap": spec.get("wrap"),
"row": spec.get("row"),
"help": spec.get("help"),
"template": spec.get("template"),
"template_name": spec.get("template_name"),
"template_ctx": spec.get("template_ctx"),
"label_spec": spec.get("label_spec")
}
if rel_prop:
if field["type"] is None:
field["type"] = "select"
if field["type"] == "select" and field.get("options") is None and session is not None:
related_model = rel_prop.mapper.class_
label_spec = (
spec.get("label_spec")
or label_specs_model_default.get(base_rel_name)
or getattr(related_model, "__crud_label__", None)
or "id"
)
field["options"] = _fk_options(session, related_model, label_spec)
return field
col = mapper.columns.get(name)
if field["type"] is None:
if col is not None and hasattr(col.type, "python_type"):
py = None
try:
py = col.type.python_type
except Exception:
pass
if py is bool:
field["type"] = "checkbox"
else:
field["type"] = "text"
else:
field["type"] = "text"
return field
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 _label_from_obj(obj: Any, spec: Any) -> str:
if obj is None:
return ""
if spec is None:
for attr in ("label", "name", "title", "description"):
val = _get_loaded_attr(obj, attr)
if val is not None:
return str(val)
vid = _get_loaded_attr(obj, "id")
return str(vid) if vid is not None else object.__repr__(obj)
if isinstance(spec, (list, tuple)):
parts = []
for a in spec:
cur = obj
for part in str(a).split("."):
cur = _get_loaded_attr(cur, part) if cur is not None else 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:
data[root] = _SafeObj(_get_loaded_attr(obj, root))
try:
return spec.format(**data)
except Exception:
return str(obj)
cur = obj
for part in str(spec).split("."):
cur = _get_loaded_attr(cur, part) if cur is not None else None
if cur is None:
return ""
return str(cur)
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."""
val = _deep_get(row, dotted)
if val is None:
val = _deep_get_from_obj(obj, dotted)
return val
def _matches_simple_condition(row: Dict[str, Any], obj: Any, cond: Dict[str, Any]) -> bool:
"""
Supports:
{"field": "foo.bar", "eq": 10}
{"field": "foo", "ne": None}
{"field": "count", "gt": 0} (also lt, gte, lte)
{"field": "name", "in": ["a","b"]}
{"field": "thing", "is": None, | True | False}
{"any": [ ...subconds... ]} # OR
{"all": [ ...subconds... ]} # AND
{"not": { ...subcond... }} # NOT
"""
if "any" in cond:
return any(_matches_simple_condition(row, obj, c) for c in cond["any"])
if "all" in cond:
return all(_matches_simple_condition(row, obj, c) for c in cond["all"])
if "not" in cond:
return not _matches_simple_condition(row, obj, cond["not"])
field = cond.get("field")
val = _val_from_row_or_obj(row, obj, field) if field else None
if "is" in cond:
target = cond["is"]
if target is None:
return val is None
if isinstance(target, bool):
return bool(val) is target
return val is target
if "eq" in cond:
return val == cond["eq"]
if "ne" in cond:
return val != cond["ne"]
if "gt" in cond:
try: return val > cond["gt"]
except Exception: return False
if "lt" in cond:
try: return val < cond["lt"]
except Exception: return False
if "gte" in cond:
try: return val >= cond["gte"]
except Exception: return False
if "lte" in cond:
try: return val <= cond["lte"]
except Exception: return False
if "in" in cond:
try: return val in cond["in"]
except Exception: return False
return False
def _row_class_for(row: Dict[str, Any], obj: Any, rules: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""
rules is a list of:
{"when": <condition-dict>, "class": "table-warning fw-semibold"}
Multiple matching rules stack classes. Later wins on duplicates by normal CSS rules.
"""
if not rules:
return None
classes = []
for rule in rules:
when = rule.get("when") or {}
if _matches_simple_condition(row, obj, when):
cls = rule.get("class")
if cls:
classes.append(cls)
return " ".join(dict.fromkeys(classes)) or None
def _is_rel_loaded(obj, rel_name: str) -> bool:
try:
state = inspect(obj)
attr = state.attrs[rel_name]
return attr.loaded_value is not NO_VALUE
except Exception:
return False
def _deep_get_from_obj(obj, dotted: str):
cur = obj
parts = dotted.split(".")
for i, part in enumerate(parts):
if i < len(parts) - 1 and not _is_rel_loaded(cur, part):
return None
cur = getattr(cur, part, None)
if cur is None:
return None
return cur
def _deep_get(row: Dict[str, Any], dotted: str) -> Any:
if dotted in row:
return row[dotted]
cur = row
for part in dotted.split('.'):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None
return cur
def _format_value(val: Any, fmt: Optional[str]) -> Any:
if fmt is None:
return val
try:
if fmt == "yesno":
return "Yes" if bool(val) else "No"
if fmt == "date":
return val.strftime("%Y-%m-%d") if hasattr(val, "strftime") else val
if fmt == "datetime":
return val.strftime("%Y-%m-%d %H:%M") if hasattr(val, "strftime") else val
if fmt == "time":
return val.strftime("%H:%M") if hasattr(val, "strftime") else val
except Exception:
return val
return val
def _has_label_bits_loaded(obj: Any, label_spec: Any) -> 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:
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:
return False
try:
t_st = inspect(ra.loaded_value)
if col not in t_st.dict:
return False
except Exception:
return False
return True
def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]:
if not classes:
return None
key = "none" if val is None else str(val).lower()
return classes.get(key, classes.get("default"))
def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]:
if not spec:
return None
params = {}
for k, v in (spec.get("params") or {}).items():
if isinstance(v, str) and v.startswith("{") and v.endswith("}"):
key = v[1:-1]
val = _deep_get(row, key)
if val is None:
val = _deep_get_from_obj(obj, key)
params[k] = val
else:
params[k] = v
if any(v is None for v in params.values()):
return None
try:
return url_for(spec["endpoint"], **params)
except Exception as e:
print(f"Cannot create endpoint for {spec['endpoint']}: {str(e)}")
return None
def _humanize(field: str) -> str:
return field.replace(".", " > ").replace("_", " ").title()
def _normalize_columns(columns: Optional[List[Dict[str, Any]]], default_fields: List[str]) -> List[Dict[str, Any]]:
if not columns:
return [{"field": f, "label": _humanize(f)} for f in default_fields]
norm = []
for col in columns:
c = dict(col)
c.setdefault("label", _humanize(c["field"]))
norm.append(c)
return norm
def _normalize_opts(opts: Dict[str, Any]) -> Dict[str, Any]:
"""
Accept either:
render_table(..., object_class='user', row_classe[...])
or:
render_table(..., opts={'object_class': 'user', 'row_classes': [...]})
Returns a flat dict with top-level keys for convenience, while preserving
all original keys for the template.
"""
if not isinstance(opts, dict):
return {}
flat = dict(opts)
nested = flat.get("opts")
if isinstance(nested, dict):
for k, v in nested.items():
flat.setdefault(k, v)
return flat
def get_crudkit_template(env, name):
try:
return env.get_template(f'crudkit/{name}')
except Exception:
return env.get_template(name)
def render_field(field, value):
env = get_env()
# 1) custom template field
field_type = field.get('type', 'text')
if field_type == 'template':
tname = field.get('template') or field.get('template_name')
if not tname:
return "" # nothing to render
t = get_crudkit_template(env, tname)
# merge ctx with some sensible defaults
ctx = dict(field.get('template_ctx') or {})
# make sure templates always see these
ctx.setdefault('field', field)
ctx.setdefault('value', value)
return t.render(**ctx)
# 2) normal controls
template = get_crudkit_template(env, 'field.html')
return template.render(
field_name=field['name'],
field_label=field.get('label', field['name']),
value=value,
field_type=field_type,
options=field.get('options', None),
attrs=_sanitize_attrs(field.get('attrs') or {}),
label_attrs=_sanitize_attrs(field.get('label_attrs') or {}),
help=field.get('help'),
value_label=field.get('value_label'),
)
def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None, **opts):
env = get_env()
template = get_crudkit_template(env, 'table.html')
if not objects:
return template.render(fields=[], rows=[])
flat_opts = _normalize_opts(opts)
proj = getattr(objects[0], "__crudkit_projection__", None)
row_dicts = [obj.as_dict(proj) for obj in objects]
default_fields = [k for k in row_dicts[0].keys() if k != "id"]
cols = _normalize_columns(columns, default_fields)
row_rules = (flat_opts.get("row_classes") or [])
disp_rows = []
for obj, rd in zip(objects, row_dicts):
cells = []
for col in cols:
field = col["field"]
raw = _deep_get(rd, field)
text = _format_value(raw, col.get("format"))
href = _build_href(col.get("link"), rd, obj) if col.get("link") else None
cls = _class_for(raw, col.get("classes"))
cells.append({"text": text, "href": href, "class": cls})
row_cls = _row_class_for(rd, obj, row_rules)
disp_rows.append({"id": rd.get("id"), "class": row_cls, "cells": cells})
return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts)
def render_form(
model_cls,
values,
session=None,
*,
fields_spec: Optional[list[dict]] = None,
label_specs: Optional[Dict[str, Any]] = None,
exclude: Optional[set[str]] = None,
overrides: Optional[Dict[str, Dict[str, Any]]] = None,
instance: Any = None,
layout: Optional[list[dict]] = None,
submit_attrs: Optional[dict[str, Any]] = None,
submit_label: Optional[str] = None,
):
"""
fields_spec: list of dicts describing fields in order. Each dict supports:
- name: "first_name" | "location" | "location_id" (required)
- label: override_label
- type: "text" | "textarea" | "checkbox" | "select" | "hidden" | ...
- label_spec: for relationship selects, e.g. "{name} - {room_function.description}"
- options: prebuilt list of {"value","label"}; skips querying if provided
- attrs: dict of arbitrary HTML attributes, e.g. {"required": True, "placeholder": "Jane"}
- help: small help text under the field
label_specs: legacy per-relationship label spec fallback ({"location": "..."}).
exclude: set of field names to hide.
overrides: legacy quick overrides keyed by field name (label/type/etc.)
instance: the ORM object backing the form; used to populate *_id values
layout: A list of dicts describing layouts for fields.
submit_attrs: A dict of attributes to apply to the submit button.
"""
env = get_env()
template = get_crudkit_template(env, "form.html")
exclude = exclude or set()
overrides = overrides or {}
label_specs = label_specs or {}
mapper = class_mapper(model_cls)
fields: list[dict] = []
values_map = dict(values or {}) # we'll augment this with *_id selections
if fields_spec:
# Spec-driven path
for spec in fields_spec:
if spec["name"] in exclude:
continue
field = _normalize_field_spec(
{**spec, **overrides.get(spec["name"], {})},
mapper, session, label_specs
)
fields.append(field)
# After building fields, inject current values for any M2O selects
for f in fields:
name = f.get("name")
if isinstance(name, str) and name.endswith("_id"):
base = name[:-3]
rel_prop = mapper.relationships.get(base)
if isinstance(rel_prop, RelationshipProperty) and rel_prop.direction.name == "MANYTOONE":
values_map[name] = _coerce_fk_value(values, instance, base)
else:
# Auto-generate path (your original behavior)
fk_fields = set()
# Relationships first
for prop in mapper.iterate_properties:
if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE':
base = prop.key
if base in exclude or f"{base}_id" in exclude:
continue
if session is None:
continue
related_model = prop.mapper.class_
rel_label_spec = (
label_specs.get(base)
or getattr(related_model, "__crud_label__", None)
or "id"
)
options = _fk_options(session, related_model, rel_label_spec)
base_field = {
"name": f"{base}_id",
"label": base,
"type": "select",
"options": options,
}
field = {**base_field, **overrides.get(f"{base}_id", {})}
fields.append(field)
fk_fields.add(f"{base}_id")
# NEW: set the current selection for this dropdown
values_map[f"{base}_id"] = _coerce_fk_value(values, instance, base)
# Then plain columns
for col in model_cls.__table__.columns:
if col.name in fk_fields or col.name in exclude:
continue
if col.name in ('id', 'created_at', 'updated_at'):
continue
if col.default or col.server_default or col.onupdate:
continue
base_field = {
"name": col.name,
"label": col.name,
"type": "checkbox" if getattr(col.type, "python_type", None) is bool else "text",
}
field = {**base_field, **overrides.get(col.name, {})}
if field.get("wrap"):
field["wrap"] = _sanitize_attrs(field["wrap"])
fields.append(field)
if submit_attrs:
submit_attrs = _sanitize_attrs(submit_attrs)
common_ctx = {"values": values_map, "instance": instance, "model_cls": model_cls, "session": session}
for f in fields:
if f.get("type") == "template":
base = dict(common_ctx)
base.update(f.get("template_ctx") or {})
f["template_ctx"] = base
for f in fields:
vl = _value_label_for_field(f, mapper, values_map, instance, session)
if vl is not None:
f["value_label"] = vl
# Build rows (supports nested layout with parents)
rows_map = _normalize_rows_layout(layout)
rows_tree = _assign_fields_to_rows(fields, rows_map)
return template.render(
rows=rows_tree,
fields=fields, # keep for backward compatibility
values=values_map,
render_field=render_field,
submit_attrs=submit_attrs,
submit_label=submit_label
)