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. 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['_id'] 2) values['']['id'] or values[''] if it's an int or numeric string 3) instance. (if already loaded) -> use its .id [safe for detached] 4) instance._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": , "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 _format_label_from_values(spec: Any, values: dict) -> Optional[str]: if not spec: return None if isinstance(spec, (list, tuple)): parts = [] for a in spec: v = _deep_get(values, str(a)) parts.append("" if v is None else str(v)) return " ".join(p for p in parts if p) s = str(spec) if "{" in s and "}" in s: names = re.findall(r"{\s*([^}:\s]+)", s) data = {n: _deep_get(values, n) for n in names} # wrap for safe .format() data = {k: ("" if v is None else v) for k, v in data.items()} try: return s.format(**data) except Exception: return None # simple field name v = _deep_get(values, s) return "" if v is None else str(v) 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 for f in fields: # existing FK label resolution vl = _value_label_for_field(f, mapper, values_map, instance, session) if vl is not None: f["value_label"] = vl # NEW: if not a relationship but a label_spec is provided, format from values elif f.get("label_spec"): base, rel_prop = _rel_for_id_name(mapper, f["name"]) if not rel_prop: # scalar field vl2 = _format_label_from_values(f["label_spec"], values_map) if vl2 is not None: f["value_label"] = vl2 # 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 )