Compare commits

..

13 commits

Author SHA1 Message Date
Yaro Kasear
5234cbdd61 SMall tweaks to the new dropdown. 2025-10-22 12:30:57 -05:00
Yaro Kasear
5718deee6b New dropdown widget! 2025-10-22 11:23:43 -05:00
Yaro Kasear
43b3df9938 Starting work on adding new dropdown selector. 2025-10-21 15:46:30 -05:00
Yaro Kasear
38bae34247 Refactoring MarkDown behavior. 2025-10-21 11:33:11 -05:00
Yaro Kasear
6357e5794f More markdown enhancements. 2025-10-21 10:19:26 -05:00
Yaro Kasear
8d26d5b084 Improving listing layout. 2025-10-21 09:14:05 -05:00
Yaro Kasear
ce7d092be4 Removed unused functions in the Flask API file. 2025-10-20 13:54:30 -05:00
Yaro Kasear
01a0031cf4 Fix a regression added by some refactor. 2025-10-20 13:53:27 -05:00
Yaro Kasear
e829de9792 Optimization and refactoring pass. 2025-10-20 11:03:03 -05:00
Yaro Kasear
15ae0caf27 Tiny fix to the inventory entry page. 2025-10-20 09:08:33 -05:00
Yaro Kasear
c8f5c7011e Add summary page. 2025-10-16 14:42:33 -05:00
Yaro Kasear
ade5ceaae5 Sorting added to ComboBox logic. 2025-10-16 08:02:52 -05:00
Yaro Kasear
81e02bbabd I'm "done." At least for now. Need deletion implementation. 2025-10-15 15:05:49 -05:00
25 changed files with 650 additions and 687 deletions

View file

