diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 39e7c49..4e310d3 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -2,45 +2,29 @@ from __future__ import annotations -from flask import Blueprint, jsonify, request, abort, current_app, url_for -from hashlib import md5 +from flask import Blueprint, jsonify, request, abort 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: @@ -66,75 +50,65 @@ 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 = _args_flat() + args = request.args.to_dict(flat=True) # 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 _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @bp.get("/") def rest_get(obj_id: int): - item = service.get(obj_id, request.args) - if item is None: - abort(404) - 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 + 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 @bp.post("/") def rest_create(): - payload = _json_payload() + payload = request.get_json(silent=True) or {} try: obj = service.create(payload) resp = jsonify(obj.as_dict()) resp.status_code = 201 - resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) + resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}" return resp except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @bp.patch("/") def rest_update(obj_id: int): - payload = _json_payload() + payload = request.get_json(silent=True) or {} try: obj = service.update(obj_id, payload) return jsonify(obj.as_dict()) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @bp.delete("/") def rest_delete(obj_id: int): - hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] + hard = (request.args.get("hard") in ("1", "true", "yes")) try: obj = service.delete(obj_id, hard=hard) if obj is None: abort(404) return ("", 204) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 # ---------- 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 @@ -144,52 +118,48 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r abort(404) return jsonify(item.as_dict()) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @bp.get("/list") def rpc_list(): - print("⚠️ WARNING: Deprecated RPC call used: /list") - args = _args_flat() + args = request.args.to_dict(flat=True) try: items = service.list(args) return jsonify([obj.as_dict() for obj in items]) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @bp.post("/create") def rpc_create(): - print("⚠️ WARNING: Deprecated RPC call used: /create") - payload = _json_payload() + payload = request.get_json(silent=True) or {} try: obj = service.create(payload) return jsonify(obj.as_dict()), 201 except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @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 = _json_payload() + payload = request.get_json(silent=True) or {} try: obj = service.update(id_, payload) return jsonify(obj.as_dict()) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 @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 = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] + hard = (request.args.get("hard") in ("1", "true", "yes")) try: obj = service.delete(id_, hard=hard) return ("", 204) if obj is not None else abort(404) except Exception as e: - return _json_error(e) + return jsonify({"status": "error", "error": str(e)}), 400 return bp diff --git a/crudkit/core/base.py b/crudkit/core/base.py index d73a04f..c42b90e 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,66 +1,18 @@ -from functools import lru_cache -from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast +from typing import Any, Dict, Iterable, List, Tuple, Set from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect -from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper -from sqlalchemy.orm.state import InstanceState +from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty Base = declarative_base() -@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) - 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 - + st = inspect(obj) + attr = st.attrs.get(name) 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 @@ -81,11 +33,14 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool: def _serialize_simple_obj(obj) -> Dict[str, Any]: """Columns only (no relationships).""" out: Dict[str, Any] = {} - for name in _column_names_for_model(type(obj)): - try: - out[name] = getattr(obj, name) - except Exception: - out[name] = None + for cls in obj.__class__.__mro__: + if hasattr(cls, "__table__"): + for col in cls.__table__.columns: + name = col.name + try: + out[name] = getattr(obj, name) + except Exception: + out[name] = None return out def _serialize_loaded_rel(obj, name, *, depth: int, seen: Set[Tuple[type, Any]], embed: Set[str]) -> Any: @@ -249,16 +204,12 @@ class CRUDMixin: # Determine which relationships to consider try: - 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 + st = inspect(self) + mapper = st.mapper + embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names for name, prop in mapper.relationships.items(): # Only touch relationships that are already loaded; never lazy-load here. - rel_loaded = getattr(st, "attrs", {}).get(name) + rel_loaded = st.attrs.get(name) if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE: continue @@ -315,10 +266,13 @@ class CRUDMixin: val = None # If it's a scalar ORM object (relationship), serialize its columns - mapper = _sa_mapper(val) - if mapper is not None: - out[name] = _serialize_simple_obj(val) - continue + try: + st = inspect(val) # will raise if not an ORM object + if getattr(st, "mapper", None) 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)): diff --git a/inventory/__init__.py b/inventory/__init__.py index 0dc7e3f..49347e0 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -21,7 +21,6 @@ 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__) @@ -46,6 +45,7 @@ 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,12 +74,13 @@ 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): diff --git a/inventory/db.py b/inventory/db.py index b1b7e2e..0b863a8 100644 --- a/inventory/db.py +++ b/inventory/db.py @@ -12,6 +12,7 @@ _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) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index addb6a7..7e3acdc 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -26,7 +26,6 @@ def _fields_for_model(model: str): "condition", "notes", "owner.id", - "image.filename", ] fields_spec = [ {"name": "label", "type": "display", "label": "", "row": "label", @@ -283,12 +282,6 @@ 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"] @@ -332,10 +325,6 @@ 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) @@ -433,6 +422,7 @@ 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) diff --git a/inventory/routes/index.py b/inventory/routes/index.py index b6e156a..794c5ad 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -15,6 +15,7 @@ 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, @@ -25,6 +26,41 @@ 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", @@ -35,7 +71,7 @@ def init_index_routes(app): logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) - return render_template("index.html", logs=logs) + return render_template("index.html", logs=logs, chart_data=chart_data) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/listing.py b/inventory/routes/listing.py index ff3e2a9..95301e9 100644 --- a/inventory/routes/listing.py +++ b/inventory/routes/listing.py @@ -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 18 + int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 15 ) sort = request.args.get("sort") fields_qs = request.args.get("fields") @@ -54,13 +54,12 @@ def init_listing_routes(app): elif model.lower() == 'user': spec = {"fields": [ "label", "last_name", "first_name", "supervisor.label", - "robot.overlord", "staff", "active", "title", + "robot.overlord", "staff", "active", ], "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"}, diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py index 2d69cba..d12bff9 100644 --- a/inventory/routes/reports.py +++ b/inventory/routes/reports.py @@ -1,95 +1,142 @@ -from flask import Blueprint, render_template, url_for -from urllib.parse import urlencode - +import math import pandas as pd -import crudkit +from flask import Blueprint, request, jsonify +from sqlalchemy import select, func, case -bp_reports = Blueprint("reports", __name__) +from ..db import get_session +from ..models.inventory import Inventory +from ..models.device_type import DeviceType -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 = Blueprint("reports", __name__, url_prefix="/api/reports") - rows = inventory_service.list({ - "limit": 0, - "sort": "device_type.description", - "fields": ["id", "condition", "device_type.description"], +@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 }) - df = pd.DataFrame([r.as_dict() for r in rows]) + items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True) + return jsonify({"items": items[:top_n]}) - 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, +@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) + ) - # 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] + # 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)) - # Drop zero-only rows - pt = pt.loc[(pt != 0).any(axis=1)] + 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)) - # 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) + rows = session.execute(stmt).all() + return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows]) diff --git a/inventory/static/css/components/dropdown.css b/inventory/static/css/components/dropdown.css deleted file mode 100644 index fee3d50..0000000 --- a/inventory/static/css/components/dropdown.css +++ /dev/null @@ -1,4 +0,0 @@ -.inventory-dropdown { - border-color: rgb(222, 226, 230) !important; - overflow-y: auto; -} diff --git a/inventory/static/js/components/combobox.js b/inventory/static/js/components/combobox.js index 874dbbb..4ed1a77 100644 --- a/inventory/static/js/components/combobox.js +++ b/inventory/static/js/components/combobox.js @@ -1,39 +1,6 @@ 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}`); @@ -98,7 +65,6 @@ ComboBox.utilities = { opt.value = data.id; opt.textContent = data[labelAttr] ?? val; list.appendChild(opt); - this.sortList(id); input.value = ''; }, @@ -115,9 +81,8 @@ ComboBox.utilities = { if (res.ok) { selected.remove(); - this.sortList(id); toastMessage(`Deleted ${id} successfully.`, 'success'); - this.handleSelect(id); + this.changeRemove(id); return; } @@ -141,7 +106,7 @@ ComboBox.utilities = { const opt = list?.selectedOptions?.[0]; if (!opt) return; - const val = opt.value; + const val = opt.value; // id of the row const newText = (input?.value ?? '').trim(); if (!newText) return; @@ -168,6 +133,7 @@ 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) { diff --git a/inventory/static/js/components/dropdown.js b/inventory/static/js/components/dropdown.js deleted file mode 100644 index cc8068d..0000000 --- a/inventory/static/js/components/dropdown.js +++ /dev/null @@ -1,95 +0,0 @@ -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(); - } -})(); diff --git a/inventory/static/js/components/markdown.js b/inventory/static/js/components/markdown.js deleted file mode 100644 index 9d18065..0000000 --- a/inventory/static/js/components/markdown.js +++ /dev/null @@ -1,36 +0,0 @@ -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); - } -}; diff --git a/inventory/static/js/utils/json.js b/inventory/static/js/utils/json.js deleted file mode 100644 index 44edcba..0000000 --- a/inventory/static/js/utils/json.js +++ /dev/null @@ -1,7 +0,0 @@ -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; } -} diff --git a/inventory/templates/base.html b/inventory/templates/base.html index dfbc595..18930f9 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -36,12 +36,6 @@ Users - {% block header %} {% endblock %} @@ -49,13 +43,6 @@ - - - - - @@ -139,7 +126,7 @@ const list = JSON.parse(sessionStorage.getItem(key) || '[]'); list.push({ message, type }); sessionStorage.setItem(key, JSON.stringify(list)); - } catch { } + } catch {} }; {% block script %} @@ -154,7 +141,7 @@ for (const t of list) { window.toastMessage(t.message, t.type || 'info'); } - } catch { } + } catch {} }); diff --git a/inventory/templates/components/combobox.html b/inventory/templates/components/combobox.html index a4d3945..5fb91e1 100644 --- a/inventory/templates/components/combobox.html +++ b/inventory/templates/components/combobox.html @@ -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) %} -
+
diff --git a/inventory/templates/crudkit/field.html b/inventory/templates/crudkit/field.html index 1a7cdb3..1179f1c 100644 --- a/inventory/templates/crudkit/field.html +++ b/inventory/templates/crudkit/field.html @@ -1,88 +1,83 @@ - +{# show label unless hidden/custom #} {% if field_type != 'hidden' and field_label %} - + + {% endif %} + {{ field_label }} + {% if link_href %} + + {% endif %} + {% endif %} {% if field_type == 'select' %} -{# - -#} -{% if options %} -{% if value %} -{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %} -{% else %} -{% set sel_label = "-- Select --" %} -{% endif %} - - -{% else %} - -{% endif %} - + {% elif field_type == 'textarea' %} - + {% elif field_type == 'checkbox' %} - + {% elif field_type == 'hidden' %} - + {% elif field_type == 'display' %} -
{{ value_label if value_label else (value if value else "") }}
+
{{ value_label if value_label else (value if value else "") }}
{% elif field_type == "date" %} - + {% elif field_type == "time" %} - + {% elif field_type == "datetime" %} - + {% else %} - + {% endif %} {% if help %} -
{{ help }}
-{% endif %} \ No newline at end of file +
{{ help }}
+{% endif %} diff --git a/inventory/templates/crudkit/table.html b/inventory/templates/crudkit/table.html index 2fb4ef1..0c05cf2 100644 --- a/inventory/templates/crudkit/table.html +++ b/inventory/templates/crudkit/table.html @@ -1,5 +1,5 @@
- +
{% for col in columns %} diff --git a/inventory/templates/entry.html b/inventory/templates/entry.html index edc939b..3c8c6bf 100644 --- a/inventory/templates/entry.html +++ b/inventory/templates/entry.html @@ -1,16 +1,8 @@ {% extends 'base.html' %} -{% block styleincludes %} - -{% endblock %} - {% block main %}
{{ form | safe }}
-{% endblock %} - -{% block scriptincludes %} - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/index.html b/inventory/templates/index.html index a6a3d6e..b6c429c 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -1,5 +1,15 @@ {% 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 %}

{{ title or "Inventory Manager" }}

Find out about all of your assets.

@@ -9,5 +19,256 @@

Active Worklogs

{{ logs | safe }} + +
+

Inventory Report

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ +
+ +
+
{% endblock %} + +{% block scriptincludes %} + +{% 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 = ''+escapeHtml(state.rows)+''; + for (const c of cols) thead += `${escapeHtml(c)}`; + if (showTotals) thead += 'Total'; + thead += ''; + + // body + let tbody = ''; + for (const r of rows) { + tbody += `${escapeHtml(r)}`; + for (const c of cols) { + const n = counts.get(`${r}|||${c}`) || 0; + tbody += `${n}`; + } + if (showTotals) tbody += `${rowTotals.get(r) || 0}`; + tbody += ''; + } + if (showTotals) { + tbody += 'Total'; + let grand = 0; + for (const c of cols) { + const t = colTotals.get(c) || 0; + grand += t; + tbody += `${t}`; + } + tbody += `${grand}`; + } + 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 %} diff --git a/inventory/templates/inventory_note.html b/inventory/templates/inventory_note.html index 90bf445..abff378 100644 --- a/inventory/templates/inventory_note.html +++ b/inventory/templates/inventory_note.html @@ -28,76 +28,86 @@ - - + function changeMode() { + const container = document.getElementById('editContainer'); + const toggle = document.getElementById('editSwitch'); + if (!toggle.checked) return renderView(getMarkdown()); + + const current = getMarkdown(); + container.innerHTML = ` + +
+ + + +
+
+ `; + const ta = document.getElementById('editor'); + autoGrow(ta); + ta.addEventListener('input', () => autoGrow(ta)); + } + + function saveEdit() { + const ta = document.getElementById('editor'); + const value = ta ? ta.value : ""; + setMarkdown(value); + renderView(value); + document.getElementById('editSwitch').checked = false; + } + + function cancelEdit() { + document.getElementById('editSwitch').checked = false; + renderView(getMarkdown()); + } + + function togglePreview() { + const ta = document.getElementById('editor'); + 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); + } + } + + function autoGrow(ta) { + if (!ta) return; + ta.style.height = 'auto'; + ta.style.height = ta.scrollHeight + 'px'; + } + + function escapeForTextarea(s) { + return (s ?? "").replace(/&/g,'&').replace(//g,'>'); + } + \ No newline at end of file diff --git a/inventory/templates/listing.html b/inventory/templates/listing.html index cf48c71..80fd531 100644 --- a/inventory/templates/listing.html +++ b/inventory/templates/listing.html @@ -6,13 +6,10 @@ Inventory Manager - {{ model|title }} Listing {% block main %}
-
-
- -
-

{{ model|title }} Listing

-
+

{{ model|title }} Listing

+
+
{{ table | safe }} diff --git a/inventory/templates/settings.html b/inventory/templates/settings.html index 2473272..5f30ce3 100644 --- a/inventory/templates/settings.html +++ b/inventory/templates/settings.html @@ -23,34 +23,18 @@
-
- - {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} -
-
- - {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }} -
+ {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} + {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
-
- - {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} -
-
- - {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }} -
+ {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} + {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
-
{{ rooms | safe }}
diff --git a/inventory/templates/summary.html b/inventory/templates/summary.html deleted file mode 100644 index 4a76680..0000000 --- a/inventory/templates/summary.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block main %} -

Inventory Summary

-
- - - - - {% for col in col_headers %} - {% if col.href %} - - {% else %} - - {% endif %} - {% endfor %} - - - - {% for row in table_rows %} - - - {% for cell in row.cells %} - {% if cell.href %} - - {% else %} - - {% endif %} - {% endfor %} - - {% endfor %} - -
Device Type{{ col.label }}{{ col.label }}
- {% if row.href %} - {{ row.label }} - {% else %} - {{ row.label }} - {% endif %} - {{ cell.value }}{{ cell.value }}
-
-{% endblock %} diff --git a/inventory/templates/update_list.html b/inventory/templates/update_list.html index 582442e..1579261 100644 --- a/inventory/templates/update_list.html +++ b/inventory/templates/update_list.html @@ -16,7 +16,7 @@
    {% for n in items %} -
  • +
  • @@ -63,15 +63,44 @@ - -