From 31cc630dcf2feb1abba76de094d5818de07ab1ed Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 12 Sep 2025 10:45:45 -0500 Subject: [PATCH] Lots of work for reports support. --- crudkit/core/metadata.py | 0 crudkit/core/service.py | 53 +++++-- inventory/__init__.py | 2 + inventory/models/inventory.py | 4 +- inventory/models/room.py | 5 + inventory/routes/index.py | 89 ++++++++++- inventory/routes/reports.py | 144 ++++++++++++++++++ inventory/templates/base.html | 11 +- inventory/templates/index.html | 262 ++++++++++++++++++++++++++++++++- 9 files changed, 544 insertions(+), 26 deletions(-) delete mode 100644 crudkit/core/metadata.py create mode 100644 inventory/routes/reports.py diff --git a/crudkit/core/metadata.py b/crudkit/core/metadata.py deleted file mode 100644 index e69de29..0000000 diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 2e1c400..ed0063a 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,12 +1,34 @@ -from typing import Type, TypeVar, Generic, Optional -from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic +from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.util import AliasedClass +from sqlalchemy.engine import Engine, Connection from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec from crudkit.backend import BackendInfo, make_backend_info -T = TypeVar("T") +@runtime_checkable +class _HasID(Protocol): + id: int + +@runtime_checkable +class _HasTable(Protocol): + __table__: Any + +@runtime_checkable +class _HasADict(Protocol): + def as_dict(self) -> dict: ... + +@runtime_checkable +class _SoftDeletable(Protocol): + is_deleted: bool + +class _CRUDModelProto(_HasID, _HasTable, _HasADict, Protocol): + """Minimal surface that our CRUD service relies on. Soft-delete is optional.""" + pass + +T = TypeVar("T", bound=_CRUDModelProto) def _is_truthy(val): return str(val).lower() in ('1', 'true', 'yes', 'on') @@ -25,7 +47,9 @@ class CRUDService(Generic[T]): self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') # Cache backend info once. If not provided, derive from session bind. - self.backend = backend or make_backend_info(self.session.get_bind()) + bind = self.session.get_bind() + eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind) + self.backend = backend or make_backend_info(eng) def get_query(self): if self.polymorphic: @@ -35,7 +59,7 @@ class CRUDService(Generic[T]): # Helper: default ORDER BY for MSSQL when paginating without explicit order def _default_order_by(self, root_alias): - mapper = inspect(self.model) + mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) cols = [] for col in mapper.primary_key: try: @@ -64,11 +88,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -123,11 +145,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -206,7 +226,8 @@ class CRUDService(Generic[T]): if hard or not self.supports_soft_delete: self.session.delete(obj) else: - obj.is_deleted = True + soft = cast(_SoftDeletable, obj) + soft.is_deleted = True self.session.commit() self._log_version("delete", obj, actor) return obj diff --git a/inventory/__init__.py b/inventory/__init__.py index 6013957..3ab61dd 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -19,6 +19,7 @@ def create_app(config_cls=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) Session = app.extensions["crudkit"].get("Session") @@ -49,6 +50,7 @@ def create_app(config_cls=DevConfig) -> Flask: app.register_blueprint(generate_crud_blueprint(_models.User, user_service), url_prefix="/api/user") app.register_blueprint(generate_crud_blueprint(_models.WorkLog, work_log_service), url_prefix="/api/work_log") app.register_blueprint(generate_crud_blueprint(_models.WorkNote, work_note_service), url_prefix="/api/work_note") + app.register_blueprint(bp_reports) init_index_routes(app) diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py index 99068a3..ca2af7b 100644 --- a/inventory/models/inventory.py +++ b/inventory/models/inventory.py @@ -52,10 +52,10 @@ class Inventory(Base, CRUDMixin): parts.append(f"notes={repr(self.notes)}") if self.owner: - parts.append(f"owner={repr(self.owner.identifier)}") + parts.append(f"owner={repr(self.owner.label)}") if self.location: - parts.append(f"location={repr(self.location.identifier)}") + parts.append(f"location={repr(self.location.label)}") return f"" diff --git a/inventory/models/room.py b/inventory/models/room.py index 75e3221..e8cec2f 100644 --- a/inventory/models/room.py +++ b/inventory/models/room.py @@ -1,6 +1,7 @@ from typing import List, Optional, TYPE_CHECKING from sqlalchemy import Boolean, ForeignKey, Integer, Unicode +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression as sql @@ -28,3 +29,7 @@ class Room(Base, CRUDMixin): def __repr__(self): return f"" + + @hybrid_property + def label(self): + return f"{self.name} - {self.room_function.description}" diff --git a/inventory/routes/index.py b/inventory/routes/index.py index 6feb6ab..cc04000 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -1,20 +1,73 @@ -from flask import Blueprint, current_app, render_template, send_file +from flask import Blueprint, current_app, jsonify, render_template, request, send_file from pathlib import Path +from sqlalchemy import func, select + +import pandas as pd from crudkit.core.service import CRUDService from crudkit.core.spec import CRUDSpec from crudkit.ui.fragments import render_table from ..db import get_session +from ..models.device_type import DeviceType +from ..models.inventory import Inventory from ..models.work_log import WorkLog bp_index = Blueprint("index", __name__) def init_index_routes(app): + @bp_index.get("/api/pivot/inventory") + def pivot_inventory(): + session = get_session() + # qs params: rows, cols, where_status, top_n + rows = request.args.get("rows", "device_type.description") + cols = request.args.get("cols", "condition") + top_n = int(request.args.get("top_n", "40")) + + # Map friendly names to columns + COLS = { + "device_type.description": DeviceType.description.label("row"), + "condition": Inventory.condition.label("row"), + "status": Inventory.condition.label("row"), # alias if you use 'status' in UI + } + ROW = COLS.get(rows) + COL = Inventory.condition.label("col") if cols == "condition" else DeviceType.description.label("col") + + stmt = ( + select(ROW, COL, func.count(Inventory.id).label("n")) + .select_from(Inventory).join(DeviceType, Inventory.type_id == DeviceType.id) + .group_by(ROW, COL) + ) + data = session.execute(stmt).all() # [(row, col, n), ...] + + # reshape into Chart.js: labels = sorted rows by total, datasets per column value + import pandas as pd + df = pd.DataFrame(data, columns=["row", "col", "n"]) + if df.empty: + return jsonify({"labels": [], "datasets": []}) + + totals = df.groupby("row")["n"].sum().sort_values(ascending=False) + keep_rows = totals.head(top_n).index.tolist() + df = df[df["row"].isin(keep_rows)] + + labels = keep_rows + by_col = df.pivot_table(index="row", columns="col", values="n", aggfunc="sum", fill_value=0) + by_col = by_col.reindex(labels) # row order + + payload = { + "labels": labels, + "datasets": [ + {"label": str(col), "data": by_col[col].astype(int).tolist()} + for col in by_col.columns + ] + } + return jsonify(payload) + @bp_index.get("/") def index(): session = get_session() + inventory_service = CRUDService(Inventory, session) work_log_service = CRUDService(WorkLog, session) work_logs = work_log_service.list({ "complete__ne": 1, @@ -26,6 +79,38 @@ 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] + 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"}, @@ -38,7 +123,7 @@ def init_index_routes(app): logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) - return render_template("index.html", logs=logs, columns=columns) + return render_template("index.html", logs=logs, chart_data=chart_data) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py new file mode 100644 index 0000000..e914002 --- /dev/null +++ b/inventory/routes/reports.py @@ -0,0 +1,144 @@ +import math +import pandas as pd + +from flask import Blueprint, request, jsonify +from sqlalchemy import select, func, case + +from ..db import get_session +from ..models.inventory import Inventory +from ..models.device_type import DeviceType + +bp_reports = Blueprint("reports", __name__, url_prefix="/api/reports") + +@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() + + print(rows) + + 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) + return jsonify({"items": items[:top_n]}) + +@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) + ) + + # 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)) + + 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)) + + rows = session.execute(stmt).all() + return jsonify([{"device_type": dt, "condition": cond} for dt, cond in rows]) diff --git a/inventory/templates/base.html b/inventory/templates/base.html index 0983a8d..0018ae1 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -12,7 +12,7 @@ - +