Making tables a little smarter.
This commit is contained in:
parent
cf795086f1
commit
3f677fceee
4 changed files with 157 additions and 14 deletions
|
|
@ -4,6 +4,7 @@ from flask import current_app, url_for
|
||||||
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.orm import class_mapper, RelationshipProperty
|
from sqlalchemy.orm import class_mapper, RelationshipProperty
|
||||||
|
from sqlalchemy.orm.attributes import NO_VALUE
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
def get_env():
|
def get_env():
|
||||||
|
|
@ -15,10 +16,88 @@ def get_env():
|
||||||
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
def _is_rel_loaded(obj, rel_name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
state = inspect(obj)
|
state = inspect(obj)
|
||||||
return state.attrs[rel_name].loaded_value is not None
|
attr = state.attrs[rel_name]
|
||||||
|
return attr.loaded_value is not NO_VALUE
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -27,7 +106,6 @@ def _deep_get_from_obj(obj, dotted: str):
|
||||||
parts = dotted.split(".")
|
parts = dotted.split(".")
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
if i < len(parts) - 1 and not _is_rel_loaded(cur, part):
|
if i < len(parts) - 1 and not _is_rel_loaded(cur, part):
|
||||||
print(f"WARNING: {cur}.{part} is not loaded!")
|
|
||||||
return None
|
return None
|
||||||
cur = getattr(cur, part, None)
|
cur = getattr(cur, part, None)
|
||||||
if cur is None:
|
if cur is None:
|
||||||
|
|
@ -82,12 +160,10 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]
|
||||||
else:
|
else:
|
||||||
params[k] = v
|
params[k] = v
|
||||||
if any(v is None for v in params.values()):
|
if any(v is None for v in params.values()):
|
||||||
print(f"[render_table] url_for failed: endpoint={spec}: params={params}")
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return url_for('crudkit.' + spec["endpoint"], **params)
|
return url_for('crudkit.' + spec["endpoint"], **params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _humanize(field: str) -> str:
|
def _humanize(field: str) -> str:
|
||||||
|
|
@ -104,6 +180,28 @@ def _normalize_columns(columns: Optional[List[Dict[str, Any]]], default_fields:
|
||||||
norm.append(c)
|
norm.append(c)
|
||||||
return norm
|
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):
|
def get_crudkit_template(env, name):
|
||||||
try:
|
try:
|
||||||
return env.get_template(f'crudkit/{name}')
|
return env.get_template(f'crudkit/{name}')
|
||||||
|
|
@ -128,12 +226,16 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N
|
||||||
if not objects:
|
if not objects:
|
||||||
return template.render(fields=[], rows=[])
|
return template.render(fields=[], rows=[])
|
||||||
|
|
||||||
|
flat_opts = _normalize_opts(opts)
|
||||||
|
|
||||||
proj = getattr(objects[0], "__crudkit_projection__", None)
|
proj = getattr(objects[0], "__crudkit_projection__", None)
|
||||||
row_dicts = [obj.as_dict(proj) for obj in objects]
|
row_dicts = [obj.as_dict(proj) for obj in objects]
|
||||||
|
|
||||||
default_fields = [k for k in row_dicts[0].keys() if k != "id"]
|
default_fields = [k for k in row_dicts[0].keys() if k != "id"]
|
||||||
cols = _normalize_columns(columns, default_fields)
|
cols = _normalize_columns(columns, default_fields)
|
||||||
|
|
||||||
|
row_rules = (flat_opts.get("row_classes") or [])
|
||||||
|
|
||||||
disp_rows = []
|
disp_rows = []
|
||||||
for obj, rd in zip(objects, row_dicts):
|
for obj, rd in zip(objects, row_dicts):
|
||||||
cells = []
|
cells = []
|
||||||
|
|
@ -144,9 +246,11 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N
|
||||||
href = _build_href(col.get("link"), rd, obj) if col.get("link") else None
|
href = _build_href(col.get("link"), rd, obj) if col.get("link") else None
|
||||||
cls = _class_for(raw, col.get("classes"))
|
cls = _class_for(raw, col.get("classes"))
|
||||||
cells.append({"text": text, "href": href, "class": cls})
|
cells.append({"text": text, "href": href, "class": cls})
|
||||||
disp_rows.append({"id": rd.get("id"), "cells": cells})
|
|
||||||
|
|
||||||
return template.render(columns=cols, rows=disp_rows, kwargs=opts)
|
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):
|
def render_form(model_cls, values, session=None):
|
||||||
env = get_env()
|
env = get_env()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ def init_listing_routes(app):
|
||||||
|
|
||||||
spec = {}
|
spec = {}
|
||||||
columns = []
|
columns = []
|
||||||
|
row_classes = []
|
||||||
if model.lower() == 'inventory':
|
if model.lower() == 'inventory':
|
||||||
spec = {"fields": [
|
spec = {"fields": [
|
||||||
"label",
|
"label",
|
||||||
|
|
@ -44,22 +45,49 @@ def init_listing_routes(app):
|
||||||
{"field": "owner.label", "label": "Contact", "link": {"endpoint": "user.get_item", "params": {"id": "{owner.id}"}}},
|
{"field": "owner.label", "label": "Contact", "link": {"endpoint": "user.get_item", "params": {"id": "{owner.id}"}}},
|
||||||
{"field": "location.label", "label": "Room"},
|
{"field": "location.label", "label": "Room"},
|
||||||
]
|
]
|
||||||
if model.lower() == 'user':
|
elif model.lower() == 'user':
|
||||||
spec = {"fields": [
|
spec = {"fields": [
|
||||||
"label",
|
"label",
|
||||||
"last_name",
|
"last_name",
|
||||||
"first_name",
|
"first_name",
|
||||||
"supervisor.label",
|
"supervisor.label",
|
||||||
|
"robot.overlord",
|
||||||
"staff",
|
"staff",
|
||||||
"active",
|
"active",
|
||||||
]}
|
], "sort": "first_name,last_name"}
|
||||||
columns = [
|
columns = [
|
||||||
{"field": "label", "label": "Full Name"},
|
{"field": "label", "label": "Full Name"},
|
||||||
{"field": "last_name"},
|
{"field": "last_name"},
|
||||||
{"field": "first_name"},
|
{"field": "first_name"},
|
||||||
{"field": "supervisor.label", "label": "Supervisor", "link": {"endpoint": "user.get_item", "params": {"id": "{supervisor.id}"}}},
|
{"field": "supervisor.label", "label": "Supervisor", "link": {"endpoint": "user.get_item", "params": {"id": "{supervisor.id}"}}},
|
||||||
{"field": "staff"},
|
{"field": "staff", "format": "yesno"},
|
||||||
{"field": "active"},
|
{"field": "active", "format": "yesno"},
|
||||||
|
]
|
||||||
|
row_classes = [
|
||||||
|
{"when": {"field": "active", "is": False}, "class": "table-secondary"},
|
||||||
|
{"when": {"all": [
|
||||||
|
{"field": "staff", "is": False},
|
||||||
|
{"field": "active", "is": True}
|
||||||
|
]}, "class": "table-success"},
|
||||||
|
]
|
||||||
|
elif model.lower() == 'worklog':
|
||||||
|
spec = {"fields": [
|
||||||
|
"work_item.label",
|
||||||
|
"contact.label",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"complete",
|
||||||
|
]}
|
||||||
|
columns = [
|
||||||
|
{"field": "work_item.label", "label": "Work Item", "link": {"endpoint": "inventory.get_item", "params": {"id": "{work_item.id}"}}},
|
||||||
|
{"field": "contact.label", "label": "Contact", "link": {"endpoint": "user.get_item", "params": {"id": "{contact.id}"}}},
|
||||||
|
{"field": "start_time", "format": "datetime"},
|
||||||
|
{"field": "end_time", "format": "datetime"},
|
||||||
|
{"field": "complete", "format": "yesno"},
|
||||||
|
]
|
||||||
|
row_classes = [
|
||||||
|
{"when": {"field": "complete", "is": True}, "class": "table-success"},
|
||||||
|
{"when": {"field": "complete", "is": False}, "class": "table-danger"}
|
||||||
]
|
]
|
||||||
spec["limit"] = 15
|
spec["limit"] = 15
|
||||||
spec["offset"] = (page_num - 1) * 15
|
spec["offset"] = (page_num - 1) * 15
|
||||||
|
|
@ -67,7 +95,7 @@ def init_listing_routes(app):
|
||||||
service = crudkit.crud.get_service(cls)
|
service = crudkit.crud.get_service(cls)
|
||||||
rows = service.list(spec)
|
rows = service.list(spec)
|
||||||
|
|
||||||
table = render_table(rows, columns=columns, opts={"object_class": model})
|
table = render_table(rows, columns=columns, opts={"object_class": model, "row_classes": row_classes})
|
||||||
|
|
||||||
return render_template("listing.html", model=model, table=table)
|
return render_template("listing.html", model=model, table=table)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,22 @@
|
||||||
<body class="pb-5">
|
<body class="pb-5">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar bg-body-tertiary border border-top-0">
|
<nav class="navbar navbar-expand-sm bg-body-tertiary border border-top-0">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('index.index') }}">
|
<a class="navbar-brand" href="{{ url_for('index.index') }}">
|
||||||
{{ title if title else "Inventory Manager" }}
|
{{ title if title else "Inventory Manager" }}
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ url_for('listing.show_list', model='inventory') }}" class="nav-link link-success fw-semibold">Inventory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ url_for('listing.show_list', model='worklog') }}" class="nav-link link-success fw-semibold">Work Log</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ url_for('listing.show_list', model='user') }}" class="nav-link link-success fw-semibold">Users</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="table-responsive mx-5" style="max-height: 80vh;">
|
<div class="table-responsive mx-5" style="max-height: 80vh;">
|
||||||
<table class="table table-light table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
|
<table class="table table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% for col in columns %}
|
{% for col in columns %}
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr onclick="location.href='{{ url_for( 'crudkit.' + kwargs['opts']['object_class'] + '.get_item', id=row.id) }}'" style="cursor: pointer;">
|
<tr onclick="location.href='{{ url_for( 'crudkit.' + kwargs['object_class'] + '.get_item', id=row.id) }}'" style="cursor: pointer;" class="{{ row.class or '' }}">
|
||||||
{% for cell in row.cells %}
|
{% for cell in row.cells %}
|
||||||
{% if cell.href %}
|
{% if cell.href %}
|
||||||
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue