Summary screen and error screens.

This commit is contained in:
Yaro Kasear 2025-10-29 15:50:11 -05:00
parent dc3482f887
commit 8481a40553
7 changed files with 256 additions and 58 deletions

View file

@ -2,12 +2,13 @@ from __future__ import annotations
import os, logging, sys import os, logging, sys
from flask import Flask from flask import Flask, render_template, request, current_app
from jinja_markdown import MarkdownExtension from jinja_markdown import MarkdownExtension
from pathlib import Path from pathlib import Path
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.pool import Pool from sqlalchemy.pool import Pool
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.profiler import ProfilerMiddleware from werkzeug.middleware.profiler import ProfilerMiddleware
import crudkit import crudkit
@ -42,6 +43,27 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
except Exception as e: except Exception as e:
return {"error": str(e)}, 500 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) crudkit.init_crud(app)
print(f"Effective DB URL: {str(runtime.engine.url)}") print(f"Effective DB URL: {str(runtime.engine.url)}")

View file

@ -7,6 +7,7 @@ import crudkit
from crudkit.ui.fragments import render_table from crudkit.ui.fragments import render_table
from ..models.device_type import DeviceType
from ..models.inventory import Inventory from ..models.inventory import Inventory
from ..models.work_log import WorkLog 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"}) 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") @bp_index.get("/LICENSE")
def license(): def license():

View file

@ -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 from urllib.parse import urlencode
import pandas as pd import pandas as pd
@ -9,83 +11,196 @@ import crudkit
bp_reports = Blueprint("reports", __name__) 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): def init_reports_routes(app):
@bp_reports.get('/summary') @bp_reports.get('/summary')
def summary(): def summary():
inventory_model = crudkit.crud.get_model('inventory') inventory_model = crudkit.crud.get_model('inventory')
inventory_service = crudkit.crud.get_service(inventory_model) 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({ rows = inventory_service.list({
"limit": 0, "limit": 0,
"sort": "device_type.description", "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: if "id" in df.columns:
df = df.drop_duplicates(subset="id") df = df.drop_duplicates(subset="id")
pt = df.pivot_table( # Normalize text columns
index="device_type.description", df["device_type.description"] = (
columns="condition.description", df.get("device_type.description")
values="id", .fillna("(Unspecified)")
aggfunc="count", .astype(str)
fill_value=0,
) )
# Reorder/exclude like before # condition.category might be Enum(StatusCategory). We want the human values, e.g. "Active".
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"] if "condition.category" in df.columns:
exclude = ["Removed", "Disposed"] def _enum_value(x):
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] # StatusCategory is str, enum.Enum, so x.value is the nice string ("Active").
pt = pt[cols] 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 # Build the pivot by CATEGORY
pt = pt.loc[(pt != 0).any(axis=1)] 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 well 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 # Totals
pt["Total"] = pt.sum(axis=1) if not pt.empty and ordered:
total_row = pt.sum(axis=0).to_frame().T # Per-row totals (counts only)
total_row.index = ["Total"] pt["Total"] = pt[ordered].sum(axis=1)
pt = pd.concat([pt, total_row], axis=0) # 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.index.name = None
pt.columns.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") 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): columns_for_render = list(pt.columns) if not pt.empty else []
return f"{base_list_url}?{urlencode(h)}" if h else None
# 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 = [] col_headers = []
for col in pt.columns.tolist(): for col in columns_for_render:
if col == "Total": # Only make category columns clickable; planning/Total are informational
col_headers.append({"label": col, "href": None}) if col == "Total" or col in planning_cols:
col_headers.append({"label": label_for(col), "href": None})
else: 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 = [] table_rows = []
for idx in pt.index.tolist(): index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique())
# Row header link: only if not Total for idx in index_for_render:
if idx == "Total": is_total_row = (idx == "Total")
row_href = None row_href = None if is_total_row else q({"device_type.description": idx})
else:
row_href = q({"device_type.description": idx})
# Cells: combine filters, respecting Total row/col rules
cells = [] cells = []
for col in pt.columns.tolist(): for col in columns_for_render:
val = int(pt.at[idx, col]) # 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 <NA>, integer otherwise
if pd.isna(val):
s = ""
else:
s = f"{int(val):,}"
params = {} params = {}
if idx != "Total": if not is_total_row:
params["device_type.description"] = idx params["device_type.description"] = idx
if col != "Total": if col not in ("Total", *planning_cols):
params["condition"] = col params[cat_col] = col
href = q(params) if params else None # None for Total×Total href = q(params) if params else None
cells.append({"value": f"{val:,}", "href": href}) cells.append({"value": s, "href": href})
table_rows.append({"label": idx, "href": row_href, "cells": cells}) table_rows.append({"label": idx, "href": row_href, "cells": cells})
return render_template( return render_template(
@ -94,7 +209,6 @@ def init_reports_routes(app):
table_rows=table_rows, table_rows=table_rows,
) )
@bp_reports.get("/problems") @bp_reports.get("/problems")
def problems(): def problems():
inventory_model = crudkit.crud.get_model('inventory') inventory_model = crudkit.crud.get_model('inventory')

View file

@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}Internal Server Error{% endblock %}
{% block main %}
<h1 class="display-1 text-center">Internal Server Error</h1>
<div class="alert alert-danger text-center">This service has encountered an error. This error has been logged. Please try again later.</div>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}{{ code }} {{ name }}{% endblock %}
{% block main %}
<h1 class="display-1 text-center">{{ code }} - {{ name }}</h1>
<div class="alert alert-danger text-center">{{ description }}</div>
{% endblock %}

