Implementing useful UI changes.
This commit is contained in:
parent
7ddfe084ba
commit
de5e5b4a43
6 changed files with 181 additions and 38 deletions
|
|
@ -34,7 +34,7 @@ class CRUDMixin:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for cls in self.__clas__.__mro__:
|
for cls in self.__class__.__mro__:
|
||||||
if hasattr(cls, "__table__"):
|
if hasattr(cls, "__table__"):
|
||||||
for column in cls.__table__.columns:
|
for column in cls.__table__.columns:
|
||||||
name = column.name
|
name = column.name
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,106 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import current_app
|
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.orm import class_mapper, RelationshipProperty
|
from sqlalchemy.orm import class_mapper, RelationshipProperty
|
||||||
from typing import List
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
def get_env():
|
def get_env():
|
||||||
app_loader = current_app.jinja_loader
|
app = current_app
|
||||||
|
|
||||||
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
||||||
fallback_loader = FileSystemLoader(default_path)
|
fallback_loader = FileSystemLoader(default_path)
|
||||||
|
|
||||||
env = Environment(loader=ChoiceLoader([
|
return app.jinja_env.overlay(
|
||||||
app_loader,
|
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
||||||
fallback_loader
|
)
|
||||||
]))
|
|
||||||
return env
|
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):
|
def get_crudkit_template(env, name):
|
||||||
try:
|
try:
|
||||||
|
|
@ -34,15 +119,34 @@ def render_field(field, value):
|
||||||
options=field.get('options', None)
|
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()
|
env = get_env()
|
||||||
template = get_crudkit_template(env, 'table.html')
|
template = get_crudkit_template(env, 'table.html')
|
||||||
|
|
||||||
if not objects:
|
if not objects:
|
||||||
return template.render(fields=[], rows=[])
|
return template.render(fields=[], rows=[])
|
||||||
|
|
||||||
proj = getattr(objects[0], "__crudkit_projection__", None)
|
proj = getattr(objects[0], "__crudkit_projection__", None)
|
||||||
rows = [obj.as_dict(proj) for obj in objects]
|
row_dicts = [obj.as_dict(proj) for obj in objects]
|
||||||
fields = list(rows[0].keys())
|
|
||||||
return template.render(fields=fields, rows=rows, headers=headers)
|
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):
|
def render_form(model_cls, values, session=None):
|
||||||
env = get_env()
|
env = get_env()
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
<table>
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for col in columns %}
|
||||||
|
<th>{{ col.label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<thead>
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if headers %}
|
{% for cell in row.cells %}
|
||||||
{% for header in headers %}<th>{{ header }}</th>{% endfor %}
|
{% if cell.href %}
|
||||||
|
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for field in fields if field != "id" %}<th>{{ field }}</th>{% endfor %}
|
<td class="{{ cell.class or '' }}">{{ cell.text if cell.text is not none else '-' }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{% endfor %}
|
||||||
<tbody>
|
{% else %}
|
||||||
{% for row in rows %}
|
<tr><td colspan="{{ columns|length }}">No data.</td></tr>
|
||||||
<tr>{% for _, cell in row.items() if _ != "id" %}<td>{{ cell if cell else "-" }}</td>{% endfor %}</tr>
|
{% endif %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
{% else %}
|
|
||||||
<tr><th>No data.</th></tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -21,18 +21,25 @@ def init_index_routes(app):
|
||||||
"fields": [
|
"fields": [
|
||||||
"start_time",
|
"start_time",
|
||||||
"contact.last_name",
|
"contact.last_name",
|
||||||
|
"contact.first_name",
|
||||||
"work_item.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")
|
@bp_index.get("/LICENSE")
|
||||||
def license():
|
def license():
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@
|
||||||
<footer class="bg-body-tertiary border border-bottom-0 position-absolute bottom-0 w-100 pb-1">
|
<footer class="bg-body-tertiary border border-bottom-0 position-absolute bottom-0 w-100 pb-1">
|
||||||
<small>
|
<small>
|
||||||
<span class="align-middle">© 2025 Conrad Nelson •
|
<span class="align-middle">© 2025 Conrad Nelson •
|
||||||
<a href="/LICENSE">AGPL-3.0-or-later</a> •
|
<a href="/LICENSE" class="link-underline link-underline-opacity-0">AGPL-3.0-or-later</a> •
|
||||||
<a href="https://git.kasear.net/yaro/inventory">Source Code</a>
|
<a href="https://git.kasear.net/yaro/inventory" class="link-underline link-underline-opacity-0">Source Code</a>
|
||||||
</span>
|
</span>
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
26
inventory/templates/crudkit/table.html
Normal file
26
inventory/templates/crudkit/table.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for col in columns %}
|
||||||
|
<th>{{ col.label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if rows %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
{% if cell.href %}
|
||||||
|
<td class="{{ cell.class or '' }}"><a href="{{ cell.href }}">{{ cell.text if cell.text is not none else '-' }}</a></td>
|
||||||
|
{% else %}
|
||||||
|
<td class="{{ cell.class or '' }}">{{ cell.text if cell.text is not none else '-' }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="{{ columns|length }}">No data.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue