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/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..8a9d8e4 100644 --- a/inventory/routes/listing.py +++ b/inventory/routes/listing.py @@ -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/templates/base.html b/inventory/templates/base.html index 6dfbabc..dfbc595 100644 --- a/inventory/templates/base.html +++ b/inventory/templates/base.html @@ -36,6 +36,12 @@ Users +