Compare commits
13 commits
ae54277e58
...
5234cbdd61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5234cbdd61 | ||
|
|
5718deee6b | ||
|
|
43b3df9938 | ||
|
|
38bae34247 | ||
|
|
6357e5794f | ||
|
|
8d26d5b084 | ||
|
|
ce7d092be4 | ||
|
|
01a0031cf4 | ||
|
|
e829de9792 | ||
|
|
15ae0caf27 | ||
|
|
c8f5c7011e | ||
|
|
ade5ceaae5 | ||
|
|
81e02bbabd |
25 changed files with 650 additions and 687 deletions
|
|
@ -2,29 +2,45 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, abort
|
||||
from flask import Blueprint, jsonify, request, abort, current_app, url_for
|
||||
from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from crudkit.api._cursor import encode_cursor, decode_cursor
|
||||
from crudkit.core.service import _is_truthy
|
||||
|
||||
MAX_JSON = 1_000_000
|
||||
|
||||
def _etag_for(obj) -> str:
|
||||
v = getattr(obj, "updated_at", None) or obj.id
|
||||
return md5(str(v).encode()).hexdigest()
|
||||
|
||||
def _json_payload() -> dict:
|
||||
if request.content_length and request.content_length > MAX_JSON:
|
||||
abort(413)
|
||||
if not request.is_json:
|
||||
abort(415)
|
||||
payload = request.get_json(silent=False)
|
||||
if not isinstance(payload, dict):
|
||||
abort(400)
|
||||
return payload
|
||||
|
||||
def _args_flat() -> dict[str, str]:
|
||||
return request.args.to_dict(flat=True) # type: ignore[arg-type]
|
||||
|
||||
def _json_error(e: Exception, status: int = 400):
|
||||
if isinstance(e, HTTPException):
|
||||
status = e.code or status
|
||||
msg = e.description
|
||||
else:
|
||||
msg = str(e)
|
||||
if current_app.debug:
|
||||
return jsonify({"status": "error", "error": msg, "type": e.__class__.__name__}), status
|
||||
return jsonify({"status": "error", "error": msg}), status
|
||||
|
||||
def _bool_param(d: dict[str, str], key: str, default: bool) -> bool:
|
||||
return _is_truthy(d.get(key, "1" if default else "0"))
|
||||
|
||||
|
||||
def _safe_int(value: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _link_with_params(base_url: str, **params) -> str:
|
||||
q = {k: v for k, v in params.items() if v is not None}
|
||||
return f"{base_url}?{urlencode(q)}"
|
||||
|
||||
|
||||
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
|
||||
"""
|
||||
REST:
|
||||
|
|
@ -50,65 +66,75 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
|
|||
|
||||
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
|
||||
|
||||
@bp.errorhandler(Exception)
|
||||
def _handle_any(e: Exception):
|
||||
return _json_error(e)
|
||||
|
||||
@bp.errorhandler(404)
|
||||
def _not_found(_e):
|
||||
return jsonify({"status": "error", "error": "not found"}), 404
|
||||
|
||||
# ---------- REST ----------
|
||||
if rest:
|
||||
@bp.get("/")
|
||||
def rest_list():
|
||||
args = request.args.to_dict(flat=True)
|
||||
args = _args_flat()
|
||||
# support cursor pagination transparently; fall back to limit/offset
|
||||
try:
|
||||
items = service.list(args)
|
||||
return jsonify([o.as_dict() for o in items])
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.get("/<int:obj_id>")
|
||||
def rest_get(obj_id: int):
|
||||
try:
|
||||
item = service.get(obj_id, request.args)
|
||||
if item is None:
|
||||
abort(404)
|
||||
return jsonify(item.as_dict())
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
etag = _etag_for(item)
|
||||
if request.if_none_match and (etag in request.if_none_match):
|
||||
return "", 304
|
||||
resp = jsonify(item.as_dict())
|
||||
resp.set_etag(etag)
|
||||
return resp
|
||||
|
||||
@bp.post("/")
|
||||
def rest_create():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
payload = _json_payload()
|
||||
try:
|
||||
obj = service.create(payload)
|
||||
resp = jsonify(obj.as_dict())
|
||||
resp.status_code = 201
|
||||
resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}"
|
||||
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
|
||||
return resp
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.patch("/<int:obj_id>")
|
||||
def rest_update(obj_id: int):
|
||||
payload = request.get_json(silent=True) or {}
|
||||
payload = _json_payload()
|
||||
try:
|
||||
obj = service.update(obj_id, payload)
|
||||
return jsonify(obj.as_dict())
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.delete("/<int:obj_id>")
|
||||
def rest_delete(obj_id: int):
|
||||
hard = (request.args.get("hard") in ("1", "true", "yes"))
|
||||
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
|
||||
try:
|
||||
obj = service.delete(obj_id, hard=hard)
|
||||
if obj is None:
|
||||
abort(404)
|
||||
return ("", 204)
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
# ---------- RPC (your existing routes) ----------
|
||||
if rpc:
|
||||
# your original functions verbatim, shortened here for sanity
|
||||
@bp.get("/get")
|
||||
def rpc_get():
|
||||
print("⚠️ WARNING: Deprecated RPC call used: /get")
|
||||
id_ = int(request.args.get("id", 0))
|
||||
if not id_:
|
||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||
|
|
@ -118,48 +144,52 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
|
|||
abort(404)
|
||||
return jsonify(item.as_dict())
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.get("/list")
|
||||
def rpc_list():
|
||||
args = request.args.to_dict(flat=True)
|
||||
print("⚠️ WARNING: Deprecated RPC call used: /list")
|
||||
args = _args_flat()
|
||||
try:
|
||||
items = service.list(args)
|
||||
return jsonify([obj.as_dict() for obj in items])
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.post("/create")
|
||||
def rpc_create():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
print("⚠️ WARNING: Deprecated RPC call used: /create")
|
||||
payload = _json_payload()
|
||||
try:
|
||||
obj = service.create(payload)
|
||||
return jsonify(obj.as_dict()), 201
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.patch("/update")
|
||||
def rpc_update():
|
||||
print("⚠️ WARNING: Deprecated RPC call used: /update")
|
||||
id_ = int(request.args.get("id", 0))
|
||||
if not id_:
|
||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||
payload = request.get_json(silent=True) or {}
|
||||
payload = _json_payload()
|
||||
try:
|
||||
obj = service.update(id_, payload)
|
||||
return jsonify(obj.as_dict())
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
@bp.delete("/delete")
|
||||
def rpc_delete():
|
||||
print("⚠️ WARNING: Deprecated RPC call used: /delete")
|
||||
id_ = int(request.args.get("id", 0))
|
||||
if not id_:
|
||||
return jsonify({"status": "error", "error": "missing required param: id"}), 400
|
||||
hard = (request.args.get("hard") in ("1", "true", "yes"))
|
||||
hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type]
|
||||
try:
|
||||
obj = service.delete(id_, hard=hard)
|
||||
return ("", 204) if obj is not None else abort(404)
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 400
|
||||
return _json_error(e)
|
||||
|
||||
return bp
|
||||
|
|
|
|||
|
|
@ -1,18 +1,66 @@
|
|||
from typing import Any, Dict, Iterable, List, Tuple, Set
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast
|
||||
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
|
||||
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty
|
||||
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper
|
||||
from sqlalchemy.orm.state import InstanceState
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def _safe_get_loaded_attr(obj, name):
|
||||
@lru_cache(maxsize=512)
|
||||
def _column_names_for_model(cls: type) -> tuple[str, ...]:
|
||||
try:
|
||||
mapper = inspect(cls)
|
||||
return tuple(prop.key for prop in mapper.column_attrs)
|
||||
except Exception:
|
||||
names: list[str] = []
|
||||
for c in cls.__mro__:
|
||||
if hasattr(c, "__table__"):
|
||||
names.extend(col.name for col in c.__table__.columns)
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
def _sa_state(obj: Any) -> Optional[InstanceState[Any]]:
|
||||
"""Safely get SQLAlchemy InstanceState (or None)."""
|
||||
try:
|
||||
st = inspect(obj)
|
||||
attr = st.attrs.get(name)
|
||||
return cast(Optional[InstanceState[Any]], st)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _sa_mapper(obj: Any) -> Optional[Mapper]:
|
||||
"""Safely get Mapper for a maooed instance (or None)."""
|
||||
try:
|
||||
st = inspect(obj)
|
||||
mapper = getattr(st, "mapper", None)
|
||||
return cast(Optional[Mapper], mapper)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _safe_get_loaded_attr(obj, name):
|
||||
st = _sa_state(obj)
|
||||
if st is None:
|
||||
return None
|
||||
try:
|
||||
st_dict = getattr(st, "dict", {})
|
||||
if name in st_dict:
|
||||
return st_dict[name]
|
||||
|
||||
attrs = getattr(st, "attrs", None)
|
||||
attr = None
|
||||
if attrs is not None:
|
||||
try:
|
||||
attr = attrs[name]
|
||||
except Exception:
|
||||
try:
|
||||
get = getattr(attrs, "get", None)
|
||||
if callable(get):
|
||||
attr = get(name)
|
||||
except Exception:
|
||||
attr = None
|
||||
|
||||
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 None
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -33,10 +81,7 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool:
|
|||
def _serialize_simple_obj(obj) -> Dict[str, Any]:
|
||||
"""Columns only (no relationships)."""
|
||||
out: Dict[str, Any] = {}
|
||||
for cls in obj.__class__.__mro__:
|
||||
if hasattr(cls, "__table__"):
|
||||
for col in cls.__table__.columns:
|
||||
name = col.name
|
||||
for name in _column_names_for_model(type(obj)):
|
||||
try:
|
||||
out[name] = getattr(obj, name)
|
||||
except Exception:
|
||||
|
|
@ -204,12 +249,16 @@ class CRUDMixin:
|
|||
|
||||
# Determine which relationships to consider
|
||||
try:
|
||||
st = inspect(self)
|
||||
mapper = st.mapper
|
||||
embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names
|
||||
mapper = _sa_mapper(self)
|
||||
embed_set = set(str(x).split(".", 1)[0] for x in (embed or []))
|
||||
if mapper is None:
|
||||
return data
|
||||
st = _sa_state(self)
|
||||
if st is None:
|
||||
return data
|
||||
for name, prop in mapper.relationships.items():
|
||||
# Only touch relationships that are already loaded; never lazy-load here.
|
||||
rel_loaded = st.attrs.get(name)
|
||||
rel_loaded = getattr(st, "attrs", {}).get(name)
|
||||
if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE:
|
||||
continue
|
||||
|
||||
|
|
@ -266,13 +315,10 @@ class CRUDMixin:
|
|||
val = None
|
||||
|
||||
# If it's a scalar ORM object (relationship), serialize its columns
|
||||
try:
|
||||
st = inspect(val) # will raise if not an ORM object
|
||||
if getattr(st, "mapper", None) is not None:
|
||||
mapper = _sa_mapper(val)
|
||||
if mapper is not None:
|
||||
out[name] = _serialize_simple_obj(val)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If it's a collection and no subfields were requested, emit a light list
|
||||
if isinstance(val, (list, tuple)):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .routes.index import init_index_routes
|
|||
from .routes.listing import init_listing_routes
|
||||
from .routes.search import init_search_routes
|
||||
from .routes.settings import init_settings_routes
|
||||
from .routes.reports import init_reports_routes
|
||||
|
||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
|
@ -45,7 +46,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
|||
print(f"Effective DB URL: {str(runtime.engine.url)}")
|
||||
|
||||
from . import models as _models
|
||||
from .routes.reports import bp_reports
|
||||
_models.Base.metadata.create_all(bind=runtime.engine)
|
||||
|
||||
# ensure extensions carries the scoped_session from integrations.init_app
|
||||
|
|
@ -74,13 +74,12 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
|||
_models.WorkNote,
|
||||
])
|
||||
|
||||
app.register_blueprint(bp_reports)
|
||||
|
||||
init_entry_routes(app)
|
||||
init_index_routes(app)
|
||||
init_listing_routes(app)
|
||||
init_search_routes(app)
|
||||
init_settings_routes(app)
|
||||
init_reports_routes(app)
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _remove_session(_exc):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ _engine = None
|
|||
SessionLocal = None
|
||||
|
||||
def init_db(database_url: str, engine_kwargs: Dict[str, Any], session_kwargs: Dict[str, Any]) -> None:
|
||||
print("AM I EVEN BEING RUN?!")
|
||||
global _engine, SessionLocal
|
||||
print(database_url)
|
||||
_engine = create_engine(database_url, **engine_kwargs)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ def _fields_for_model(model: str):
|
|||
"condition",
|
||||
"notes",
|
||||
"owner.id",
|
||||
"image.filename",
|
||||
]
|
||||
fields_spec = [
|
||||
{"name": "label", "type": "display", "label": "", "row": "label",
|
||||
|
|
@ -282,6 +283,12 @@ def init_entry_routes(app):
|
|||
f["label"] = ""
|
||||
f["label_spec"] = "New User"
|
||||
break
|
||||
elif model == "room":
|
||||
for f in fields_spec:
|
||||
if f.get("name") == "label" and f.get("type") == "display":
|
||||
f["label"] = ""
|
||||
f["label_spec"] = "New Room"
|
||||
break
|
||||
|
||||
obj = cls()
|
||||
ScopedSession = current_app.extensions["crudkit"]["Session"]
|
||||
|
|
@ -325,6 +332,10 @@ def init_entry_routes(app):
|
|||
if "work_item" in payload and "work_item_id" not in payload:
|
||||
payload["work_item_id"] = payload.pop("work_item")
|
||||
|
||||
if model == "room":
|
||||
if "room_function_id" in payload and "function_id" not in payload:
|
||||
payload["function_id"] = payload.pop("room_function_id")
|
||||
|
||||
# Parent first, no commit yet
|
||||
obj = svc.create(payload, actor="create_entry", commit=False)
|
||||
|
||||
|
|
@ -422,7 +433,6 @@ def init_entry_routes(app):
|
|||
|
||||
return {"status": "success", "payload": payload}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return {"status": "failure", "error": str(e)}
|
||||
|
||||
app.register_blueprint(bp_entry)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ bp_index = Blueprint("index", __name__)
|
|||
def init_index_routes(app):
|
||||
@bp_index.get("/")
|
||||
def index():
|
||||
inventory_service = crudkit.crud.get_service(Inventory)
|
||||
work_log_service = crudkit.crud.get_service(WorkLog)
|
||||
work_logs = work_log_service.list({
|
||||
"complete__ne": 1,
|
||||
|
|
@ -26,41 +25,6 @@ def init_index_routes(app):
|
|||
],
|
||||
"sort": "start_time"
|
||||
})
|
||||
inventory_report_rows = inventory_service.list({
|
||||
"fields": ["condition", "device_type.description"],
|
||||
"limit": 0
|
||||
})
|
||||
rows = [item.as_dict() for item in inventory_report_rows]
|
||||
chart_data = {}
|
||||
if rows:
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int)
|
||||
|
||||
top_labels = (
|
||||
xtab.sum(axis=0)
|
||||
.sort_values(ascending=False)
|
||||
.index.tolist()
|
||||
)
|
||||
xtab = xtab[top_labels]
|
||||
|
||||
preferred_order = [
|
||||
"Deployed", "Working", "Unverified",
|
||||
"Partially Inoperable", "Inoperable",
|
||||
"Removed", "Disposed"
|
||||
]
|
||||
conditions = [c for c in preferred_order if c in xtab.index] + [c for c in xtab.index if c not in preferred_order]
|
||||
xtab = xtab.loc[conditions]
|
||||
|
||||
chart_data = {
|
||||
"labels": top_labels,
|
||||
"datasets": [{
|
||||
"label": cond,
|
||||
"data": xtab.loc[cond].to_list()
|
||||
}
|
||||
for cond in xtab.index]
|
||||
}
|
||||
|
||||
columns = [
|
||||
{"field": "start_time", "label": "Start", "format": "date"},
|
||||
{"field": "contact.label", "label": "Contact",
|
||||
|
|
@ -71,7 +35,7 @@ def init_index_routes(app):
|
|||
|
||||
logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"})
|
||||
|
||||
return render_template("index.html", logs=logs, chart_data=chart_data)
|
||||
return render_template("index.html", logs=logs)
|
||||
|
||||
@bp_index.get("/LICENSE")
|
||||
def license():
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def init_listing_routes(app):
|
|||
limit_qs = request.args.get("limit")
|
||||
page = int(request.args.get("page", 1) or 1)
|
||||
per_page = int(per_page_qs) if (per_page_qs and per_page_qs.isdigit()) else (
|
||||
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 15
|
||||
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 18
|
||||
)
|
||||
sort = request.args.get("sort")
|
||||
fields_qs = request.args.get("fields")
|
||||
|
|
@ -54,12 +54,13 @@ def init_listing_routes(app):
|
|||
elif model.lower() == 'user':
|
||||
spec = {"fields": [
|
||||
"label", "last_name", "first_name", "supervisor.label",
|
||||
"robot.overlord", "staff", "active",
|
||||
"robot.overlord", "staff", "active", "title",
|
||||
], "sort": "first_name,last_name"}
|
||||
columns = [
|
||||
{"field": "label", "label": "Full Name"},
|
||||
{"field": "last_name"},
|
||||
{"field": "first_name"},
|
||||
{"field": "title"},
|
||||
{"field": "supervisor.label", "label": "Supervisor",
|
||||
"link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
||||
{"field": "staff", "format": "yesno"},
|
||||
|
|
|
|||
|
|
@ -1,142 +1,95 @@
|
|||
import math
|
||||
from flask import Blueprint, render_template, url_for
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy import select, func, case
|
||||
import crudkit
|
||||
|
||||
from ..db import get_session
|
||||
from ..models.inventory import Inventory
|
||||
from ..models.device_type import DeviceType
|
||||
bp_reports = Blueprint("reports", __name__)
|
||||
|
||||
bp_reports = Blueprint("reports", __name__, url_prefix="/api/reports")
|
||||
def init_reports_routes(app):
|
||||
@bp_reports.get('/summary')
|
||||
def summary():
|
||||
inventory_model = crudkit.crud.get_model('inventory')
|
||||
inventory_service = crudkit.crud.get_service(inventory_model)
|
||||
|
||||
@bp_reports.get("/inventory/availability")
|
||||
def inventory_availability():
|
||||
"""
|
||||
Returns Chart.js-ready JSON with labels = device types
|
||||
and datasets = Deployed / Available / Unavailable counts
|
||||
Query params:
|
||||
top_n: int (default 40) -> Limit how many device types to show by total desc
|
||||
"""
|
||||
top_n = int(request.args.get("top_n", "40"))
|
||||
session = get_session()
|
||||
|
||||
deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0)).label("deployed")
|
||||
available = func.sum(case((Inventory.condition.in_(("Working", "Unverified")), 1), else_=0)).label("available")
|
||||
unavailable = func.sum(
|
||||
case((~Inventory.condition.in_(("Deployed", "Working", "Unverified")), 1), else_=0)
|
||||
).label("unavailable")
|
||||
|
||||
stmt = (
|
||||
select(DeviceType.description.label("device_type"), deployed, available, unavailable)
|
||||
.select_from(Inventory)
|
||||
.join(DeviceType, Inventory.type_id == DeviceType.id)
|
||||
.group_by(DeviceType.description)
|
||||
)
|
||||
|
||||
rows = session.execute(stmt).all()
|
||||
|
||||
if not rows:
|
||||
return jsonify({"labels": [], "datasets": []})
|
||||
|
||||
totals = [(dt, d + a + u) for dt, d, a, u in rows]
|
||||
totals.sort(key=lambda t: t[1], reverse=True)
|
||||
keep = {dt for dt, _ in totals[:top_n]}
|
||||
|
||||
labels = [dt for dt, _ in totals if dt in keep]
|
||||
d_data, a_data, u_data = [], [], []
|
||||
by_dt = {dt: (d, a, u) for dt, d, a, u in rows}
|
||||
for dt in labels:
|
||||
d, a, u = by_dt[dt]
|
||||
d_data.append(int(d))
|
||||
a_data.append(int(a))
|
||||
u_data.append(int(u))
|
||||
|
||||
payload = {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{"label": "Deployed", "data": d_data},
|
||||
{"label": "Available", "data": a_data},
|
||||
{"label": "Unavailable", "data": u_data},
|
||||
],
|
||||
}
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@bp_reports.get("/inventory/spares")
|
||||
def inventory_spares():
|
||||
"""
|
||||
Query params:
|
||||
ratio: float (default 0.10) -> 10% spare target
|
||||
min: int (default 2) -> floor for critical device types
|
||||
critical: comma list -> applies 'min' floor
|
||||
top_n: int (default 20)
|
||||
"""
|
||||
import math
|
||||
ratio = float(request.args.get("ratio", "0.10"))
|
||||
min_floor = int(request.args.get("min", "2"))
|
||||
critical = set(x.strip() for x in request.args.get("critical", "Monitor,Desktop PC,Laptop").split(","))
|
||||
top_n = int(request.args.get("top_n", "20"))
|
||||
|
||||
session = get_session()
|
||||
deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0))
|
||||
available = func.sum(case((Inventory.condition.in_(("Working","Unverified")), 1), else_=0))
|
||||
stmt = (
|
||||
select(DeviceType.description, deployed.label("deployed"), available.label("available"))
|
||||
.select_from(Inventory)
|
||||
.join(DeviceType, Inventory.type_id == DeviceType.id)
|
||||
.group_by(DeviceType.description)
|
||||
)
|
||||
rows = session.execute(stmt).all()
|
||||
|
||||
items = []
|
||||
for dev, dep, avail in rows:
|
||||
dep = int(dep or 0)
|
||||
avail = int(avail or 0)
|
||||
target = math.ceil(dep * ratio)
|
||||
if dev in critical:
|
||||
target = max(target, min_floor)
|
||||
deficit = max(target - avail, 0)
|
||||
if dep == 0 and avail == 0 and deficit == 0:
|
||||
continue
|
||||
items.append({
|
||||
"device_type": dev,
|
||||
"deployed": dep,
|
||||
"available": avail,
|
||||
"target_spares": target,
|
||||
"deficit": deficit
|
||||
rows = inventory_service.list({
|
||||
"limit": 0,
|
||||
"sort": "device_type.description",
|
||||
"fields": ["id", "condition", "device_type.description"],
|
||||
})
|
||||
items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True)
|
||||
return jsonify({"items": items[:top_n]})
|
||||
df = pd.DataFrame([r.as_dict() for r in rows])
|
||||
|
||||
@bp_reports.get("/inventory/rows")
|
||||
def inventory_rows():
|
||||
"""
|
||||
Flat rows for PivotTable: device_type, condition.
|
||||
Optional filters: condition_in=Working,Unverified device_type_in=Monitor,Laptop
|
||||
"""
|
||||
session = get_session()
|
||||
stmt = (
|
||||
select(
|
||||
DeviceType.description.label("device_type"),
|
||||
Inventory.condition.label("condition")
|
||||
)
|
||||
.select_from(Inventory)
|
||||
.join(DeviceType, Inventory.type_id == DeviceType.id)
|
||||
if "id" in df.columns:
|
||||
df = df.drop_duplicates(subset="id")
|
||||
|
||||
pt = df.pivot_table(
|
||||
index="device_type.description",
|
||||
columns="condition",
|
||||
values="id",
|
||||
aggfunc="count",
|
||||
fill_value=0,
|
||||
)
|
||||
|
||||
# simple whitelist filters
|
||||
cond_in = request.args.get("condition_in")
|
||||
if cond_in:
|
||||
vals = [s.strip() for s in cond_in.split(",") if s.strip()]
|
||||
if vals:
|
||||
stmt = stmt.where(Inventory.condition.in_(vals))
|
||||
# Reorder/exclude like before
|
||||
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
|
||||
exclude = ["Removed", "Disposed"]
|
||||
cols = [c for c in order if c in pt.columns] + [c for c in pt.columns if c not in order and c not in exclude]
|
||||
pt = pt[cols]
|
||||
|
||||
dt_in = request.args.get("device_type_in")
|
||||
if dt_in:
|
||||
vals = [s.strip() for s in dt_in.split(",") if s.strip()]
|
||||
if vals:
|
||||
stmt = stmt.where(DeviceType.description.in_(vals))
|
||||
# Drop zero-only rows
|
||||
pt = pt.loc[(pt != 0).any(axis=1)]
|
||||
|
||||
rows = session.execute(stmt).all()
|
||||
return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows])
|
||||
# Totals
|
||||
pt["Total"] = pt.sum(axis=1)
|
||||
total_row = pt.sum(axis=0).to_frame().T
|
||||
total_row.index = ["Total"]
|
||||
pt = pd.concat([pt, total_row], axis=0)
|
||||
|
||||
# Names off
|
||||
pt.index.name = None
|
||||
pt.columns.name = None
|
||||
|
||||
# Build link helpers. url_for can't take dotted kwarg keys, so build query strings.
|
||||
base_list_url = url_for("listing.show_list", model="inventory")
|
||||
|
||||
def q(h):
|
||||
return f"{base_list_url}?{urlencode(h)}" if h else None
|
||||
|
||||
# Column headers with links (except Total)
|
||||
col_headers = []
|
||||
for col in pt.columns.tolist():
|
||||
if col == "Total":
|
||||
col_headers.append({"label": col, "href": None})
|
||||
else:
|
||||
col_headers.append({"label": col, "href": q({"condition": col})})
|
||||
|
||||
# Rows with header links and cell links
|
||||
table_rows = []
|
||||
for idx in pt.index.tolist():
|
||||
# Row header link: only if not Total
|
||||
if idx == "Total":
|
||||
row_href = None
|
||||
else:
|
||||
row_href = q({"device_type.description": idx})
|
||||
|
||||
# Cells: combine filters, respecting Total row/col rules
|
||||
cells = []
|
||||
for col in pt.columns.tolist():
|
||||
val = int(pt.at[idx, col])
|
||||
params = {}
|
||||
if idx != "Total":
|
||||
params["device_type.description"] = idx
|
||||
if col != "Total":
|
||||
params["condition"] = col
|
||||
href = q(params) if params else None # None for Total×Total
|
||||
cells.append({"value": f"{val:,}", "href": href})
|
||||
table_rows.append({"label": idx, "href": row_href, "cells": cells})
|
||||
|
||||
return render_template(
|
||||
"summary.html",
|
||||
col_headers=col_headers,
|
||||
table_rows=table_rows,
|
||||
)
|
||||
|
||||
app.register_blueprint(bp_reports)
|
||||
|
|
|
|||
4
inventory/static/css/components/dropdown.css
Normal file
4
inventory/static/css/components/dropdown.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.inventory-dropdown {
|
||||
border-color: rgb(222, 226, 230) !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -1,6 +1,39 @@
|
|||
const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {});
|
||||
|
||||
ComboBox.utilities = {
|
||||
sortList(id) {
|
||||
const select = document.getElementById(id);
|
||||
if (!select) return;
|
||||
|
||||
const prevScroll = select.scrollTop;
|
||||
const prevValue = select.value;
|
||||
|
||||
const options = Array.from(select.options);
|
||||
let placeholder = null;
|
||||
if (options.length && (
|
||||
options[0].value === '' ||
|
||||
options[0].disabled && (options[0].hidden || options[0].getAttribute('aria-hidden') === 'true')
|
||||
)) {
|
||||
placeholder = options.shift();
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
options.sort((a, b) => collator.compare(a.textContent.trim(), b.textContent.trim()));
|
||||
|
||||
select.innerHTML = '';
|
||||
if (placeholder) select.appendChild(placeholder);
|
||||
for (const opt of options) select.appendChild(opt);
|
||||
|
||||
if (prevValue) {
|
||||
select.value = prevValue;
|
||||
if (select.value !== prevValue) {
|
||||
select.selectedIndex = placeholder ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
select.scrollTop = prevScroll;
|
||||
},
|
||||
|
||||
changeAdd(id) {
|
||||
const input = document.getElementById(`input-${id}`);
|
||||
const add = document.getElementById(`add-${id}`);
|
||||
|
|
@ -65,6 +98,7 @@ ComboBox.utilities = {
|
|||
opt.value = data.id;
|
||||
opt.textContent = data[labelAttr] ?? val;
|
||||
list.appendChild(opt);
|
||||
this.sortList(id);
|
||||
input.value = '';
|
||||
},
|
||||
|
||||
|
|
@ -81,8 +115,9 @@ ComboBox.utilities = {
|
|||
|
||||
if (res.ok) {
|
||||
selected.remove();
|
||||
this.sortList(id);
|
||||
toastMessage(`Deleted ${id} successfully.`, 'success');
|
||||
this.changeRemove(id);
|
||||
this.handleSelect(id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +141,7 @@ ComboBox.utilities = {
|
|||
const opt = list?.selectedOptions?.[0];
|
||||
if (!opt) return;
|
||||
|
||||
const val = opt.value; // id of the row
|
||||
const val = opt.value;
|
||||
const newText = (input?.value ?? '').trim();
|
||||
if (!newText) return;
|
||||
|
||||
|
|
@ -133,7 +168,6 @@ ComboBox.utilities = {
|
|||
return;
|
||||
}
|
||||
|
||||
// not ok -> try to show the server error
|
||||
try { data = await res.json(); } catch { data = { error: await res.text() }; }
|
||||
toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger');
|
||||
} catch (e) {
|
||||
|
|
|
|||
95
inventory/static/js/components/dropdown.js
Normal file
95
inventory/static/js/components/dropdown.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
|
||||
|
||||
DropDown.utilities = {
|
||||
filterList(id) {
|
||||
value = document.getElementById(`${id}-filter`).value;
|
||||
list = document.querySelectorAll(`#${id}-dropdown li`);
|
||||
|
||||
list.forEach(item => {
|
||||
const txt = item.textContent.toLowerCase();
|
||||
if (txt.includes(value)) {
|
||||
item.style.display = 'list-item';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectItem(id, value) {
|
||||
const btn = document.getElementById(`${id}-button`);
|
||||
const txt = document.getElementById(`${id}-${value}`).textContent;
|
||||
const inp = document.getElementById(id);
|
||||
|
||||
btn.dataset.value = value;
|
||||
btn.textContent = txt;
|
||||
|
||||
inp.value = value;
|
||||
},
|
||||
};
|
||||
|
||||
(() => {
|
||||
const VISIBLE_ITEMS = 10;
|
||||
|
||||
function setMenuMaxHeight(buttonEl) {
|
||||
const menu = buttonEl?.nextElementSibling;
|
||||
if (!menu || !menu.classList.contains('dropdown-menu')) return;
|
||||
|
||||
const input = menu.querySelector('input.form-control');
|
||||
const firstItem = menu.querySelector('.dropdown-item');
|
||||
if (!firstItem) return;
|
||||
|
||||
// Measure even if the menu is closed
|
||||
const computed = getComputedStyle(menu);
|
||||
const wasHidden = computed.display === 'none' || computed.visibility === 'hidden';
|
||||
if (wasHidden) {
|
||||
menu.style.visibility = 'hidden';
|
||||
menu.style.display = 'block';
|
||||
}
|
||||
|
||||
const inputH = input ? input.getBoundingClientRect().height : 0;
|
||||
const itemH = firstItem.getBoundingClientRect().height || 0;
|
||||
const itemCount = Math.min(
|
||||
VISIBLE_ITEMS,
|
||||
menu.querySelectorAll('.dropdown-item').length
|
||||
);
|
||||
|
||||
const target = Math.ceil(inputH + itemH * itemCount);
|
||||
menu.style.maxHeight = `${target + 10}px`;
|
||||
menu.style.overflowY = 'auto';
|
||||
|
||||
if (wasHidden) {
|
||||
menu.style.display = '';
|
||||
menu.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
function onShow(e) {
|
||||
setMenuMaxHeight(e.target);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
document.querySelectorAll('.dropdown-toggle[data-bs-toggle="dropdown"]').forEach(btn => {
|
||||
const menu = btn.nextElementSibling;
|
||||
if (menu && menu.classList.contains('dropdown-menu') && menu.classList.contains('show')) {
|
||||
setMenuMaxHeight(btn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init(root = document) {
|
||||
// Delegate so dynamically-added dropdowns work too
|
||||
root.addEventListener('show.bs.dropdown', onShow);
|
||||
window.addEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// Expose for manyal calls or tests
|
||||
DropDown.utilities.setMenuMaxHeight = setMenuMaxHeight;
|
||||
DropDown.init = init;
|
||||
|
||||
// Auto-init
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => init());
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
36
inventory/static/js/components/markdown.js
Normal file
36
inventory/static/js/components/markdown.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const MarkDown = {
|
||||
parseOptions: { gfm: true, breaks: false },
|
||||
sanitizeOptions: { ADD_ATTR: ['target', 'rel'] },
|
||||
|
||||
toHTML(md) {
|
||||
const raw = marked.parse(md || "", this.parseOptions);
|
||||
return DOMPurify.sanitize(raw, this.sanitizeOptions);
|
||||
},
|
||||
|
||||
enhance(root) {
|
||||
if (!root) return;
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
for (const t of root.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
for (const q of root.querySelectorAll('blockquote')) {
|
||||
q.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
|
||||
}
|
||||
for (const l of root.querySelectorAll('ul')) {
|
||||
l.classList.add('list-group');
|
||||
}
|
||||
for (const l of root.querySelectorAll('li')) {
|
||||
l.classList.add('list-group-item');
|
||||
}
|
||||
},
|
||||
|
||||
renderInto(el, md) {
|
||||
if (!el) return;
|
||||
el.innerHTML = this.toHTML(md);
|
||||
this.enhance(el);
|
||||
}
|
||||
};
|
||||
7
inventory/static/js/utils/json.js
Normal file
7
inventory/static/js/utils/json.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
function readJSONScript(id, fallback = "") {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return fallback;
|
||||
const txt = el.textContent?.trim();
|
||||
if (!txt) return fallback;
|
||||
try { return JSON.parse(txt); } catch { return fallback; }
|
||||
}
|
||||
|
|
@ -36,6 +36,12 @@
|
|||
<a href="{{ url_for('listing.show_list', model='user') }}"
|
||||
class="nav-link link-success fw-semibold">Users</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
{% block header %}
|
||||
{% endblock %}
|
||||
|
|
@ -43,6 +49,13 @@
|
|||
<input type="text" id="searchText" class="form-control me-3">
|
||||
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
|
||||
</div>
|
||||
<a href="{{ url_for('settings.settings') }}" class="link-success fw-semibold ms-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor"
|
||||
class="bi bi-gear-fill" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
{% macro combobox(id, name, placeholder, items, value_attr='id', label_attr='name',
|
||||
add_disabled=True, remove_disabled=True, edit_disabled=True) %}
|
||||
<div class="col combobox">
|
||||
<div class="combobox">
|
||||
<div class="d-flex">
|
||||
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
|
||||
placeholder="{{ placeholder }}" id="input-{{id}}" oninput="ComboBox.utilities.changeAdd('{{ id }}');">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
{# show label unless hidden/custom #}
|
||||
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
|
||||
{% if field_type != 'hidden' and field_label %}
|
||||
<label for="{{ field_name }}"
|
||||
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
|
||||
%}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
{% if link_href %}
|
||||
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
|
||||
{% endif %}
|
||||
|
|
@ -15,11 +13,9 @@
|
|||
{% endif %}
|
||||
|
||||
{% if field_type == 'select' %}
|
||||
<select name="{{ field_name }}" id="{{ field_name }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}
|
||||
{%- if not options %} disabled{% endif %}>
|
||||
{#
|
||||
<select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
||||
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
|
||||
{% if options %}
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in options %}
|
||||
|
|
@ -31,51 +27,60 @@
|
|||
<option value="">-- No selection available --</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
#}
|
||||
{% if options %}
|
||||
{% if value %}
|
||||
{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %}
|
||||
{% else %}
|
||||
{% set sel_label = "-- Select --" %}
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
|
||||
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button>
|
||||
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
|
||||
<input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0"
|
||||
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
|
||||
{% for opt in options %}
|
||||
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
|
||||
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No selection available --</button>
|
||||
{% endif %}
|
||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
|
||||
|
||||
{% elif field_type == 'textarea' %}
|
||||
<textarea name="{{ field_name }}" id="{{ field_name }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
|
||||
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
|
||||
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
|
||||
|
||||
{% elif field_type == 'checkbox' %}
|
||||
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1"
|
||||
{% if value %}checked{% endif %}
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
|
||||
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
|
||||
%}>
|
||||
|
||||
{% elif field_type == 'hidden' %}
|
||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
|
||||
|
||||
{% elif field_type == 'display' %}
|
||||
<div {% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
||||
<div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
|
||||
%}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
||||
|
||||
{% elif field_type == "date" %}
|
||||
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
|
||||
{% elif field_type == "time" %}
|
||||
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
|
||||
{% elif field_type == "datetime" %}
|
||||
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
|
||||
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
|
||||
%}>
|
||||
|
||||
{% else %}
|
||||
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||
{% if attrs %}{% for k,v in attrs.items() %}
|
||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||
{% endfor %}{% endif %}>
|
||||
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
|
||||
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
{% if help %}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div class="table-responsive" style="max-height: 80vh;">
|
||||
<table class="table table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
|
||||
<table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block styleincludes %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container mt-5">
|
||||
{{ form | safe }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block style %}
|
||||
.chart-container { height: 420px; }
|
||||
|
||||
.pivot-cell { min-width: 0; min-height: 0; }
|
||||
|
||||
.table-responsive { max-height: 420px; overflow: auto; }
|
||||
|
||||
#pivot thead th { position: sticky; top: 0; z-index: 1; background: var(--bs-body-bg); }
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
|
||||
<p class="lead text-center">Find out about all of your assets.</p>
|
||||
|
|
@ -19,256 +9,5 @@
|
|||
<p class="display-6 text-center">Active Worklogs</p>
|
||||
{{ logs | safe }}
|
||||
</div>
|
||||
|
||||
<div class="col pivot-cell me-5">
|
||||
<p class="display-6 text-center">Inventory Report</p>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end mb-2">
|
||||
<div>
|
||||
<label class="form-label">Rows</label>
|
||||
<select id="rows-dim" class="form-select form-select-sm">
|
||||
<option value="device_type" selected>Device Type</option>
|
||||
<option value="condition">Condition</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Columns</label>
|
||||
<select id="cols-dim" class="form-select form-select-sm">
|
||||
<option value="condition" selected>Condition</option>
|
||||
<option value="device_type">Device Type</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Filter: Condition</label>
|
||||
<select id="filter-condition" class="form-select form-select-sm" multiple size="4">
|
||||
<option>Deployed</option>
|
||||
<option>Working</option>
|
||||
<option>Unverified</option>
|
||||
<option>Partially Inoperable</option>
|
||||
<option>Inoperable</option>
|
||||
<option>Removed</option>
|
||||
<option>Disposed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Top N rows</label>
|
||||
<input id="top-n" type="number" min="5" max="200" value="40" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="show-totals" checked>
|
||||
<label class="form-check-label" for="show-totals">Show totals</label>
|
||||
</div>
|
||||
<button id="refresh" class="btn btn-sm btn-primary">Update</button>
|
||||
<button id="export" class="btn btn-sm btn-outline-secondary">Export CSV</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" style="overflow: auto;">
|
||||
<table id="pivot" class="table table-sm table-bordered align-middle mb-2"></table>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="pivot-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
const state = {
|
||||
rows: 'device_type',
|
||||
cols: 'condition',
|
||||
filterCondition: new Set(),
|
||||
topN: 40,
|
||||
data: []
|
||||
};
|
||||
|
||||
const el = {
|
||||
rows: document.getElementById('rows-dim'),
|
||||
cols: document.getElementById('cols-dim'),
|
||||
cond: document.getElementById('filter-condition'),
|
||||
topN: document.getElementById('top-n'),
|
||||
totals: document.getElementById('show-totals'),
|
||||
table: document.getElementById('pivot'),
|
||||
export: document.getElementById('export'),
|
||||
refresh: document.getElementById('refresh'),
|
||||
canvas: document.getElementById('pivot-chart')
|
||||
};
|
||||
|
||||
let chart;
|
||||
|
||||
async function fetchRows() {
|
||||
// apply server-side filters when possible
|
||||
const condList = [...state.filterCondition];
|
||||
const qs = condList.length ? `?condition_in=${encodeURIComponent(condList.join(','))}` : '';
|
||||
const res = await fetch(`/api/reports/inventory/rows${qs}`);
|
||||
state.data = await res.json();
|
||||
}
|
||||
|
||||
function uniq(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function pivotize(rows, rowKey, colKey) {
|
||||
// build sets
|
||||
const rowVals = uniq(rows.map(r => r[rowKey]));
|
||||
const colVals = uniq(rows.map(r => r[colKey]));
|
||||
// counts
|
||||
const counts = new Map(); // key `${r}|||${c}` -> n
|
||||
for (const r of rowVals) for (const c of colVals) counts.set(`${r}|||${c}`, 0);
|
||||
for (const rec of rows) {
|
||||
const k = `${rec[rowKey]}|||${rec[colKey]}`;
|
||||
counts.set(k, (counts.get(k) || 0) + 1);
|
||||
}
|
||||
// totals per row
|
||||
const totals = new Map();
|
||||
for (const r of rowVals) {
|
||||
let t = 0;
|
||||
for (const c of colVals) t += counts.get(`${r}|||${c}`) || 0;
|
||||
totals.set(r, t);
|
||||
}
|
||||
// sort rows by total desc, take topN
|
||||
const sortedRows = rowVals.sort((a,b) => (totals.get(b)||0) - (totals.get(a)||0))
|
||||
.slice(0, state.topN);
|
||||
// recompute column totals for visible rows
|
||||
const colTotals = new Map();
|
||||
for (const c of colVals) {
|
||||
let t = 0;
|
||||
for (const r of sortedRows) t += counts.get(`${r}|||${c}`) || 0;
|
||||
colTotals.set(c, t);
|
||||
}
|
||||
return { rows: sortedRows, cols: colVals, counts, rowTotals: totals, colTotals };
|
||||
}
|
||||
|
||||
function renderTable(piv) {
|
||||
const { rows, cols, counts, rowTotals, colTotals } = piv;
|
||||
const showTotals = el.totals.checked;
|
||||
|
||||
// header
|
||||
let thead = '<thead><tr><th>'+escapeHtml(state.rows)+'</th>';
|
||||
for (const c of cols) thead += `<th>${escapeHtml(c)}</th>`;
|
||||
if (showTotals) thead += '<th>Total</th>';
|
||||
thead += '</tr></thead>';
|
||||
|
||||
// body
|
||||
let tbody = '<tbody>';
|
||||
for (const r of rows) {
|
||||
tbody += `<tr><th class="text-nowrap">${escapeHtml(r)}</th>`;
|
||||
for (const c of cols) {
|
||||
const n = counts.get(`${r}|||${c}`) || 0;
|
||||
tbody += `<td class="text-end">${n}</td>`;
|
||||
}
|
||||
if (showTotals) tbody += `<td class="text-end fw-semibold">${rowTotals.get(r) || 0}</td>`;
|
||||
tbody += '</tr>';
|
||||
}
|
||||
if (showTotals) {
|
||||
tbody += '<tr class="table-light fw-semibold"><th>Total</th>';
|
||||
let grand = 0;
|
||||
for (const c of cols) {
|
||||
const t = colTotals.get(c) || 0;
|
||||
grand += t;
|
||||
tbody += `<td class="text-end">${t}</td>`;
|
||||
}
|
||||
tbody += `<td class="text-end">${grand}</td></tr>`;
|
||||
}
|
||||
tbody += '</tbody>';
|
||||
|
||||
el.table.innerHTML = thead + tbody;
|
||||
}
|
||||
|
||||
function renderChart(piv) {
|
||||
const { rows, cols, counts } = piv;
|
||||
|
||||
// Build Chart.js datasets to mirror the table (stacked horizontal)
|
||||
const datasets = cols.map(c => ({
|
||||
label: c,
|
||||
data: rows.map(r => counts.get(`${r}|||${c}`) || 0)
|
||||
}));
|
||||
|
||||
// height based on rows, with a clamp so it doesn’t grow forever
|
||||
const perRow = 24, pad = 96, maxH = 520;
|
||||
el.canvas.height = Math.min(rows.length * perRow + pad, maxH);
|
||||
|
||||
if (chart) chart.destroy();
|
||||
chart = new Chart(el.canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: { labels: rows, datasets },
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: false },
|
||||
tooltip: { mode: 'index', intersect: false }
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, title: { display: true, text: 'Count' } },
|
||||
y: { stacked: true, ticks: { autoSkip: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function exportCsv(piv) {
|
||||
const { rows, cols, counts, rowTotals, colTotals } = piv;
|
||||
const showTotals = el.totals.checked;
|
||||
|
||||
let csv = [ [state.rows, ...cols] ];
|
||||
if (showTotals) csv[0].push('Total');
|
||||
|
||||
for (const r of rows) {
|
||||
const line = [r];
|
||||
let rt = 0;
|
||||
for (const c of cols) {
|
||||
const n = counts.get(`${r}|||${c}`) || 0;
|
||||
line.push(n);
|
||||
rt += n;
|
||||
}
|
||||
if (showTotals) line.push(rt);
|
||||
csv.push(line);
|
||||
}
|
||||
if (showTotals) {
|
||||
const totalLine = ['Total', ...cols.map(c => colTotals.get(c) || 0)];
|
||||
totalLine.push(totalLine.slice(1).reduce((a,b)=>a+b,0));
|
||||
csv.push(totalLine);
|
||||
}
|
||||
|
||||
const blob = new Blob([csv.map(row => row.map(csvEscape).join(',')).join('\r\n')], {type: 'text/csv'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'inventory_pivot.csv';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getSelectedOptions(selectEl) {
|
||||
return [...selectEl.options].filter(o => o.selected).map(o => o.value);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
function csvEscape(v) {
|
||||
const s = String(v ?? '');
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
state.rows = el.rows.value;
|
||||
state.cols = el.cols.value;
|
||||
state.topN = parseInt(el.topN.value || '40', 10);
|
||||
state.filterCondition = new Set(getSelectedOptions(el.cond));
|
||||
await fetchRows();
|
||||
const piv = pivotize(state.data, state.rows, state.cols);
|
||||
renderTable(piv);
|
||||
renderChart(piv);
|
||||
el.export.onclick = () => exportCsv(piv);
|
||||
}
|
||||
|
||||
el.refresh.addEventListener('click', refresh);
|
||||
refresh();
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -28,40 +28,30 @@
|
|||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderView(getMarkdown());
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
|
||||
});
|
||||
|
||||
function getMarkdown() {
|
||||
const el = document.getElementById('noteContent');
|
||||
return el ? (JSON.parse(el.textContent) || "") : "";
|
||||
}
|
||||
// used by entry_buttons submit
|
||||
window.getMarkdown = function () {
|
||||
return readJSONScript('noteContent', "");
|
||||
};
|
||||
|
||||
function setMarkdown(md) {
|
||||
const el = document.getElementById('noteContent');
|
||||
if (el) el.textContent = JSON.stringify(md ?? "");
|
||||
}
|
||||
|
||||
function renderView(md) {
|
||||
const container = document.getElementById('editContainer');
|
||||
if (!container) return;
|
||||
const html = marked.parse(md || "", {gfm: true});
|
||||
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
|
||||
for (const a of container.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
for (const t of container.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
}
|
||||
|
||||
function changeMode() {
|
||||
const container = document.getElementById('editContainer');
|
||||
const toggle = document.getElementById('editSwitch');
|
||||
if (!toggle.checked) return renderView(getMarkdown());
|
||||
if (!toggle.checked) {
|
||||
MarkDown.renderInto(container, getMarkdown());
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getMarkdown();
|
||||
container.innerHTML = `
|
||||
|
|
@ -82,13 +72,13 @@
|
|||
const ta = document.getElementById('editor');
|
||||
const value = ta ? ta.value : "";
|
||||
setMarkdown(value);
|
||||
renderView(value);
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), value);
|
||||
document.getElementById('editSwitch').checked = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
document.getElementById('editSwitch').checked = false;
|
||||
renderView(getMarkdown());
|
||||
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
|
|
@ -96,13 +86,13 @@
|
|||
const preview = document.getElementById('preview');
|
||||
preview.classList.toggle('d-none');
|
||||
if (!preview.classList.contains('d-none')) {
|
||||
const html = marked.parse(ta ? ta.value : "");
|
||||
preview.innerHTML = DOMPurify.sanitize(html);
|
||||
MarkDown.renderInto(preview, ta ? ta.value : "");
|
||||
}
|
||||
}
|
||||
|
||||
function autoGrow(ta) {
|
||||
if (!ta) return;
|
||||
if (CSS?.supports?.('field-sizing: content')) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = ta.scrollHeight + 'px';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ Inventory Manager - {{ model|title }} Listing
|
|||
|
||||
{% block main %}
|
||||
<div class="mx-5">
|
||||
<h1 class="display-4 text-center mt-2">{{ model|title }} Listing</h1>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary mb-3"
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="btn-group h-50 align-self-end">
|
||||
<button type="button" class="btn btn-primary mb-2"
|
||||
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
|
||||
</div>
|
||||
<h1 class="display-6 text-center">{{ model|title }} Listing</h1>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{{ table | safe }}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,18 +23,34 @@
|
|||
|
||||
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="brand" class="form-label">Brand</label>
|
||||
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="devicetype" class="form-label">Device Type</label>
|
||||
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="area" class="form-label">Area</label>
|
||||
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="roomfunction" class="form-label">Description</label>
|
||||
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<label for="rooms" class="form-label">
|
||||
Rooms
|
||||
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
|
||||
</label>
|
||||
<div class="col">
|
||||
{{ rooms | safe }}
|
||||
</div>
|
||||
|
|
|
|||
40
inventory/templates/summary.html
Normal file
40
inventory/templates/summary.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
{% block main %}
|
||||
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
|
||||
<div class="table-responsive mx-5">
|
||||
<table class="table table-sm table-striped table-hover table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-nowrap">Device Type</th>
|
||||
{% for col in col_headers %}
|
||||
{% if col.href %}
|
||||
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
|
||||
{% else %}
|
||||
<th class="text-end">{{ col.label }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in table_rows %}
|
||||
<tr>
|
||||
<th class="text-nowrap">
|
||||
{% if row.href %}
|
||||
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
|
||||
{% else %}
|
||||
{{ row.label }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% for cell in row.cells %}
|
||||
{% if cell.href %}
|
||||
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
|
||||
{% else %}
|
||||
<td class="text-end">{{ cell.value }}</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
<ul class="list-group mt-3">
|
||||
{% for n in items %}
|
||||
<li class="list-group-item">
|
||||
<li class="list-group-item" id="note-{{ n.id }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
|
||||
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
|
||||
|
|
@ -63,44 +63,15 @@
|
|||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
|
||||
<script>
|
||||
// State (kept global for compatibility with your form serialization)
|
||||
window.newDrafts = window.newDrafts || [];
|
||||
window.deletedIds = window.deletedIds || [];
|
||||
|
||||
// ---------- DRY UTILITIES ----------
|
||||
function renderMarkdown(md) {
|
||||
// One place to parse + sanitize
|
||||
const raw = marked.parse(md || "");
|
||||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
|
||||
}
|
||||
|
||||
function enhanceLinks(root) {
|
||||
if (!root) return;
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceTables(root) {
|
||||
if (!root) return;
|
||||
for (const t of root.querySelectorAll('table')) {
|
||||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHTML(el, md) {
|
||||
if (!el) return;
|
||||
el.innerHTML = renderMarkdown(md);
|
||||
enhanceLinks(el);
|
||||
enhanceTables(el);
|
||||
}
|
||||
|
||||
function getMarkdown(id) {
|
||||
const el = document.getElementById(`md-${id}`);
|
||||
return el ? JSON.parse(el.textContent || '""') : "";
|
||||
return readJSONScript(`md-${id}`, "");
|
||||
}
|
||||
|
||||
function setMarkdown(id, md) {
|
||||
|
|
@ -114,14 +85,14 @@
|
|||
|
||||
function autoGrow(ta) {
|
||||
if (!ta) return;
|
||||
if (CSS?.supports?.('field-sizing: content')) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = (ta.scrollHeight + 5) + 'px';
|
||||
}
|
||||
|
||||
// ---------- RENDERERS ----------
|
||||
function renderExistingView(id) {
|
||||
const container = document.getElementById(`editContainer${id}`);
|
||||
renderHTML(container, getMarkdown(id));
|
||||
MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
|
||||
}
|
||||
|
||||
function renderEditor(id) {
|
||||
|
|
@ -159,8 +130,7 @@
|
|||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'w-100 markdown-body';
|
||||
left.innerHTML = renderMarkdown(md || '');
|
||||
enhanceLinks(left);
|
||||
MarkDown.renderInto(left, md || '');
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'ms-3 d-flex flex-column align-items-end';
|
||||
|
|
@ -263,7 +233,7 @@
|
|||
if (!preview) return;
|
||||
preview.classList.toggle('d-none');
|
||||
if (!preview.classList.contains('d-none')) {
|
||||
preview.innerHTML = renderMarkdown(ta ? ta.value : "");
|
||||
MarkDown.renderInto(preview, ta ? ta.value : "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ dependencies = [
|
|||
"flask_sqlalchemy",
|
||||
"html5lib",
|
||||
"jinja_markdown",
|
||||
"matplotlib",
|
||||
"pandas",
|
||||
"pyodbc",
|
||||
"python-dotenv",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue