diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 1f74f3a..ddef704 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -26,6 +26,24 @@ def get_env(): 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. @@ -173,6 +191,69 @@ def _sanitize_attrs(attrs: Any) -> dict[str, Any]: 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",) @@ -181,10 +262,8 @@ class _SafeObj: def __getattr__(self, name): if self._obj is None: return "" - val = getattr(self._obj, name, None) - if val is None: - return "" - return _SafeObj(val) + 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): """ @@ -314,6 +393,7 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default): "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: @@ -387,41 +467,24 @@ def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, s 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 obj is None: + return "" + if spec is None: for attr in ("label", "name", "title", "description"): - if hasattr(obj, attr): - val = getattr(obj, attr) - if not callable(val) and val is not None: - return str(val) - if hasattr(obj, "id"): - return str(getattr(obj, "id")) - return object.__repr__(obj) + 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 = getattr(cur, part, None) + 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)) @@ -433,9 +496,7 @@ def _label_from_obj(obj: Any, spec: Any) -> str: for f in fields: root = f.split(".", 1)[0] if root not in data: - val = getattr(obj, root, None) - data[root] = _SafeObj(val) - + data[root] = _SafeObj(_get_loaded_attr(obj, root)) try: return spec.format(**data) except Exception: @@ -443,7 +504,7 @@ def _label_from_obj(obj: Any, spec: Any) -> str: cur = obj for part in str(spec).split("."): - cur = getattr(cur, part, None) + cur = _get_loaded_attr(cur, part) if cur is not None else None if cur is None: return "" return str(cur) @@ -572,6 +633,28 @@ def _format_value(val: Any, fmt: Optional[str]) -> Any: 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 @@ -642,6 +725,7 @@ def get_crudkit_template(env, name): def render_field(field, value): env = get_env() + print(field) # 1) custom template field field_type = field.get('type', 'text') @@ -668,6 +752,7 @@ def render_field(field, value): 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'), ) @@ -825,6 +910,11 @@ def render_form( 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) diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html index 0c0a39b..81b7062 100644 --- a/crudkit/ui/templates/field.html +++ b/crudkit/ui/templates/field.html @@ -30,7 +30,7 @@ + {% endfor %}{% endif %}>{{ value if value else "" }} {% elif field_type == 'checkbox' %} {% elif field_type == 'hidden' %} - + {% elif field_type == 'display' %}
{{ value }}
+ {% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }} {% else %} - diff --git a/inventory/__init__.py b/inventory/__init__.py index ce7f87f..c55ba5a 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -6,6 +6,8 @@ import crudkit from crudkit.integrations.flask import init_app +from .debug_pretty import init_pretty + from .routes.index import init_index_routes from .routes.listing import init_listing_routes from .routes.entry import init_entry_routes @@ -13,6 +15,8 @@ from .routes.entry import init_entry_routes def create_app(config_cls=crudkit.DevConfig) -> Flask: app = Flask(__name__) + init_pretty(app) + runtime = init_app(app, config=crudkit.ProdConfig) crudkit.init_crud(app) print(f"Effective DB URL: {str(runtime.engine.url)}") diff --git a/inventory/debug_pretty.py b/inventory/debug_pretty.py new file mode 100644 index 0000000..27636e2 --- /dev/null +++ b/inventory/debug_pretty.py @@ -0,0 +1,31 @@ +from flask import request +from werkzeug.wrappers.response import Response + +def init_pretty(app): + @app.after_request + def _pretty_html(resp: Response): + if not app.debug: + print("Not debugging.") + return resp + if resp.mimetype != "text/html": + return resp + if request.args.get("pretty") != "1": + return resp + + html = resp.get_data(as_text=True) + try: + # Prefer lxml if present; falls back to bs4 + from lxml import html as lhtml + pretty = lhtml.tostring( + lhtml.fromstring(html), + encoding="unicode", + method="html", + pretty_print=True, + ) + except Exception: + from bs4 import BeautifulSoup + pretty = BeautifulSoup(html, "html.parser").prettify(formatter="html") + + resp.set_data(pretty) + resp.headers["Content-Length"] = str(len(pretty.encode("utf-8"))) + return resp diff --git a/inventory/models/user.py b/inventory/models/user.py index 0b09c0d..1447ef1 100644 --- a/inventory/models/user.py +++ b/inventory/models/user.py @@ -1,6 +1,6 @@ from typing import List, Optional, TYPE_CHECKING -from sqlalchemy import Boolean, Integer, ForeignKey, Unicode, func +from sqlalchemy import Boolean, Integer, ForeignKey, Unicode, case, func, literal from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression as sql @@ -41,8 +41,25 @@ class User(Base, CRUDMixin): @hybrid_property def label(self): - return f"{self.first_name} {self.last_name}" + out = f"{self.first_name} {self.last_name}" + if self.title: + out = out + f" ({self.title})" + return out @label.expression def label(cls): - return func.concat(cls.first_name, " ", cls.last_name) + first = func.coalesce(cls.first_name, "") + last = func.coalesce(cls.last_name, "") + title = func.coalesce(cls.title, "") + + have_first = func.length(func.trim(first)) > 0 + have_last = func.length(func.trim(last)) > 0 + + space = case((have_first & have_last, literal(" ")), else_=literal("")) + + title_part = case( + (func.length(func.trim(title)) > 0, func.concat(" (", title, ")")), + else_=literal("") + ) + + return func.concat(first, space, last, title_part) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 000b0a4..a247413 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -21,35 +21,65 @@ def init_entry_routes(app): fields_spec = [] layout = [] if model == "inventory": - fields["fields"] = ["label", "name", "barcode", "serial"] + fields["fields"] = ["label", "name", "serial", "barcode", "brand", "model", "device_type", "owner", "location", "condition", "image"] fields_spec = [ - {"name": "label", "label": "", "row": "label", "wrap": {"class": "col"}}, - {"name": "name", "label": "Name", "row": "identification", "wrap": {"class": "col"}}, - {"name": "barcode", "label": "Bar Code #", "row": "identification", "wrap": {"class": "col"}}, - {"name": "serial", "label": "Serial #", "row": "identification", "wrap": {"class": "col"}}, + {"name": "label", "type": "display", "label": "", + "label_attrs": {"class": "display-6 me-2"}, "row": "label", + "attrs": {"class": "display-6 mb-3"}}, + + {"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"}, + "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, + {"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, + {"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, + + {"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}}, + {"name": "model", "row": "device", "wrap": {"class": "col"}, "attrs": {"class": "form-control"}, + "label": "Model #", "label_attrs": {"class": "form-label"}}, + {"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}}, + + {"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, + "label_spec": "{first_name} {last_name}"}, + {"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"}, + "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, + "label_spec": "{name} - {room_function.description}"}, + {"name": "condition", "row": "status", "label": "Condition", "wrap": {"class": "col"}, + "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"}} ] layout = [ - {"name": "label", "order": 10, "attrs": {"class": "row"}}, - {"name": "identification", "order": 20, "attrs": {"class": "row"}}, + {"name": "label", "order": 5}, + {"name": "kitchen_sink", "order": 6, "attrs": {"class": "row"}}, + {"name": "everything", "order": 10, "attrs": {"class": "col"}, "parent": "kitchen_sink"}, + {"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"} ] elif model.lower() == 'user': fields["fields"] = ["label", "first_name", "last_name", "title", "active", "staff", "location", "supervisor"] fields_spec = [ - {"name": "label", "row": "label", "label": "User Record", - "label_attrs": {"class": "display-6"}, "type": "display", - "attrs": {"class": "display-4 mb-3"}, "wrap": {"class": "text-center"}}, + {"name": "label", "row": "label", "label": "", + "label_attrs": {"class": "display-6 me-2"}, "type": "display", + "attrs": {"class": "display-6 mb-3"}}, {"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"}, "attrs": {"placeholder": "Doe", "class": "form-control"}, - "row": "name", "wrap": {"class": "col-2"}}, + "row": "name", "wrap": {"class": "col-3"}}, {"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"}, "attrs": {"placeholder": "John", "class": "form-control"}, - "row": "name", "wrap": {"class": "col-2"}}, + "row": "name", "wrap": {"class": "col-3"}}, {"name": "title", "label": "Title", "label_attrs": {"class": "form-label"}, "attrs": {"placeholder": "President of the Universe", "class": "form-control"}, - "row": "name", "wrap": {"class": "col-2"}}, + "row": "name", "wrap": {"class": "col-3"}}, {"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"}, "label_spec": "{first_name} {last_name}", "row": "details", "wrap": {"class": "col-3"}, @@ -67,8 +97,8 @@ def init_entry_routes(app): ] layout = [ {"name": "label", "order": 0}, - {"name": "name", "order": 10, "attrs": {"class": "row mb-3"}}, - {"name": "details", "order": 20, "attrs": {"class": "row"}}, + {"name": "name", "order": 10, "attrs": {"class": "row"}}, + {"name": "details", "order": 20, "attrs": {"class": "row mt-2"}}, {"name": "checkboxes", "order": 30, "parent": "name", "attrs": {"class": "col d-flex flex-column justify-content-end"}} ] elif model == "worklog": @@ -85,7 +115,7 @@ def init_entry_routes(app): instance=obj, fields_spec=fields_spec, layout=layout, - submit_attrs={"class": "btn btn-primary"} + submit_attrs={"class": "btn btn-primary mt-3"} ) return render_template("entry.html", form=form) diff --git a/inventory/templates/image_display.html b/inventory/templates/image_display.html new file mode 100644 index 0000000..4d922bf --- /dev/null +++ b/inventory/templates/image_display.html @@ -0,0 +1,4 @@ +{{ value }} \ No newline at end of file