From 8481a4055348f24990917ab137baff96b7052e68 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 29 Oct 2025 15:50:11 -0500 Subject: [PATCH 1/2] Summary screen and error screens. --- inventory/__init__.py | 24 ++- inventory/routes/index.py | 29 +++- inventory/routes/reports.py | 204 ++++++++++++++++++------ inventory/templates/errors/500.html | 8 + inventory/templates/errors/default.html | 8 + inventory/templates/index.html | 13 +- inventory/templates/summary.html | 28 ++-- 7 files changed, 256 insertions(+), 58 deletions(-) create mode 100644 inventory/templates/errors/500.html create mode 100644 inventory/templates/errors/default.html 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 %} From 4ef4d5e23f5bbff55c7295ad874383498034a196 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 31 Oct 2025 16:31:23 -0500 Subject: [PATCH 2/2] I lost track of all these changes. Congratulations me. --- inventory/routes/entry.py | 10 ++- inventory/routes/index.py | 64 +++++++++++++----- inventory/routes/settings.py | 1 - inventory/templates/index.html | 42 +++++++++--- inventory/templates/summary.html | 88 +++++++++++++------------ inventory/templates/user_inventory.html | 33 ++++++++++ 6 files changed, 170 insertions(+), 68 deletions(-) create mode 100644 inventory/templates/user_inventory.html diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index 951d56e..2f8ab7d 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -81,7 +81,11 @@ def _fields_for_model(model: str): "title", "active", "staff", - "supervisor.id" + "supervisor.id", + "inventory.label", + "inventory.brand.name", + "inventory.model", + "inventory.device_type.description" ] fields_spec = [ {"name": "label", "row": "label", "label": "", "type": "display", @@ -106,6 +110,7 @@ def _fields_for_model(model: str): "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, {"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"}, "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, + {"name": "inventory", "label": "Inventory", "type": "template", "row": "inventory", "template": "user_inventory.html"}, ] layout = [ {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, @@ -113,6 +118,7 @@ def _fields_for_model(model: str): {"name": "details", "order": 20, "attrs": {"class": "row mt-2"}}, {"name": "checkboxes", "order": 30, "parent": "details", "attrs": {"class": "col d-flex flex-column justify-content-end"}}, + {"name": "inventory", "order": 40}, ] elif model == "worklog": @@ -127,7 +133,7 @@ def _fields_for_model(model: str): "updates.id", "updates.content", "updates.timestamp", - "updates.is_deleted", + "updates.is_deleted" ] fields_spec = [ {"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}", diff --git a/inventory/routes/index.py b/inventory/routes/index.py index d0ee22b..240089b 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -16,6 +16,7 @@ bp_index = Blueprint("index", __name__) def init_index_routes(app): @bp_index.get("/") def index(): + # 1. work log stuff (leave it) work_log_service = crudkit.crud.get_service(WorkLog) work_logs = work_log_service.list({ "complete__ne": 1, @@ -33,36 +34,69 @@ def init_index_routes(app): {"field": "work_item.label", "label": "Work Item", "link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}} ] - logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) + # 2. get device types with targets device_type_service = crudkit.crud.get_service(DeviceType) - device_types = device_type_service.list({ + dt_rows = device_type_service.list({ 'limit': 0, 'target__gt': 0, 'fields': [ 'description', - 'target', - 'condition.category' + 'target' ], + "sort": "description", }) - 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'} + # turn into df + device_types = pd.DataFrame([d.as_dict() for d in dt_rows]) + + # if nobody has targets, just show empty table + if device_types.empty: + empty_df = pd.DataFrame(columns=['id', 'description', 'target', 'actual', 'needed']) + return render_template("index.html", logs=logs, needed_inventory=empty_df) + + # 3. now we can safely collect ids from the DF + dt_ids = device_types['id'].tolist() + + # 4. build inventory filter + dt_filter = { + '$or': [{'device_type_id': d} for d in dt_ids], + # drop this if you decided to ignore condition + 'condition.category': 'Available' + } + + # 5. fetch inventory inventory_service = crudkit.crud.get_service(Inventory) - needed_inventory = inventory_service.list({ + inv_rows = inventory_service.list({ 'limit': 0, **dt_filter, - 'fields': ['device_type.description'] + 'fields': ['device_type.description'], + 'sort': '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') + inventory_df = pd.DataFrame([i.as_dict() for i in inv_rows]) - return render_template("index.html", logs=logs, device_types=device_types, needed_inventory=needed_inventory) + # if there is no inventory for these device types, actual = 0 + if inventory_df.empty: + device_types['actual'] = 0 + device_types['needed'] = device_types['target'] + return render_template("index.html", logs=logs, needed_inventory=device_types) + + # 6. aggregate counts + inv_counts = ( + inventory_df['device_type.description'] + .value_counts() + .rename('actual') + .reset_index() + .rename(columns={'device_type.description': 'description'}) + ) + + # 7. merge + merged = device_types.merge(inv_counts, on='description', how='left') + merged['actual'] = merged['actual'].fillna(0).astype(int) + merged['needed'] = (merged['target'] - merged['actual']).clip(lower=0) + + return render_template("index.html", logs=logs, needed_inventory=merged) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/settings.py b/inventory/routes/settings.py index 1128422..4885d1b 100644 --- a/inventory/routes/settings.py +++ b/inventory/routes/settings.py @@ -53,7 +53,6 @@ def init_settings_routes(app): ], }) statuses = render_table(statuses, opts={"object_class": 'status'}) - print([t.as_dict() for t in device_types]) return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses) diff --git a/inventory/templates/index.html b/inventory/templates/index.html index 7ef4a05..eaaf426 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -11,14 +11,38 @@

Supply Status

- {% for d in device_types %} -

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

- {% endfor %} -
-      {{ needed_inventory }}
-    
+
+
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 }}
+ {% if not needed_inventory.empty %} + + + + + + + + + + {% for row in needed_inventory.itertuples() %} + + + + + + + {% endfor %} + + {% else %} + + + + + + {% endif %} +
DeviceTargetOn HandNeeded
{{ row.description }}{{ row.target }}{{ row.actual }}{{ row.needed }}
No data.
+
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/summary.html b/inventory/templates/summary.html index f407356..e7833ab 100644 --- a/inventory/templates/summary.html +++ b/inventory/templates/summary.html @@ -1,48 +1,54 @@ {% extends "base.html" %} {% block style %} - thead.sticky-top th { - z-index: 2; - } +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 %} - {% set need_more = (row['cells'][-2]['value'] | int > 0) %} - - - {% for cell in row.cells %} - {% if cell.href %} - - {% else %} - - {% endif %} - {% endfor %} - +

