From de5e5b4a43349f10c7d1b68b1b6d9e0c9e799e89 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 11 Sep 2025 08:12:41 -0500 Subject: [PATCH] Implementing useful UI changes. --- crudkit/core/base.py | 2 +- crudkit/ui/fragments.py | 130 ++++++++++++++++++++++--- crudkit/ui/templates/table.html | 34 ++++--- inventory/routes/index.py | 23 +++-- inventory/templates/base.html | 4 +- inventory/templates/crudkit/table.html | 26 +++++ 6 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 inventory/templates/crudkit/table.html diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 2204778..46874fe 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -34,7 +34,7 @@ class CRUDMixin: return out result = {} - for cls in self.__clas__.__mro__: + for cls in self.__class__.__mro__: if hasattr(cls, "__table__"): for column in cls.__table__.columns: name = column.name diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index cef0f3c..e60559d 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -1,21 +1,106 @@ import os -from flask import current_app +from flask import current_app, url_for from jinja2 import Environment, FileSystemLoader, ChoiceLoader +from sqlalchemy import inspect from sqlalchemy.orm import class_mapper, RelationshipProperty -from typing import List +from typing import Any, Dict, List, Optional, Tuple def get_env(): - app_loader = current_app.jinja_loader - + app = current_app default_path = os.path.join(os.path.dirname(__file__), 'templates') fallback_loader = FileSystemLoader(default_path) - env = Environment(loader=ChoiceLoader([ - app_loader, - fallback_loader - ])) - return env + return app.jinja_env.overlay( + loader=ChoiceLoader([app.jinja_loader, fallback_loader]) + ) + +def _is_rel_loaded(obj, rel_name: str) -> bool: + try: + state = inspect(obj) + return state.attrs[rel_name].loaded_value is not None + 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): + print(f"WARNING: {cur}.{part} is not loaded!") + 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 _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: + 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 get_crudkit_template(env, name): try: @@ -34,15 +119,34 @@ def render_field(field, value): options=field.get('options', None) ) -def render_table(objects, headers: List[str] | None = None): +def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None): env = get_env() template = get_crudkit_template(env, 'table.html') + if not objects: return template.render(fields=[], rows=[]) + proj = getattr(objects[0], "__crudkit_projection__", None) - rows = [obj.as_dict(proj) for obj in objects] - fields = list(rows[0].keys()) - return template.render(fields=fields, rows=rows, headers=headers) + 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) + + 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}) + disp_rows.append({"id": rd.get("id"), "cells": cells}) + + print(disp_rows) + + return template.render(columns=cols, rows=disp_rows) def render_form(model_cls, values, session=None): env = get_env() diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index 4005c0c..06379cf 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -1,20 +1,26 @@ + + + {% for col in columns %} + + {% endfor %} + + + {% if rows %} - + {% for row in rows %} - {% if headers %} - {% for header in headers %}{% endfor %} + {% for cell in row.cells %} + {% if cell.href %} + {% else %} - {% for field in fields if field != "id" %}{% endfor %} + {% endif %} + {% endfor %} - - - {% for row in rows %} - {% for _, cell in row.items() if _ != "id" %}{% endfor %} - {% endfor %} - {% else %} - - {% endif %} - -
{{ col.label }}
{{ header }}{{ cell.text if cell.text is not none else '-' }}{{ field }}{{ cell.text if cell.text is not none else '-' }}
{{ cell if cell else "-" }}
No data.
\ No newline at end of file + {% endfor %} + {% else %} + No data. + {% endif %} + + diff --git a/inventory/routes/index.py b/inventory/routes/index.py index fd02776..b50ea08 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -21,18 +21,25 @@ def init_index_routes(app): "fields": [ "start_time", "contact.last_name", + "contact.first_name", "work_item.name" ], - "limit": 10 + "sort": "start_time" }) - headers = [ - "Start Time", - "Contact Last Name", - "Work Item" - ] - logs = render_table(work_logs, headers) - return render_template("index.html", logs=logs, headers=headers) + columns = [ + {"field": "start_time", "label": "Start", "format": "date"}, + {"field": "contact.last_name", "label": "Contact", + "link": {"endpoint": "contact.entry", "params": {"id": "{contact.id}"}}}, + {"field": "work_item.name", "label": "Work Item", + "link": {"endpoint": "work_item.entry", "params": {"id": "{work_item.id}"}}}, + {"field": "complete", "label": "Status", + "format": "yesno", "classes": {"true":"badge bg-success","false":"badge bg-warning","none":"text-muted"}}, + ] + + logs = render_table(work_logs, columns=columns) + + return render_template("index.html", logs=logs, columns=columns) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/templates/base.html b/inventory/templates/base.html index 3d639e5..0983a8d 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -37,8 +37,8 @@