@ -2,29 +2,45 @@
from __future__ import annotations 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 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 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: def _bool_param(d: dict[str, str], key: str, default: bool) -> bool:
return _is_truthy(d.get(key, "1" if default else "0")) 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): def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
""" """
REST: 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 = 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 ---------- # ---------- REST ----------
if rest: if rest:
@bp.get("/") @bp.get("/")
def rest_list(): def rest_list():
args = request.args.to_dict(flat=True) args = _args_flat()
# support cursor pagination transparently; fall back to limit/offset # support cursor pagination transparently; fall back to limit/offset
try: try:
items = service.list(args) items = service.list(args)
return jsonify([o.as_dict() for o in items]) return jsonify([o.as_dict() for o in items])
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.get("/<int:obj_id>") @bp.get("/<int:obj_id>")
def rest_get(obj_id: int): def rest_get(obj_id: int):
try:
item = service.get(obj_id, request.args) item = service.get(obj_id, request.args)
if item is None: if item is None:
abort(404) abort(404)
return jsonify(item.as_dict()) etag = _etag_for(item)
except Exception as e: if request.if_none_match and (etag in request.if_none_match):
return jsonify({"status": "error", "error": str(e)}), 400 return "", 304
resp = jsonify(item.as_dict())
resp.set_etag(etag)
return resp
@bp.post("/") @bp.post("/")
def rest_create(): def rest_create():
payload = request.get_json(silent=True) or {} payload = _json_payload()
try: try:
obj = service.create(payload) obj = service.create(payload)
resp = jsonify(obj.as_dict()) resp = jsonify(obj.as_dict())
resp.status_code = 201 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 return resp
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.patch("/<int:obj_id>") @bp.patch("/<int:obj_id>")
def rest_update(obj_id: int): def rest_update(obj_id: int):
payload = request.get_json(silent=True) or {} payload = _json_payload()
try: try:
obj = service.update(obj_id, payload) obj = service.update(obj_id, payload)
return jsonify(obj.as_dict()) return jsonify(obj.as_dict())
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.delete("/<int:obj_id>") @bp.delete("/<int:obj_id>")
def rest_delete(obj_id: int): 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: try:
obj = service.delete(obj_id, hard=hard) obj = service.delete(obj_id, hard=hard)
if obj is None: if obj is None:
abort(404) abort(404)
return ("", 204) return ("", 204)
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
# ---------- RPC (your existing routes) ---------- # ---------- RPC (your existing routes) ----------
if rpc: if rpc:
# your original functions verbatim, shortened here for sanity
@bp.get("/get") @bp.get("/get")
def rpc_get(): def rpc_get():
print("⚠️ WARNING: Deprecated RPC call used: /get")
id_ = int(request.args.get("id", 0)) id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 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) abort(404)
return jsonify(item.as_dict()) return jsonify(item.as_dict())
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.get("/list") @bp.get("/list")
def rpc_list(): def rpc_list():
args = request.args.to_dict(flat=True) print("⚠️ WARNING: Deprecated RPC call used: /list")
args = _args_flat()
try: try:
items = service.list(args) items = service.list(args)
return jsonify([obj.as_dict() for obj in items]) return jsonify([obj.as_dict() for obj in items])
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.post("/create") @bp.post("/create")
def rpc_create(): def rpc_create():
payload = request.get_json(silent=True) or {} print("⚠️ WARNING: Deprecated RPC call used: /create")
payload = _json_payload()
try: try:
obj = service.create(payload) obj = service.create(payload)
return jsonify(obj.as_dict()), 201 return jsonify(obj.as_dict()), 201
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.patch("/update") @bp.patch("/update")
def rpc_update(): def rpc_update():
print("⚠️ WARNING: Deprecated RPC call used: /update")
id_ = int(request.args.get("id", 0)) id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 return jsonify({"status": "error", "error": "missing required param: id"}), 400
payload = request.get_json(silent=True) or {} payload = _json_payload()
try: try:
obj = service.update(id_, payload) obj = service.update(id_, payload)
return jsonify(obj.as_dict()) return jsonify(obj.as_dict())
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
@bp.delete("/delete") @bp.delete("/delete")
def rpc_delete(): def rpc_delete():
print("⚠️ WARNING: Deprecated RPC call used: /delete")
id_ = int(request.args.get("id", 0)) id_ = int(request.args.get("id", 0))
if not id_: if not id_:
return jsonify({"status": "error", "error": "missing required param: id"}), 400 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: try:
obj = service.delete(id_, hard=hard) obj = service.delete(id_, hard=hard)
return ("", 204) if obj is not None else abort(404) return ("", 204) if obj is not None else abort(404)
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400 return _json_error(e)
return bp return bp

View file

@ -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 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() 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: try:
st = inspect(obj) 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: if attr is not None:
val = attr.loaded_value val = attr.loaded_value
return None if val is NO_VALUE else val return None if val is NO_VALUE else val
if name in st.dict:
return st.dict.get(name)
return None return None
except Exception: except Exception:
return None return None
@ -33,10 +81,7 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool:
def _serialize_simple_obj(obj) -> Dict[str, Any]: def _serialize_simple_obj(obj) -> Dict[str, Any]:
"""Columns only (no relationships).""" """Columns only (no relationships)."""
out: Dict[str, Any] = {} out: Dict[str, Any] = {}
for cls in obj.__class__.__mro__: for name in _column_names_for_model(type(obj)):
if hasattr(cls, "__table__"):
for col in cls.__table__.columns:
name = col.name
try: try:
out[name] = getattr(obj, name) out[name] = getattr(obj, name)
except Exception: except Exception:
@ -204,12 +249,16 @@ class CRUDMixin:
# Determine which relationships to consider # Determine which relationships to consider
try: try:
st = inspect(self) mapper = _sa_mapper(self)
mapper = st.mapper embed_set = set(str(x).split(".", 1)[0] for x in (embed or []))
embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names if mapper is None:
return data
st = _sa_state(self)
if st is None:
return data
for name, prop in mapper.relationships.items(): for name, prop in mapper.relationships.items():
# Only touch relationships that are already loaded; never lazy-load here. # 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: if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE:
continue continue
@ -266,13 +315,10 @@ class CRUDMixin:
val = None val = None
# If it's a scalar ORM object (relationship), serialize its columns # If it's a scalar ORM object (relationship), serialize its columns
try: mapper = _sa_mapper(val)
st = inspect(val) # will raise if not an ORM object if mapper is not None:
if getattr(st, "mapper", None) is not None:
out[name] = _serialize_simple_obj(val) out[name] = _serialize_simple_obj(val)
continue continue
except Exception:
pass
# If it's a collection and no subfields were requested, emit a light list # If it's a collection and no subfields were requested, emit a light list
if isinstance(val, (list, tuple)): if isinstance(val, (list, tuple)):