Inventory Summary

+
+
Device Type{{ col.label }}{{ col.label }}
- {% if row.href %} - {{ row.label }} - {% else %} - {{ row.label }} - {% endif %} - {{ cell.value }}{{ cell.value }}
+ + + + {% for col in col_headers %} + {% if col.href %} + + {% else %} + + {% endif %} {% endfor %} - -
Device Type{{ col.label }}{{ col.label }}
-
-{% endblock %} + + + + {% for row in table_rows %} + {% set need_more = (row['cells'][-2]['value'] | int > 0) %} + + + {% if row.href %} + {{ row.label }} + {% else %} + {{ row.label }} + {% endif %} + + {% for cell in row.cells %} + {% if cell.href %} + + {{ cell.value }} + {% else %} + + {{ cell.value }} + {% endif %} + {% endfor %} + + {% endfor %} + + + +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/user_inventory.html b/inventory/templates/user_inventory.html new file mode 100644 index 0000000..b2d3c40 --- /dev/null +++ b/inventory/templates/user_inventory.html @@ -0,0 +1,33 @@ + +{% set inv = field['template_ctx']['values']['inventory'] %} + +
+ + {% if inv %} + + + + + + + + + + {% for i in inv %} + + + + + + + {% endfor %} + + {% else %} + + + + + + {% endif %} +
DeviceBrandModelType
{{ i.label }}{{ i['brand.name'] }}{{ i.model }}{{ i['device_type.description'] }}
No data.
+
\ No newline at end of file