Lots of work for reports support.

This commit is contained in:
Yaro Kasear 2025-09-12 10:45:45 -05:00
parent b8cd972090
commit 31cc630dcf
9 changed files with 544 additions and 26 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"<Inventory({', '.join(parts)})>"

View file

@ -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"<Room(id={self.id}, name={repr(self.name)}, area={repr(self.area.name)}, function={repr(self.room_function.description)})>"
@hybrid_property
def label(self):
return f"{self.name} - {self.room_function.description}"

View file

@ -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():

144
inventory/routes/reports.py Normal file
View file

@ -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])

View file

@ -12,7 +12,7 @@
</style>
</head>
<body>
<body class="pb-5">
<header>
<nav class="navbar bg-body-tertiary border border-top-0">
@ -29,12 +29,15 @@
{% block premain %}
{% endblock %}
<main class="container mt-5">
<main class="container mt-3">
{% block main %}
{% endblock %}
</main>
<footer class="bg-body-tertiary border border-bottom-0 position-absolute bottom-0 w-100 pb-1">
{% block postmain %}
{% endblock %}
<footer class="bg-body-tertiary border border-bottom-0 position-fixed bottom-0 w-100 pb-1">
<small>
<span class="align-middle">&copy; 2025 Conrad Nelson •
<a href="/LICENSE" class="link-underline link-underline-opacity-0">AGPL-3.0-or-later</a>
@ -46,6 +49,8 @@
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
{% block scriptincludes %}
{% endblock %}
<script>
document.addEventListener("DOMContentLoaded", () => {
{% block script %}

View file

@ -1,8 +1,264 @@
{% extends 'base.html' %}
{% block main %}
<h1 class="display-1 text-center">{{ title if title else "Inventory Manager" }}</h1>
<p class="lead text-center">Find out about all of your assets.</p>
<h1 class="display-2 text-center">{{ title or "Inventory Manager" }}</h1>
<p class="lead text-center">Find out about all of your assets.</p>
<div class="row">
<div class="col">
<p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }}
{% endblock %}
</div>
<div class="col">
<p class="display-6 text-center">Inventory PivotTable</p>
<div class="d-flex flex-wrap gap-2 align-items-end mb-2">
<div>
<label class="form-label">Rows</label>
<select id="rows-dim" class="form-select form-select-sm">
<option value="device_type" selected>Device Type</option>
<option value="condition">Condition</option>
</select>
</div>
<div>
<label class="form-label">Columns</label>
<select id="cols-dim" class="form-select form-select-sm">
<option value="condition" selected>Condition</option>
<option value="device_type">Device Type</option>
</select>
</div>
<div>
<label class="form-label">Filter: Condition</label>
<select id="filter-condition" class="form-select form-select-sm" multiple size="4">
<option>Deployed</option>
<option>Working</option>
<option>Unverified</option>
<option>Partially Inoperable</option>
<option>Inoperable</option>
<option>Removed</option>
<option>Disposed</option>
</select>
</div>
<div>
<label class="form-label">Top N rows</label>
<input id="top-n" type="number" min="5" max="200" value="40" class="form-control form-control-sm">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="show-totals" checked>
<label class="form-check-label" for="show-totals">Show totals</label>
</div>
<button id="refresh" class="btn btn-sm btn-primary">Update</button>
<button id="export" class="btn btn-sm btn-outline-secondary">Export CSV</button>
</div>
<div class="table-responsive" style="max-height: 520px; overflow: auto;">
<table id="pivot" class="table table-sm table-bordered align-middle mb-2"></table>
</div>
<div class="chart-container" style="height:420px;">
<canvas id="pivot-chart"></canvas>
</div>
</div>
</div>
{% endblock %}
{% block scriptincludes %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block script %}
const state = {
rows: 'device_type',
cols: 'condition',
filterCondition: new Set(),
topN: 40,
data: []
};
const el = {
rows: document.getElementById('rows-dim'),
cols: document.getElementById('cols-dim'),
cond: document.getElementById('filter-condition'),
topN: document.getElementById('top-n'),
totals: document.getElementById('show-totals'),
table: document.getElementById('pivot'),
export: document.getElementById('export'),
refresh: document.getElementById('refresh'),
canvas: document.getElementById('pivot-chart')
};
let chart;
async function fetchRows() {
// apply server-side filters when possible
const condList = [...state.filterCondition];
const qs = condList.length ? `?condition_in=${encodeURIComponent(condList.join(','))}` : '';
const res = await fetch(`/api/reports/inventory/rows${qs}`);
state.data = await res.json();
}
function uniq(values) {
return [...new Set(values)];
}
function pivotize(rows, rowKey, colKey) {
// build sets
const rowVals = uniq(rows.map(r => r[rowKey]));
const colVals = uniq(rows.map(r => r[colKey]));
// counts
const counts = new Map(); // key `${r}|||${c}` -> n
for (const r of rowVals) for (const c of colVals) counts.set(`${r}|||${c}`, 0);
for (const rec of rows) {
const k = `${rec[rowKey]}|||${rec[colKey]}`;
counts.set(k, (counts.get(k) || 0) + 1);
}
// totals per row
const totals = new Map();
for (const r of rowVals) {
let t = 0;
for (const c of colVals) t += counts.get(`${r}|||${c}`) || 0;
totals.set(r, t);
}
// sort rows by total desc, take topN
const sortedRows = rowVals.sort((a,b) => (totals.get(b)||0) - (totals.get(a)||0))
.slice(0, state.topN);
// recompute column totals for visible rows
const colTotals = new Map();
for (const c of colVals) {
let t = 0;
for (const r of sortedRows) t += counts.get(`${r}|||${c}`) || 0;
colTotals.set(c, t);
}
return { rows: sortedRows, cols: colVals, counts, rowTotals: totals, colTotals };
}
function renderTable(piv) {
const { rows, cols, counts, rowTotals, colTotals } = piv;
const showTotals = el.totals.checked;
// header
let thead = '<thead><tr><th>'+escapeHtml(state.rows)+'</th>';
for (const c of cols) thead += `<th>${escapeHtml(c)}</th>`;
if (showTotals) thead += '<th>Total</th>';
thead += '</tr></thead>';
// body
let tbody = '<tbody>';
for (const r of rows) {
tbody += `<tr><th class="text-nowrap">${escapeHtml(r)}</th>`;
for (const c of cols) {
const n = counts.get(`${r}|||${c}`) || 0;
tbody += `<td class="text-end">${n}</td>`;
}
if (showTotals) tbody += `<td class="text-end fw-semibold">${rowTotals.get(r) || 0}</td>`;
tbody += '</tr>';
}
if (showTotals) {
tbody += '<tr class="table-light fw-semibold"><th>Total</th>';
let grand = 0;
for (const c of cols) {
const t = colTotals.get(c) || 0;
grand += t;
tbody += `<td class="text-end">${t}</td>`;
}
tbody += `<td class="text-end">${grand}</td></tr>`;
}
tbody += '</tbody>';
el.table.innerHTML = thead + tbody;
}
function renderChart(piv) {
const { rows, cols, counts } = piv;
// Build Chart.js datasets to mirror the table (stacked horizontal)
const datasets = cols.map(c => ({
label: c,
data: rows.map(r => counts.get(`${r}|||${c}`) || 0)
}));
// height based on rows, with a clamp so it doesnt grow forever
const perRow = 24, pad = 96, maxH = 520;
el.canvas.height = Math.min(rows.length * perRow + pad, maxH);
if (chart) chart.destroy();
chart = new Chart(el.canvas.getContext('2d'), {
type: 'bar',
data: { labels: rows, datasets },
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
title: { display: false },
tooltip: { mode: 'index', intersect: false }
},
scales: {
x: { stacked: true, title: { display: true, text: 'Count' } },
y: { stacked: true, ticks: { autoSkip: false } }
}
}
});
}
function exportCsv(piv) {
const { rows, cols, counts, rowTotals, colTotals } = piv;
const showTotals = el.totals.checked;
let csv = [ [state.rows, ...cols] ];
if (showTotals) csv[0].push('Total');
for (const r of rows) {
const line = [r];
let rt = 0;
for (const c of cols) {
const n = counts.get(`${r}|||${c}`) || 0;
line.push(n);
rt += n;
}
if (showTotals) line.push(rt);
csv.push(line);
}
if (showTotals) {
const totalLine = ['Total', ...cols.map(c => colTotals.get(c) || 0)];
totalLine.push(totalLine.slice(1).reduce((a,b)=>a+b,0));
csv.push(totalLine);
}
const blob = new Blob([csv.map(row => row.map(csvEscape).join(',')).join('\r\n')], {type: 'text/csv'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'inventory_pivot.csv';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function getSelectedOptions(selectEl) {
return [...selectEl.options].filter(o => o.selected).map(o => o.value);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function csvEscape(v) {
const s = String(v ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
}
async function refresh() {
state.rows = el.rows.value;
state.cols = el.cols.value;
state.topN = parseInt(el.topN.value || '40', 10);
state.filterCondition = new Set(getSelectedOptions(el.cond));
await fetchRows();
const piv = pivotize(state.data, state.rows, state.cols);
renderTable(piv);
renderChart(piv);
el.export.onclick = () => exportCsv(piv);
}
el.refresh.addEventListener('click', refresh);
refresh();
{% endblock %}