diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 4e310d3..39e7c49 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -2,29 +2,45 @@ from __future__ import annotations -from flask import Blueprint, jsonify, request, abort +from flask import Blueprint, jsonify, request, abort, current_app, url_for +from hashlib import md5 from urllib.parse import urlencode +from werkzeug.exceptions import HTTPException -from crudkit.api._cursor import encode_cursor, decode_cursor from crudkit.core.service import _is_truthy +MAX_JSON = 1_000_000 + +def _etag_for(obj) -> str: + v = getattr(obj, "updated_at", None) or obj.id + return md5(str(v).encode()).hexdigest() + +def _json_payload() -> dict: + if request.content_length and request.content_length > MAX_JSON: + abort(413) + if not request.is_json: + abort(415) + payload = request.get_json(silent=False) + if not isinstance(payload, dict): + abort(400) + return payload + +def _args_flat() -> dict[str, str]: + return request.args.to_dict(flat=True) # type: ignore[arg-type] + +def _json_error(e: Exception, status: int = 400): + if isinstance(e, HTTPException): + status = e.code or status + msg = e.description + else: + msg = str(e) + if current_app.debug: + return jsonify({"status": "error", "error": msg, "type": e.__class__.__name__}), status + return jsonify({"status": "error", "error": msg}), status def _bool_param(d: dict[str, str], key: str, default: bool) -> bool: return _is_truthy(d.get(key, "1" if default else "0")) - -def _safe_int(value: str | None, default: int) -> int: - try: - return int(value) if value is not None else default - except Exception: - return default - - -def _link_with_params(base_url: str, **params) -> str: - q = {k: v for k, v in params.items() if v is not None} - return f"{base_url}?{urlencode(q)}" - - def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True): """ REST: @@ -50,65 +66,75 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") + @bp.errorhandler(Exception) + def _handle_any(e: Exception): + return _json_error(e) + + @bp.errorhandler(404) + def _not_found(_e): + return jsonify({"status": "error", "error": "not found"}), 404 + # ---------- REST ---------- if rest: @bp.get("/") def rest_list(): - args = request.args.to_dict(flat=True) + args = _args_flat() # support cursor pagination transparently; fall back to limit/offset try: items = service.list(args) return jsonify([o.as_dict() for o in items]) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.get("/") def rest_get(obj_id: int): - try: - item = service.get(obj_id, request.args) - if item is None: - abort(404) - return jsonify(item.as_dict()) - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + 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 @bp.post("/") def rest_create(): - payload = request.get_json(silent=True) or {} + payload = _json_payload() try: obj = service.create(payload) resp = jsonify(obj.as_dict()) resp.status_code = 201 - resp.headers["Location"] = f"{request.base_url.rstrip('/')}/{obj.id}" + resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) return resp except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.patch("/") def rest_update(obj_id: int): - payload = request.get_json(silent=True) or {} + payload = _json_payload() try: obj = service.update(obj_id, payload) return jsonify(obj.as_dict()) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.delete("/") def rest_delete(obj_id: int): - hard = (request.args.get("hard") in ("1", "true", "yes")) + hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] try: obj = service.delete(obj_id, hard=hard) if obj is None: abort(404) return ("", 204) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) # ---------- RPC (your existing routes) ---------- if rpc: - # your original functions verbatim, shortened here for sanity @bp.get("/get") def rpc_get(): + print("⚠️ WARNING: Deprecated RPC call used: /get") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 @@ -118,48 +144,52 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r abort(404) return jsonify(item.as_dict()) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.get("/list") def rpc_list(): - args = request.args.to_dict(flat=True) + print("⚠️ WARNING: Deprecated RPC call used: /list") + args = _args_flat() try: items = service.list(args) return jsonify([obj.as_dict() for obj in items]) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.post("/create") def rpc_create(): - payload = request.get_json(silent=True) or {} + print("⚠️ WARNING: Deprecated RPC call used: /create") + payload = _json_payload() try: obj = service.create(payload) return jsonify(obj.as_dict()), 201 except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.patch("/update") def rpc_update(): + print("⚠️ WARNING: Deprecated RPC call used: /update") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 - payload = request.get_json(silent=True) or {} + payload = _json_payload() try: obj = service.update(id_, payload) return jsonify(obj.as_dict()) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) @bp.delete("/delete") def rpc_delete(): + print("⚠️ WARNING: Deprecated RPC call used: /delete") id_ = int(request.args.get("id", 0)) if not id_: return jsonify({"status": "error", "error": "missing required param: id"}), 400 - hard = (request.args.get("hard") in ("1", "true", "yes")) + hard = _bool_param(_args_flat(), "hard", False) # type: ignore[arg-type] try: obj = service.delete(id_, hard=hard) return ("", 204) if obj is not None else abort(404) except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return _json_error(e) return bp diff --git a/crudkit/core/base.py b/crudkit/core/base.py index c42b90e..d73a04f 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,18 +1,66 @@ -from typing import Any, Dict, Iterable, List, Tuple, Set +from functools import lru_cache +from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect -from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty +from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper +from sqlalchemy.orm.state import InstanceState Base = declarative_base() -def _safe_get_loaded_attr(obj, name): +@lru_cache(maxsize=512) +def _column_names_for_model(cls: type) -> tuple[str, ...]: + try: + mapper = inspect(cls) + return tuple(prop.key for prop in mapper.column_attrs) + except Exception: + names: list[str] = [] + for c in cls.__mro__: + if hasattr(c, "__table__"): + names.extend(col.name for col in c.__table__.columns) + return tuple(dict.fromkeys(names)) + +def _sa_state(obj: Any) -> Optional[InstanceState[Any]]: + """Safely get SQLAlchemy InstanceState (or None).""" try: st = inspect(obj) - attr = st.attrs.get(name) + return cast(Optional[InstanceState[Any]], st) + except Exception: + return None + +def _sa_mapper(obj: Any) -> Optional[Mapper]: + """Safely get Mapper for a maooed instance (or None).""" + try: + st = inspect(obj) + mapper = getattr(st, "mapper", None) + return cast(Optional[Mapper], mapper) + except Exception: + return None + +def _safe_get_loaded_attr(obj, name): + st = _sa_state(obj) + if st is None: + return None + try: + st_dict = getattr(st, "dict", {}) + if name in st_dict: + return st_dict[name] + + attrs = getattr(st, "attrs", None) + attr = None + if attrs is not None: + try: + attr = attrs[name] + except Exception: + try: + get = getattr(attrs, "get", None) + if callable(get): + attr = get(name) + except Exception: + attr = None + if attr is not None: val = attr.loaded_value return None if val is NO_VALUE else val - if name in st.dict: - return st.dict.get(name) + return None except Exception: return None @@ -33,14 +81,11 @@ def _is_collection_rel(prop: RelationshipProperty) -> bool: def _serialize_simple_obj(obj) -> Dict[str, Any]: """Columns only (no relationships).""" out: Dict[str, Any] = {} - for cls in obj.__class__.__mro__: - if hasattr(cls, "__table__"): - for col in cls.__table__.columns: - name = col.name - try: - out[name] = getattr(obj, name) - except Exception: - out[name] = None + for name in _column_names_for_model(type(obj)): + 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: @@ -204,12 +249,16 @@ class CRUDMixin: # Determine which relationships to consider try: - st = inspect(self) - mapper = st.mapper - embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) # top-level names + mapper = _sa_mapper(self) + embed_set = set(str(x).split(".", 1)[0] for x in (embed or [])) + if mapper is None: + return data + st = _sa_state(self) + if st is None: + return data for name, prop in mapper.relationships.items(): # Only touch relationships that are already loaded; never lazy-load here. - rel_loaded = st.attrs.get(name) + rel_loaded = getattr(st, "attrs", {}).get(name) if rel_loaded is None or rel_loaded.loaded_value is NO_VALUE: continue @@ -266,13 +315,10 @@ class CRUDMixin: val = None # If it's a scalar ORM object (relationship), serialize its columns - try: - st = inspect(val) # will raise if not an ORM object - if getattr(st, "mapper", None) is not None: - out[name] = _serialize_simple_obj(val) - continue - except Exception: - pass + mapper = _sa_mapper(val) + if mapper is not None: + out[name] = _serialize_simple_obj(val) + continue # 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 49347e0..0dc7e3f 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -21,6 +21,7 @@ from .routes.index import init_index_routes from .routes.listing import init_listing_routes from .routes.search import init_search_routes from .routes.settings import init_settings_routes +from .routes.reports import init_reports_routes def create_app(config_cls=crudkit.DevConfig) -> Flask: app = Flask(__name__) @@ -45,7 +46,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: print(f"Effective DB URL: {str(runtime.engine.url)}") from . import models as _models - from .routes.reports import bp_reports _models.Base.metadata.create_all(bind=runtime.engine) # ensure extensions carries the scoped_session from integrations.init_app @@ -74,13 +74,12 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: _models.WorkNote, ]) - app.register_blueprint(bp_reports) - init_entry_routes(app) init_index_routes(app) init_listing_routes(app) init_search_routes(app) init_settings_routes(app) + init_reports_routes(app) @app.teardown_appcontext def _remove_session(_exc): diff --git a/inventory/db.py b/inventory/db.py index 0b863a8..b1b7e2e 100644 --- a/inventory/db.py +++ b/inventory/db.py @@ -12,7 +12,6 @@ _engine = None SessionLocal = None def init_db(database_url: str, engine_kwargs: Dict[str, Any], session_kwargs: Dict[str, Any]) -> None: - print("AM I EVEN BEING RUN?!") global _engine, SessionLocal print(database_url) _engine = create_engine(database_url, **engine_kwargs) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 7e3acdc..addb6a7 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -26,6 +26,7 @@ def _fields_for_model(model: str): "condition", "notes", "owner.id", + "image.filename", ] fields_spec = [ {"name": "label", "type": "display", "label": "", "row": "label", @@ -282,6 +283,12 @@ def init_entry_routes(app): f["label"] = "" f["label_spec"] = "New User" break + elif model == "room": + for f in fields_spec: + if f.get("name") == "label" and f.get("type") == "display": + f["label"] = "" + f["label_spec"] = "New Room" + break obj = cls() ScopedSession = current_app.extensions["crudkit"]["Session"] @@ -325,6 +332,10 @@ def init_entry_routes(app): if "work_item" in payload and "work_item_id" not in payload: payload["work_item_id"] = payload.pop("work_item") + if model == "room": + if "room_function_id" in payload and "function_id" not in payload: + payload["function_id"] = payload.pop("room_function_id") + # Parent first, no commit yet obj = svc.create(payload, actor="create_entry", commit=False) @@ -422,7 +433,6 @@ def init_entry_routes(app): return {"status": "success", "payload": payload} except Exception as e: - print(e) return {"status": "failure", "error": str(e)} app.register_blueprint(bp_entry) diff --git a/inventory/routes/index.py b/inventory/routes/index.py index 794c5ad..b6e156a 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -15,7 +15,6 @@ bp_index = Blueprint("index", __name__) def init_index_routes(app): @bp_index.get("/") def index(): - inventory_service = crudkit.crud.get_service(Inventory) work_log_service = crudkit.crud.get_service(WorkLog) work_logs = work_log_service.list({ "complete__ne": 1, @@ -26,41 +25,6 @@ def init_index_routes(app): ], "sort": "start_time" }) - inventory_report_rows = inventory_service.list({ - "fields": ["condition", "device_type.description"], - "limit": 0 - }) - rows = [item.as_dict() for item in inventory_report_rows] - chart_data = {} - if rows: - df = pd.DataFrame(rows) - - xtab = pd.crosstab(df["condition"], df["device_type.description"]).astype(int) - - top_labels = ( - xtab.sum(axis=0) - .sort_values(ascending=False) - .index.tolist() - ) - xtab = xtab[top_labels] - - preferred_order = [ - "Deployed", "Working", "Unverified", - "Partially Inoperable", "Inoperable", - "Removed", "Disposed" - ] - conditions = [c for c in preferred_order if c in xtab.index] + [c for c in xtab.index if c not in preferred_order] - xtab = xtab.loc[conditions] - - chart_data = { - "labels": top_labels, - "datasets": [{ - "label": cond, - "data": xtab.loc[cond].to_list() - } - for cond in xtab.index] - } - columns = [ {"field": "start_time", "label": "Start", "format": "date"}, {"field": "contact.label", "label": "Contact", @@ -71,7 +35,7 @@ def init_index_routes(app): logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) - return render_template("index.html", logs=logs, chart_data=chart_data) + return render_template("index.html", logs=logs) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/listing.py b/inventory/routes/listing.py index 95301e9..ff3e2a9 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 15 + int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 18 ) sort = request.args.get("sort") fields_qs = request.args.get("fields") @@ -54,12 +54,13 @@ def init_listing_routes(app): elif model.lower() == 'user': spec = {"fields": [ "label", "last_name", "first_name", "supervisor.label", - "robot.overlord", "staff", "active", + "robot.overlord", "staff", "active", "title", ], "sort": "first_name,last_name"} columns = [ {"field": "label", "label": "Full Name"}, {"field": "last_name"}, {"field": "first_name"}, + {"field": "title"}, {"field": "supervisor.label", "label": "Supervisor", "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, {"field": "staff", "format": "yesno"}, diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py index d12bff9..2d69cba 100644 --- a/inventory/routes/reports.py +++ b/inventory/routes/reports.py @@ -1,142 +1,95 @@ -import math +from flask import Blueprint, render_template, url_for +from urllib.parse import urlencode + import pandas as pd -from flask import Blueprint, request, jsonify -from sqlalchemy import select, func, case +import crudkit -from ..db import get_session -from ..models.inventory import Inventory -from ..models.device_type import DeviceType +bp_reports = Blueprint("reports", __name__) -bp_reports = Blueprint("reports", __name__, url_prefix="/api/reports") +def init_reports_routes(app): + @bp_reports.get('/summary') + def summary(): + inventory_model = crudkit.crud.get_model('inventory') + inventory_service = crudkit.crud.get_service(inventory_model) -@bp_reports.get("/inventory/availability") -def inventory_availability(): - """ - Returns Chart.js-ready JSON with labels = device types - and datasets = Deployed / Available / Unavailable counts - Query params: - top_n: int (default 40) -> Limit how many device types to show by total desc - """ - top_n = int(request.args.get("top_n", "40")) - session = get_session() - - deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0)).label("deployed") - available = func.sum(case((Inventory.condition.in_(("Working", "Unverified")), 1), else_=0)).label("available") - unavailable = func.sum( - case((~Inventory.condition.in_(("Deployed", "Working", "Unverified")), 1), else_=0) - ).label("unavailable") - - stmt = ( - select(DeviceType.description.label("device_type"), deployed, available, unavailable) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - .group_by(DeviceType.description) - ) - - rows = session.execute(stmt).all() - - if not rows: - return jsonify({"labels": [], "datasets": []}) - - totals = [(dt, d + a + u) for dt, d, a, u in rows] - totals.sort(key=lambda t: t[1], reverse=True) - keep = {dt for dt, _ in totals[:top_n]} - - labels = [dt for dt, _ in totals if dt in keep] - d_data, a_data, u_data = [], [], [] - by_dt = {dt: (d, a, u) for dt, d, a, u in rows} - for dt in labels: - d, a, u = by_dt[dt] - d_data.append(int(d)) - a_data.append(int(a)) - u_data.append(int(u)) - - payload = { - "labels": labels, - "datasets": [ - {"label": "Deployed", "data": d_data}, - {"label": "Available", "data": a_data}, - {"label": "Unavailable", "data": u_data}, - ], - } - - return jsonify(payload) - -@bp_reports.get("/inventory/spares") -def inventory_spares(): - """ - Query params: - ratio: float (default 0.10) -> 10% spare target - min: int (default 2) -> floor for critical device types - critical: comma list -> applies 'min' floor - top_n: int (default 20) - """ - import math - ratio = float(request.args.get("ratio", "0.10")) - min_floor = int(request.args.get("min", "2")) - critical = set(x.strip() for x in request.args.get("critical", "Monitor,Desktop PC,Laptop").split(",")) - top_n = int(request.args.get("top_n", "20")) - - session = get_session() - deployed = func.sum(case((Inventory.condition == "Deployed", 1), else_=0)) - available = func.sum(case((Inventory.condition.in_(("Working","Unverified")), 1), else_=0)) - stmt = ( - select(DeviceType.description, deployed.label("deployed"), available.label("available")) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - .group_by(DeviceType.description) - ) - rows = session.execute(stmt).all() - - items = [] - for dev, dep, avail in rows: - dep = int(dep or 0) - avail = int(avail or 0) - target = math.ceil(dep * ratio) - if dev in critical: - target = max(target, min_floor) - deficit = max(target - avail, 0) - if dep == 0 and avail == 0 and deficit == 0: - continue - items.append({ - "device_type": dev, - "deployed": dep, - "available": avail, - "target_spares": target, - "deficit": deficit + rows = inventory_service.list({ + "limit": 0, + "sort": "device_type.description", + "fields": ["id", "condition", "device_type.description"], }) - items.sort(key=lambda x: (x["deficit"], x["target_spares"], x["deployed"]), reverse=True) - return jsonify({"items": items[:top_n]}) + df = pd.DataFrame([r.as_dict() for r in rows]) -@bp_reports.get("/inventory/rows") -def inventory_rows(): - """ - Flat rows for PivotTable: device_type, condition. - Optional filters: condition_in=Working,Unverified device_type_in=Monitor,Laptop - """ - session = get_session() - stmt = ( - select( - DeviceType.description.label("device_type"), - Inventory.condition.label("condition") + 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, ) - .select_from(Inventory) - .join(DeviceType, Inventory.type_id == DeviceType.id) - ) - # simple whitelist filters - cond_in = request.args.get("condition_in") - if cond_in: - vals = [s.strip() for s in cond_in.split(",") if s.strip()] - if vals: - stmt = stmt.where(Inventory.condition.in_(vals)) + # Reorder/exclude like before + order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"] + exclude = ["Removed", "Disposed"] + cols = [c for c in order if c in pt.columns] + [c for c in pt.columns if c not in order and c not in exclude] + pt = pt[cols] - dt_in = request.args.get("device_type_in") - if dt_in: - vals = [s.strip() for s in dt_in.split(",") if s.strip()] - if vals: - stmt = stmt.where(DeviceType.description.in_(vals)) + # Drop zero-only rows + pt = pt.loc[(pt != 0).any(axis=1)] - rows = session.execute(stmt).all() - return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows]) + # Totals + pt["Total"] = pt.sum(axis=1) + total_row = pt.sum(axis=0).to_frame().T + total_row.index = ["Total"] + pt = pd.concat([pt, total_row], axis=0) + + # Names off + pt.index.name = None + pt.columns.name = None + + # Build link helpers. url_for can't take dotted kwarg keys, so build query strings. + base_list_url = url_for("listing.show_list", model="inventory") + + def q(h): + return f"{base_list_url}?{urlencode(h)}" if h else None + + # Column headers with links (except Total) + col_headers = [] + for col in pt.columns.tolist(): + if col == "Total": + col_headers.append({"label": col, "href": None}) + else: + col_headers.append({"label": col, "href": q({"condition": col})}) + + # Rows with header links and cell links + table_rows = [] + for idx in pt.index.tolist(): + # Row header link: only if not Total + if idx == "Total": + row_href = None + else: + row_href = q({"device_type.description": idx}) + + # Cells: combine filters, respecting Total row/col rules + cells = [] + for col in pt.columns.tolist(): + val = int(pt.at[idx, col]) + params = {} + if idx != "Total": + params["device_type.description"] = idx + if col != "Total": + params["condition"] = col + href = q(params) if params else None # None for Total×Total + cells.append({"value": f"{val:,}", "href": href}) + table_rows.append({"label": idx, "href": row_href, "cells": cells}) + + return render_template( + "summary.html", + col_headers=col_headers, + table_rows=table_rows, + ) + + app.register_blueprint(bp_reports) diff --git a/inventory/static/css/components/dropdown.css b/inventory/static/css/components/dropdown.css new file mode 100644 index 0000000..fee3d50 --- /dev/null +++ b/inventory/static/css/components/dropdown.css @@ -0,0 +1,4 @@ +.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 4ed1a77..874dbbb 100644 --- a/inventory/static/js/components/combobox.js +++ b/inventory/static/js/components/combobox.js @@ -1,6 +1,39 @@ const ComboBox = globalThis.ComboBox ?? (globalThis.ComboBox = {}); ComboBox.utilities = { + sortList(id) { + const select = document.getElementById(id); + if (!select) return; + + const prevScroll = select.scrollTop; + const prevValue = select.value; + + const options = Array.from(select.options); + let placeholder = null; + if (options.length && ( + options[0].value === '' || + options[0].disabled && (options[0].hidden || options[0].getAttribute('aria-hidden') === 'true') + )) { + placeholder = options.shift(); + } + + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + options.sort((a, b) => collator.compare(a.textContent.trim(), b.textContent.trim())); + + select.innerHTML = ''; + if (placeholder) select.appendChild(placeholder); + for (const opt of options) select.appendChild(opt); + + if (prevValue) { + select.value = prevValue; + if (select.value !== prevValue) { + select.selectedIndex = placeholder ? 0 : -1; + } + } + + select.scrollTop = prevScroll; + }, + changeAdd(id) { const input = document.getElementById(`input-${id}`); const add = document.getElementById(`add-${id}`); @@ -65,6 +98,7 @@ ComboBox.utilities = { opt.value = data.id; opt.textContent = data[labelAttr] ?? val; list.appendChild(opt); + this.sortList(id); input.value = ''; }, @@ -81,8 +115,9 @@ ComboBox.utilities = { if (res.ok) { selected.remove(); + this.sortList(id); toastMessage(`Deleted ${id} successfully.`, 'success'); - this.changeRemove(id); + this.handleSelect(id); return; } @@ -106,7 +141,7 @@ ComboBox.utilities = { const opt = list?.selectedOptions?.[0]; if (!opt) return; - const val = opt.value; // id of the row + const val = opt.value; const newText = (input?.value ?? '').trim(); if (!newText) return; @@ -133,7 +168,6 @@ ComboBox.utilities = { return; } - // not ok -> try to show the server error try { data = await res.json(); } catch { data = { error: await res.text() }; } toastMessage(`Edit failed: ${data?.error || `HTTP ${res.status}`}`, 'danger'); } catch (e) { diff --git a/inventory/static/js/components/dropdown.js b/inventory/static/js/components/dropdown.js new file mode 100644 index 0000000..cc8068d --- /dev/null +++ b/inventory/static/js/components/dropdown.js @@ -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(); + } +})(); diff --git a/inventory/static/js/components/markdown.js b/inventory/static/js/components/markdown.js new file mode 100644 index 0000000..9d18065 --- /dev/null +++ b/inventory/static/js/components/markdown.js @@ -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); + } +}; diff --git a/inventory/static/js/utils/json.js b/inventory/static/js/utils/json.js new file mode 100644 index 0000000..44edcba --- /dev/null +++ b/inventory/static/js/utils/json.js @@ -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; } +} diff --git a/inventory/templates/base.html b/inventory/templates/base.html index 18930f9..dfbc595 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -36,6 +36,12 @@ Users + {% block header %} {% endblock %} @@ -43,6 +49,13 @@ + + + + + @@ -126,7 +139,7 @@ const list = JSON.parse(sessionStorage.getItem(key) || '[]'); list.push({ message, type }); sessionStorage.setItem(key, JSON.stringify(list)); - } catch {} + } catch { } }; {% block script %} @@ -141,7 +154,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 5fb91e1..a4d3945 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 1179f1c..1a7cdb3 100644 --- a/inventory/templates/crudkit/field.html +++ b/inventory/templates/crudkit/field.html @@ -1,83 +1,88 @@ -{# show label unless hidden/custom #} + {% if field_type != 'hidden' and field_label %} - + + {% 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 %} +
{{ help }}
+{% endif %} \ No newline at end of file diff --git a/inventory/templates/crudkit/table.html b/inventory/templates/crudkit/table.html index 0c05cf2..2fb4ef1 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 3c8c6bf..edc939b 100644 --- a/inventory/templates/entry.html +++ b/inventory/templates/entry.html @@ -1,8 +1,16 @@ {% extends 'base.html' %} +{% block styleincludes %} + +{% endblock %} + {% block main %}
{{ form | safe }}
-{% endblock %} \ No newline at end of file +{% endblock %} + +{% block scriptincludes %} + +{% endblock %} diff --git a/inventory/templates/index.html b/inventory/templates/index.html index b6c429c..a6a3d6e 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -1,15 +1,5 @@ {% extends 'base.html' %} -{% block style %} - .chart-container { height: 420px; } - - .pivot-cell { min-width: 0; min-height: 0; } - - .table-responsive { max-height: 420px; overflow: auto; } - - #pivot thead th { position: sticky; top: 0; z-index: 1; background: var(--bs-body-bg); } -{% endblock %} - {% block main %}

{{ title or "Inventory Manager" }}

Find out about all of your assets.

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

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 abff378..90bf445 100644 --- a/inventory/templates/inventory_note.html +++ b/inventory/templates/inventory_note.html @@ -28,86 +28,76 @@ + + \ No newline at end of file + function escapeForTextarea(s) { + return (s ?? "").replace(/&/g,'&').replace(//g,'>'); + } + diff --git a/inventory/templates/listing.html b/inventory/templates/listing.html index 80fd531..cf48c71 100644 --- a/inventory/templates/listing.html +++ b/inventory/templates/listing.html @@ -6,10 +6,13 @@ 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 5f30ce3..2473272 100644 --- a/inventory/templates/settings.html +++ b/inventory/templates/settings.html @@ -23,18 +23,34 @@
- {{ 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 new file mode 100644 index 0000000..4a76680 --- /dev/null +++ b/inventory/templates/summary.html @@ -0,0 +1,40 @@ +{% 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 1579261..582442e 100644 --- a/inventory/templates/update_list.html +++ b/inventory/templates/update_list.html @@ -16,7 +16,7 @@
    {% for n in items %} -
  • +
  • @@ -63,44 +63,15 @@ + +