diff --git a/inventory/__init__.py b/inventory/__init__.py index 8c282b4..f19a417 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations import os, logging, sys -from flask import Flask +from flask import Flask, render_template, request, current_app from jinja_markdown import MarkdownExtension from pathlib import Path from sqlalchemy.engine import Engine from sqlalchemy import event from sqlalchemy.pool import Pool +from werkzeug.exceptions import HTTPException from werkzeug.middleware.profiler import ProfilerMiddleware import crudkit @@ -42,6 +43,27 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask: except Exception as e: return {"error": str(e)}, 500 + @app.errorhandler(HTTPException) + def handle_http(e: HTTPException): + code = e.code + if request.accept_mimetypes.best == 'application/json': + return { + "type": "about:blank", + "title": e.name, + "status": code, + "detail": e.description + }, code + return render_template('errors/default.html', code=code, name=e.name, description=e.description), code + + @app.errorhandler(Exception) + def handle_uncaught(e: Exception): + current_app.logger.exception("Unhandled exception") + + if request.accept_mimetypes.best == 'application/json': + return {"title": "Internal Server Error", "status": 500}, 500 + + return render_template("errors/500.html"), 500 + crudkit.init_crud(app) print(f"Effective DB URL: {str(runtime.engine.url)}") diff --git a/inventory/routes/index.py b/inventory/routes/index.py index b6e156a..d0ee22b 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -7,6 +7,7 @@ import crudkit from crudkit.ui.fragments import render_table +from ..models.device_type import DeviceType from ..models.inventory import Inventory from ..models.work_log import WorkLog @@ -35,7 +36,33 @@ def init_index_routes(app): logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) - return render_template("index.html", logs=logs) + device_type_service = crudkit.crud.get_service(DeviceType) + device_types = device_type_service.list({ + 'limit': 0, + 'target__gt': 0, + 'fields': [ + 'description', + 'target', + 'condition.category' + ], + }) + device_types = [d.as_dict() for d in device_types] + dt_ids = [d['id'] for d in device_types] + dt_filter = {'$or': [ + {'device_type_id': d} for d in dt_ids + ], + 'condition.category': 'Available'} + + inventory_service = crudkit.crud.get_service(Inventory) + needed_inventory = inventory_service.list({ + 'limit': 0, + **dt_filter, + 'fields': ['device_type.description'] + }) + needed_inventory = pd.DataFrame([i.as_dict() for i in needed_inventory]) + needed_inventory = pd.pivot_table(needed_inventory, columns='device_type.description', aggfunc='size') + + return render_template("index.html", logs=logs, device_types=device_types, needed_inventory=needed_inventory) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py index d1568f1..58afe61 100644 --- a/inventory/routes/reports.py +++ b/inventory/routes/reports.py @@ -1,4 +1,6 @@ -from flask import Blueprint, render_template, url_for +from datetime import datetime, timedelta +from email.utils import format_datetime +from flask import Blueprint, render_template, url_for, make_response, request from urllib.parse import urlencode import pandas as pd @@ -9,83 +11,196 @@ import crudkit bp_reports = Blueprint("reports", __name__) +def service_unavailable(detail="This feature is termporarily offline. Please try again later.", retry_seconds=3600): + retry_at = format_datetime(datetime.utcnow() + timedelta(seconds=retry_seconds)) + html = render_template("errors/default.html", code=503, name="Service Unavailable", description=detail) + resp = make_response(html, 503) + resp.headers["Retry-After"] = retry_at + resp.headers["Cache-Control"] = "no-store" + return resp + 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) + device_type_model = crudkit.crud.get_model('devicetype') + device_type_service = crudkit.crud.get_service(device_type_model) + + needs = device_type_service.list({"limit": 0, "sort": "description", "fields": ["description", "target"]}) + needs = pd.DataFrame([n.as_dict() for n in needs]) rows = inventory_service.list({ "limit": 0, "sort": "device_type.description", - "fields": ["id", "condition.description", "device_type.description"], + "fields": [ + "id", + "device_type.description", + "condition.category", # enum + "condition.description", # not used for pivot, but handy to have + ], }) - df = pd.DataFrame([r.as_dict() for r in rows]) + data = [r.as_dict() for r in rows] + if not data: + return render_template("summary.html", col_headers=[], table_rows=[]) + + df = pd.DataFrame(data) + + # Dedup by id just in case you have over-eager joins if "id" in df.columns: df = df.drop_duplicates(subset="id") - pt = df.pivot_table( - index="device_type.description", - columns="condition.description", - values="id", - aggfunc="count", - fill_value=0, + # Normalize text columns + df["device_type.description"] = ( + df.get("device_type.description") + .fillna("(Unspecified)") + .astype(str) ) - # 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] + # condition.category might be Enum(StatusCategory). We want the human values, e.g. "Active". + if "condition.category" in df.columns: + def _enum_value(x): + # StatusCategory is str, enum.Enum, so x.value is the nice string ("Active"). + try: + return x.value + except AttributeError: + # Fallback if already a string or something weird + s = str(x) + # If someone handed us "StatusCategory.ACTIVE", fall back to the right half and title-case + return s.split(".", 1)[-1].capitalize() if s.startswith("StatusCategory.") else s + df["condition.category"] = df["condition.category"].map(_enum_value) - # Drop zero-only rows - pt = pt.loc[(pt != 0).any(axis=1)] + # Build the pivot by CATEGORY + cat_col = "condition.category" + if cat_col not in df.columns: + # No statuses at all; show a flat, zero-only pivot so the template stays sane + pt = pd.DataFrame(index=sorted(df["device_type.description"].unique())) + else: + pt = df.pivot_table( + index="device_type.description", + columns=cat_col, + values="id", + aggfunc="count", + fill_value=0, + ) + + if "target" in needs.columns: + needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64") + needs = needs.fillna({"target": pd.NA}) + pt = pt.merge(needs, left_index=True, right_on="description") + # Make the human label the index so the left-most column renders as names, not integers + if "description" in pt.columns: + pt = pt.set_index("description") + + # Keep a handle on the category columns produced by the pivot BEFORE merge + category_cols = list(df[cat_col].unique()) if cat_col in df.columns else [] + category_cols = [c for c in category_cols if c in pt.columns] + + # Cast only count columns to int + if category_cols: + pt[category_cols] = pt[category_cols].fillna(0).astype("int64") + # And make sure target is integer too (nullable so missing stays missing) + if "target" in pt.columns: + pt["target"] = pd.to_numeric(pt["target"], errors="coerce").astype("Int64") + + # Column ordering: show the operationally meaningful ones first, hide junk unless asked + preferred_order = ["Active", "Available", "Pending", "Faulted", "Decommissioned"] + exclude_labels = {"Disposed", "Administrative"} + + # Only tread the category columns as count columns + count_cols = [c for c in category_cols if c not in exclude_labels] + ordered = [c for c in preferred_order if c in count_cols] + [c for c in count_cols if c not in preferred_order] + + # Planning columns: keep them visible, not part of totals + planning_cols = [] + if "target" in pt.columns: + # Derive on_hand/need for convenience; Available might not exist in tiny datasets + on_hand = pt[ordered].get("Available") + if on_hand is not None: + pt["on_hand"] = on_hand + pt["need"] = (pt["target"].fillna(0) - pt["on_hand"]).clip(lower=0).astype("Int64") + planning_cols = ["target", "on_hand", "need"] + else: + planning_cols = ["target"] + + # Reindex to the exact list we’ll render, so headers and cells are guaranteed to match + if not pt.empty: + pt = pt.reindex(columns=ordered + planning_cols) + # Keep rows that have any counts OR have a target (so planning rows with zero on-hand don't vanish) + if pt.shape[1] > 0: + keep_mask = (pt[ordered] != 0).any(axis=1) if ordered else False + if "target" in pt.columns: + keep_mask = keep_mask | pt["target"].notna() + pt = pt.loc[keep_mask] # 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) + if not pt.empty and ordered: + # Per-row totals (counts only) + pt["Total"] = pt[ordered].sum(axis=1) + # Build totals row (counts only). + total_row = pd.DataFrame([pt[ordered].sum()], index=["Total"]) + total_row["Total"] = total_row[ordered].sum(axis=1) + pt = pd.concat([pt, total_row], axis=0) - # Names off + # Strip pandas names pt.index.name = None pt.columns.name = None - # Build link helpers. url_for can't take dotted kwarg keys, so build query strings. + # Construct headers from the exact columns in the pivot (including Total if present) base_list_url = url_for("listing.show_list", model="inventory") + def q(params: dict | None): + return f"{base_list_url}?{urlencode(params)}" if params else None - def q(h): - return f"{base_list_url}?{urlencode(h)}" if h else None + columns_for_render = list(pt.columns) if not pt.empty else [] + + # Prettu display labels for headers (keys stay raw) + friendly = { + "target": "Target", + "on_hand": "On Hand", + "need": "Need", + "Total": "Total", + } + def label_for(col: str) -> str: + return friendly.get(col, col) - # Column headers with links (except Total) col_headers = [] - for col in pt.columns.tolist(): - if col == "Total": - col_headers.append({"label": col, "href": None}) + for col in columns_for_render: + # Only make category columns clickable; planning/Total are informational + if col == "Total" or col in planning_cols: + col_headers.append({"label": label_for(col), "href": None}) else: - col_headers.append({"label": col, "href": q({"condition": col})}) + col_headers.append({"label": label_for(col), "href": q({cat_col: col})}) - # Rows with header links and cell links + # Build rows. Cells iterate over the SAME list used for headers. No surprises. 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}) + index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique()) + for idx in index_for_render: + is_total_row = (idx == "Total") + row_href = None if is_total_row else 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]) + for col in columns_for_render: + # Safe fetch + val = pt.at[idx, col] if (not pt.empty and idx in pt.index and col in pt.columns) else (0 if col in ordered or col == "Total" else pd.NA) + # Pretty foramtting: counts/Total as ints; planning may be nullable + if col in ordered or col == "Total": + val = int(val) if pd.notna(val) else 0 + s = f"{val:,}" + else: + # planning cols: show blank for , integer otherwise + if pd.isna(val): + s = "" + else: + s = f"{int(val):,}" params = {} - if idx != "Total": + if not is_total_row: 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}) + if col not in ("Total", *planning_cols): + params[cat_col] = col + href = q(params) if params else None + cells.append({"value": s, "href": href}) + table_rows.append({"label": idx, "href": row_href, "cells": cells}) return render_template( @@ -94,7 +209,6 @@ def init_reports_routes(app): table_rows=table_rows, ) - @bp_reports.get("/problems") def problems(): inventory_model = crudkit.crud.get_model('inventory') diff --git a/inventory/templates/errors/500.html b/inventory/templates/errors/500.html new file mode 100644 index 0000000..453586d --- /dev/null +++ b/inventory/templates/errors/500.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} + +{% block title %}Internal Server Error{% endblock %} + +{% block main %} +

Internal Server Error

+
This service has encountered an error. This error has been logged. Please try again later.
+{% endblock %} diff --git a/inventory/templates/errors/default.html b/inventory/templates/errors/default.html new file mode 100644 index 0000000..500ed49 --- /dev/null +++ b/inventory/templates/errors/default.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} + +{% block title %}{{ code }} {{ name }}{% endblock %} + +{% block main %} +

{{ code }} - {{ name }}

+
{{ description }}
+{% endblock %} diff --git a/inventory/templates/index.html b/inventory/templates/index.html index a6a3d6e..7ef4a05 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -5,9 +5,20 @@

Find out about all of your assets.

-
+

Active Worklogs

{{ logs | safe }}
+
+

Supply Status

+ {% for d in device_types %} +

+ {{ d['description'] }}: {{ d['target'] }} needed +

+ {% endfor %} +
+      {{ needed_inventory }}
+    
+
{% endblock %} diff --git a/inventory/templates/summary.html b/inventory/templates/summary.html index 4a76680..f407356 100644 --- a/inventory/templates/summary.html +++ b/inventory/templates/summary.html @@ -1,24 +1,32 @@ {% extends "base.html" %} + +{% block style %} + thead.sticky-top th { + z-index: 2; + } +{% endblock %} + {% 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 %}
Device Type
Device Type{{ col.label }}{{ col.label }}{{ col.label }}{{ col.label }}
+ {% set need_more = (row['cells'][-2]['value'] | int > 0) %} +
{% if row.href %} {{ row.label }} {% else %} @@ -27,9 +35,9 @@ {{ cell.value }}{{ cell.value }}{{ cell.value }}{{ cell.value }}