View file

@ -5,9 +5,20 @@
<p class="lead text-center">Find out about all of your assets.</p> <p class="lead text-center">Find out about all of your assets.</p>
<div class="row mx-5"> <div class="row mx-5">
<div class="col pivot-cell ms-5"> <div class="col">
<p class="display-6 text-center">Active Worklogs</p> <p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }} {{ logs | safe }}
</div> </div>
<div class="col">
<p class="display-6 text-center">Supply Status</p>
{% for d in device_types %}
<p>
{{ d['description'] }}: {{ d['target'] }} needed
</p>
{% endfor %}
<pre class="border border-black bg-warning-subtle p-2">
{{ needed_inventory }}
</pre>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,24 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block style %}
thead.sticky-top th {
z-index: 2;
}
{% endblock %}
{% block main %} {% block main %}
<h1 class="display-4 text-center mb-3">Inventory Summary</h1> <h1 class="display-4 text-center mb-3">Inventory Summary</h1>
<div class="table-responsive mx-5"> <div class="table-responsive mx-5 overflow-y-auto border" style="max-height: 70vh;">
<table class="table table-sm table-striped table-hover table-bordered align-middle"> <table class="table table-sm table-striped table-hover table-bordered align-middle mb-0">
<thead> <thead>
<tr> <tr class="position-sticky top-0 bg-body border">
<th class="text-nowrap">Device Type</th> <th class="text-nowrap position-sticky top-0 bg-body border">Device Type</th>
{% for col in col_headers %} {% for col in col_headers %}
{% if col.href %} {% if col.href %}
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th> <th class="text-end position-sticky top-0 bg-body border"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
{% else %} {% else %}
<th class="text-end">{{ col.label }}</th> <th class="text-end position-sticky top-0 bg-body border">{{ col.label }}</th>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in table_rows %} {% for row in table_rows %}
<tr> {% set need_more = (row['cells'][-2]['value'] | int > 0) %}
<th class="text-nowrap"> <tr class="{% if need_more %}table-warning{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
<th class="text-nowrap{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
{% if row.href %} {% if row.href %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a> <a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %} {% else %}
@ -27,9 +35,9 @@
</th> </th>
{% for cell in row.cells %} {% for cell in row.cells %}
{% if cell.href %} {% if cell.href %}
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td> <td class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
{% else %} {% else %}
<td class="text-end">{{ cell.value }}</td> <td class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">{{ cell.value }}</td>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>