View file

@ -21,6 +21,7 @@ from .routes.index import init_index_routes
from .routes.listing import init_listing_routes from .routes.listing import init_listing_routes
from .routes.search import init_search_routes from .routes.search import init_search_routes
from .routes.settings import init_settings_routes from .routes.settings import init_settings_routes
from .routes.reports import init_reports_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask: def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__) app = Flask(__name__)
@ -45,7 +46,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
print(f"Effective DB URL: {str(runtime.engine.url)}") print(f"Effective DB URL: {str(runtime.engine.url)}")
from . import models as _models from . import models as _models
from .routes.reports import bp_reports
_models.Base.metadata.create_all(bind=runtime.engine) _models.Base.metadata.create_all(bind=runtime.engine)
# ensure extensions carries the scoped_session from integrations.init_app # ensure extensions carries the scoped_session from integrations.init_app
@ -74,13 +74,12 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
_models.WorkNote, _models.WorkNote,
]) ])
app.register_blueprint(bp_reports)
init_entry_routes(app) init_entry_routes(app)
init_index_routes(app) init_index_routes(app)
init_listing_routes(app) init_listing_routes(app)
init_search_routes(app) init_search_routes(app)
init_settings_routes(app) init_settings_routes(app)
init_reports_routes(app)
@app.teardown_appcontext @app.teardown_appcontext
def _remove_session(_exc): def _remove_session(_exc):

View file

@ -12,7 +12,6 @@ _engine = None
SessionLocal = None SessionLocal = None
def init_db(database_url: str, engine_kwargs: Dict[str, Any], session_kwargs: Dict[str, Any]) -> 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 global _engine, SessionLocal
print(database_url) print(database_url)
_engine = create_engine(database_url, **engine_kwargs) _engine = create_engine(database_url, **engine_kwargs)

View file

@ -26,6 +26,7 @@ def _fields_for_model(model: str):
"condition", "condition",
"notes", "notes",
"owner.id", "owner.id",
"image.filename",
] ]
fields_spec = [ fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label", {"name": "label", "type": "display", "label": "", "row": "label",
@ -282,6 +283,12 @@ def init_entry_routes(app):
f["label"] = "" f["label"] = ""
f["label_spec"] = "New User" f["label_spec"] = "New User"
break 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() obj = cls()
ScopedSession = current_app.extensions["crudkit"]["Session"] 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: if "work_item" in payload and "work_item_id" not in payload:
payload["work_item_id"] = payload.pop("work_item") 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 # Parent first, no commit yet
obj = svc.create(payload, actor="create_entry", commit=False) obj = svc.create(payload, actor="create_entry", commit=False)
@ -422,7 +433,6 @@ def init_entry_routes(app):
return {"status": "success", "payload": payload} return {"status": "success", "payload": payload}
except Exception as e: except Exception as e:
print(e)
return {"status": "failure", "error": str(e)} return {"status": "failure", "error": str(e)}
app.register_blueprint(bp_entry) app.register_blueprint(bp_entry)

View file

@ -15,7 +15,6 @@ bp_index = Blueprint("index", __name__)
def init_index_routes(app): def init_index_routes(app):
@bp_index.get("/") @bp_index.get("/")
def index(): def index():
inventory_service = crudkit.crud.get_service(Inventory)
work_log_service = crudkit.crud.get_service(WorkLog) work_log_service = crudkit.crud.get_service(WorkLog)
work_logs = work_log_service.list({ work_logs = work_log_service.list({
"complete__ne": 1, "complete__ne": 1,
@ -26,41 +25,6 @@ def init_index_routes(app):
], ],
"sort": "start_time" "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 = [ columns = [
{"field": "start_time", "label": "Start", "format": "date"}, {"field": "start_time", "label": "Start", "format": "date"},
{"field": "contact.label", "label": "Contact", {"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"}) 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") @bp_index.get("/LICENSE")
def license(): def license():

View file

@ -24,7 +24,7 @@ def init_listing_routes(app):
limit_qs = request.args.get("limit") limit_qs = request.args.get("limit")
page = int(request.args.get("page", 1) or 1) 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 ( 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") sort = request.args.get("sort")
fields_qs = request.args.get("fields") fields_qs = request.args.get("fields")
@ -54,12 +54,13 @@ def init_listing_routes(app):
elif model.lower() == 'user': elif model.lower() == 'user':
spec = {"fields": [ spec = {"fields": [
"label", "last_name", "first_name", "supervisor.label", "label", "last_name", "first_name", "supervisor.label",
"robot.overlord", "staff", "active", "robot.overlord", "staff", "active", "title",
], "sort": "first_name,last_name"} ], "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": "title"},
{"field": "supervisor.label", "label": "Supervisor", {"field": "supervisor.label", "label": "Supervisor",
"link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
{"field": "staff", "format": "yesno"}, {"field": "staff", "format": "yesno"},

View file

@ -1,142 +1,95 @@
import math from flask import Blueprint, render_template, url_for
from urllib.parse import urlencode
import pandas as pd import pandas as pd
from flask import Blueprint, request, jsonify import crudkit
from sqlalchemy import select, func, case
from ..db import get_session bp_reports = Blueprint("reports", __name__)
from ..models.inventory import Inventory
from ..models.device_type import DeviceType
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") rows = inventory_service.list({
def inventory_availability(): "limit": 0,
""" "sort": "device_type.description",
Returns Chart.js-ready JSON with labels = device types "fields": ["id", "condition", "device_type.description"],
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
}) })
items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True) df = pd.DataFrame([r.as_dict() for r in rows])
return jsonify({"items": items[:top_n]})
@bp_reports.get("/inventory/rows") if "id" in df.columns:
def inventory_rows(): df = df.drop_duplicates(subset="id")
"""
Flat rows for PivotTable: device_type, condition. pt = df.pivot_table(
Optional filters: condition_in=Working,Unverified device_type_in=Monitor,Laptop index="device_type.description",
""" columns="condition",
session = get_session() values="id",
stmt = ( aggfunc="count",
select( fill_value=0,
DeviceType.description.label("device_type"),
Inventory.condition.label("condition")
)
.select_from(Inventory)
.join(DeviceType, Inventory.type_id == DeviceType.id)
) )
# simple whitelist filters # Reorder/exclude like before
cond_in = request.args.get("condition_in") order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
if cond_in: exclude = ["Removed", "Disposed"]
vals = [s.strip() for s in cond_in.split(",") if s.strip()] 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]
if vals: pt = pt[cols]
stmt = stmt.where(Inventory.condition.in_(vals))
dt_in = request.args.get("device_type_in") # Drop zero-only rows
if dt_in: pt = pt.loc[(pt != 0).any(axis=1)]
vals = [s.strip() for s in dt_in.split(",") if s.strip()]
if vals:
stmt = stmt.where(DeviceType.description.in_(vals))
rows = session.execute(stmt).all() # Totals
return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows]) 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)

View file

@ -0,0 +1,4 @@
.inventory-dropdown {
border-color: rgb(222, 226, 230) !important;
overflow-y: auto;
}

View file

@ -1,6 +1,39 @@
const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {}); const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {});
ComboBox.utilities = { 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) { changeAdd(id) {
const input = document.getElementById(`input-${id}`); const input = document.getElementById(`input-${id}`);
const add = document.getElementById(`add-${id}`); const add = document.getElementById(`add-${id}`);
@ -65,6 +98,7 @@ ComboBox.utilities = {
opt.value = data.id; opt.value = data.id;
opt.textContent = data[labelAttr] ?? val; opt.textContent = data[labelAttr] ?? val;
list.appendChild(opt); list.appendChild(opt);
this.sortList(id);
input.value = ''; input.value = '';
}, },
@ -81,8 +115,9 @@ ComboBox.utilities = {
if (res.ok) { if (res.ok) {
selected.remove(); selected.remove();
this.sortList(id);
toastMessage(`Deleted ${id} successfully.`, 'success'); toastMessage(`Deleted ${id} successfully.`, 'success');
this.changeRemove(id); this.handleSelect(id);
return; return;
} }
@ -106,7 +141,7 @@ ComboBox.utilities = {
const opt = list?.selectedOptions?.[0]; const opt = list?.selectedOptions?.[0];
if (!opt) return; if (!opt) return;
const val = opt.value; // id of the row const val = opt.value;
const newText = (input?.value ?? '').trim(); const newText = (input?.value ?? '').trim();
if (!newText) return; if (!newText) return;
@ -133,7 +168,6 @@ ComboBox.utilities = {
return; return;
} }
// not ok -> try to show the server error
try { data = await res.json(); } catch { data = { error: await res.text() }; } try { data = await res.json(); } catch { data = { error: await res.text() }; }
toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger'); toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger');
} catch (e) { } catch (e) {

View 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();
}
})();

View 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);
}
};

View 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; }
}

View file

@ -36,6 +36,12 @@
<a href="{{ url_for('listing.show_list', model='user') }}" <a href="{{ url_for('listing.show_list', model='user') }}"
class="nav-link link-success fw-semibold">Users</a> class="nav-link link-success fw-semibold">Users</a>
</li> </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> </ul>
{% block header %} {% block header %}
{% endblock %} {% endblock %}
@ -43,6 +49,13 @@
<input type="text" id="searchText" class="form-control me-3"> <input type="text" id="searchText" class="form-control me-3">
<button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button> <button type="button" id="searchButton" class="btn btn-primary" disabled>Search</button>
</div> </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> </div>
</nav> </nav>
</header> </header>
@ -126,7 +139,7 @@
const list = JSON.parse(sessionStorage.getItem(key) || '[]'); const list = JSON.parse(sessionStorage.getItem(key) || '[]');
list.push({ message, type }); list.push({ message, type });
sessionStorage.setItem(key, JSON.stringify(list)); sessionStorage.setItem(key, JSON.stringify(list));
} catch {} } catch { }
}; };
{% block script %} {% block script %}
@ -141,7 +154,7 @@
for (const t of list) { for (const t of list) {
window.toastMessage(t.message, t.type || 'info'); window.toastMessage(t.message, t.type || 'info');
} }
} catch {} } catch { }
}); });
</script> </script>
</body> </body>

View file

@ -2,7 +2,7 @@
{% macro combobox(id, name, placeholder, items, value_attr='id', label_attr='name', {% macro combobox(id, name, placeholder, items, value_attr='id', label_attr='name',
add_disabled=True, remove_disabled=True, edit_disabled=True) %} add_disabled=True, remove_disabled=True, edit_disabled=True) %}
<div class="col combobox"> <div class="combobox">
<div class="d-flex"> <div class="d-flex">
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0" <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 }}');"> placeholder="{{ placeholder }}" id="input-{{id}}" oninput="ComboBox.utilities.changeAdd('{{ id }}');">

View file

@ -1,9 +1,7 @@
{# show label unless hidden/custom #} <!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{% if field_type != 'hidden' and field_label %} {% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" <label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
{% if label_attrs %}{% for k,v in label_attrs.items() %} %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% if link_href %} {% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold"> <a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %} {% endif %}
@ -11,73 +9,80 @@
{% if link_href %} {% if link_href %}
</a> </a>
{% endif %} {% endif %}
</label> </label>
{% endif %} {% endif %}
{% if field_type == 'select' %} {% if field_type == 'select' %}
<select name="{{ field_name }}" id="{{ field_name }}" {#
{% if attrs %}{% for k,v in attrs.items() %} <select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
{% endfor %}{% endif %}
{%- if not options %} disabled{% endif %}>
{% if options %} {% if options %}
<option value="">-- Select --</option> <option value="">-- Select --</option>
{% for opt in options %} {% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string == value|string %}selected{% endif %}> <option value="{{ opt.value }}" {% if opt.value|string==value|string %}selected{% endif %}>
{{ opt.label }} {{ opt.label }}
</option> </option>
{% endfor %} {% endfor %}
{% else %} {% else %}
<option value="">-- No selection available --</option> <option value="">-- No selection available --</option>
{% endif %} {% endif %}
</select> </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' %} {% elif field_type == 'textarea' %}
<textarea name="{{ field_name }}" id="{{ field_name }}" <textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
{% if attrs %}{% for k,v in attrs.items() %} sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{% elif field_type == 'checkbox' %} {% elif field_type == 'checkbox' %}
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" <input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
{% if value %}checked{% endif %} attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
{% if attrs %}{% for k,v in attrs.items() %} %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == 'hidden' %} {% elif field_type == 'hidden' %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"> <input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
{% elif field_type == 'display' %} {% elif field_type == 'display' %}
<div {% if attrs %}{% for k,v in attrs.items() %} <div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% elif field_type == "date" %} {% elif field_type == "date" %}
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "time" %} {% elif field_type == "time" %}
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "datetime" %} {% elif field_type == "datetime" %}
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
{% if attrs %}{% for k,v in attrs.items() %} attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} %}>
{% endfor %}{% endif %}>
{% else %} {% else %}
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% endif %} {% endif %}
{% if help %} {% if help %}
<div class="form-text">{{ help }}</div> <div class="form-text">{{ help }}</div>
{% endif %} {% endif %}

View file

@ -1,5 +1,5 @@
<div class="table-responsive" style="max-height: 80vh;"> <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> <thead>
<tr> <tr>
{% for col in columns %} {% for col in columns %}

View file

@ -1,8 +1,16 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
{% endblock %}
{% block main %} {% block main %}
<div class="container mt-5"> <div class="container mt-5">
{{ form | safe }} {{ form | safe }}
</div> </div>
{% endblock %} {% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
{% endblock %}

View file

@ -1,15 +1,5 @@
{% extends 'base.html' %} {% 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 %} {% block main %}
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1> <h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
<p class="lead text-center">Find out about all of your assets.</p> <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> <p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }} {{ logs | safe }}
</div> </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> </div>
{% endblock %} {% 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 doesnt 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}

View file

@ -28,40 +28,30 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <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="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> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderView(getMarkdown()); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
}); });
function getMarkdown() { // used by entry_buttons submit
const el = document.getElementById('noteContent'); window.getMarkdown = function () {
return el ? (JSON.parse(el.textContent) || "") : ""; return readJSONScript('noteContent', "");
} };
function setMarkdown(md) { function setMarkdown(md) {
const el = document.getElementById('noteContent'); const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? ""); 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() { function changeMode() {
const container = document.getElementById('editContainer'); const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch'); const toggle = document.getElementById('editSwitch');
if (!toggle.checked) return renderView(getMarkdown()); if (!toggle.checked) {
MarkDown.renderInto(container, getMarkdown());
return;
}
const current = getMarkdown(); const current = getMarkdown();
container.innerHTML = ` container.innerHTML = `
@ -82,13 +72,13 @@
const ta = document.getElementById('editor'); const ta = document.getElementById('editor');
const value = ta ? ta.value : ""; const value = ta ? ta.value : "";
setMarkdown(value); setMarkdown(value);
renderView(value); MarkDown.renderInto(document.getElementById('editContainer'), value);
document.getElementById('editSwitch').checked = false; document.getElementById('editSwitch').checked = false;
} }
function cancelEdit() { function cancelEdit() {
document.getElementById('editSwitch').checked = false; document.getElementById('editSwitch').checked = false;
renderView(getMarkdown()); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
} }
function togglePreview() { function togglePreview() {
@ -96,13 +86,13 @@
const preview = document.getElementById('preview'); const preview = document.getElementById('preview');
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : ""); MarkDown.renderInto(preview, ta ? ta.value : "");
preview.innerHTML = DOMPurify.sanitize(html);
} }
} }
function autoGrow(ta) { function autoGrow(ta) {
if (!ta) return; if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px'; ta.style.height = ta.scrollHeight + 'px';
} }

View file

@ -6,11 +6,14 @@ Inventory Manager - {{ model|title }} Listing
{% block main %} {% block main %}
<div class="mx-5"> <div class="mx-5">
<h1 class="display-4 text-center mt-2">{{ model|title }} Listing</h1> <div class="d-flex justify-content-between">
<div class="btn-group"> <div class="btn-group h-50 align-self-end">
<button type="button" class="btn btn-primary mb-3" <button type="button" class="btn btn-primary mb-2"
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button> onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
</div> </div>
<h1 class="display-6 text-center">{{ model|title }} Listing</h1>
<div></div>
</div>
{{ table | safe }} {{ table | safe }}

View file

@ -23,18 +23,34 @@
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0"> <div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
<div class="row"> <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') }} {{ 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') }} {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
</div> </div>
</div> </div>
</div>
<div class="tab-pane fade" id="location-tab-pane" tabindex="0"> <div class="tab-pane fade" id="location-tab-pane" tabindex="0">
<div class="row"> <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') }} {{ 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') }} {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div> </div>
</div>
<div class="row mt-3"> <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"> <div class="col">
{{ rooms | safe }} {{ rooms | safe }}
</div> </div>

View 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 %}

View file

@ -16,7 +16,7 @@
<ul class="list-group mt-3"> <ul class="list-group mt-3">
{% for n in items %} {% 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="d-flex justify-content-between align-items-start">
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div> <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> <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/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.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> <script>
// State (kept global for compatibility with your form serialization) // State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || []; window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || []; 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) { function getMarkdown(id) {
const el = document.getElementById(`md-${id}`); return readJSONScript(`md-${id}`, "");
return el ? JSON.parse(el.textContent || '""') : "";
} }
function setMarkdown(id, md) { function setMarkdown(id, md) {
@ -114,14 +85,14 @@
function autoGrow(ta) { function autoGrow(ta) {
if (!ta) return; if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 5) + 'px'; ta.style.height = (ta.scrollHeight + 5) + 'px';
} }
// ---------- RENDERERS ---------- // ---------- RENDERERS ----------
function renderExistingView(id) { function renderExistingView(id) {
const container = document.getElementById(`editContainer${id}`); MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
renderHTML(container, getMarkdown(id));
} }
function renderEditor(id) { function renderEditor(id) {
@ -159,8 +130,7 @@
const left = document.createElement('div'); const left = document.createElement('div');
left.className = 'w-100 markdown-body'; left.className = 'w-100 markdown-body';
left.innerHTML = renderMarkdown(md || ''); MarkDown.renderInto(left, md || '');
enhanceLinks(left);
const right = document.createElement('div'); const right = document.createElement('div');
right.className = 'ms-3 d-flex flex-column align-items-end'; right.className = 'ms-3 d-flex flex-column align-items-end';
@ -263,7 +233,7 @@
if (!preview) return; if (!preview) return;
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
preview.innerHTML = renderMarkdown(ta ? ta.value : ""); MarkDown.renderInto(preview, ta ? ta.value : "");
} }
} }

View file

@ -10,6 +10,7 @@ dependencies = [
"flask_sqlalchemy", "flask_sqlalchemy",
"html5lib", "html5lib",
"jinja_markdown", "jinja_markdown",
"matplotlib",
"pandas", "pandas",
"pyodbc", "pyodbc",
"python-dotenv", "python-dotenv",