Compare commits

..

No commits in common. "4ef4d5e23f5bbff55c7295ad874383498034a196" and "dc3482f8872b9565c634cc35e6a2cf994c24142c" have entirely different histories.

10 changed files with 90 additions and 390 deletions

View file

@ -2,13 +2,12 @@ from __future__ import annotations
import os, logging, sys import os, logging, sys
from flask import Flask, render_template, request, current_app from flask import Flask
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
@ -43,27 +42,6 @@ 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

@ -81,11 +81,7 @@ def _fields_for_model(model: str):
"title", "title",
"active", "active",
"staff", "staff",
"supervisor.id", "supervisor.id"
"inventory.label",
"inventory.brand.name",
"inventory.model",
"inventory.device_type.description"
] ]
fields_spec = [ fields_spec = [
{"name": "label", "row": "label", "label": "", "type": "display", {"name": "label", "row": "label", "label": "", "type": "display",
@ -110,7 +106,6 @@ def _fields_for_model(model: str):
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"}, {"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "inventory", "label": "Inventory", "type": "template", "row": "inventory", "template": "user_inventory.html"},
] ]
layout = [ layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
@ -118,7 +113,6 @@ def _fields_for_model(model: str):
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}}, {"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
{"name": "checkboxes", "order": 30, "parent": "details", {"name": "checkboxes", "order": 30, "parent": "details",
"attrs": {"class": "col d-flex flex-column justify-content-end"}}, "attrs": {"class": "col d-flex flex-column justify-content-end"}},
{"name": "inventory", "order": 40},
] ]
elif model == "worklog": elif model == "worklog":
@ -133,7 +127,7 @@ def _fields_for_model(model: str):
"updates.id", "updates.id",
"updates.content", "updates.content",
"updates.timestamp", "updates.timestamp",
"updates.is_deleted" "updates.is_deleted",
] ]
fields_spec = [ fields_spec = [
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}", {"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",

View file

@ -7,7 +7,6 @@ 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
@ -16,7 +15,6 @@ bp_index = Blueprint("index", __name__)
def init_index_routes(app): def init_index_routes(app):
@bp_index.get("/") @bp_index.get("/")
def index(): def index():
# 1. work log stuff (leave it)
work_log_service = crudkit.crud.get_service(WorkLog) work_log_service = crudkit.crud.get_service(WorkLog)
work_logs = work_log_service.list({ work_logs = work_log_service.list({
"complete__ne": 1, "complete__ne": 1,
@ -34,69 +32,10 @@ def init_index_routes(app):
{"field": "work_item.label", "label": "Work Item", {"field": "work_item.label", "label": "Work Item",
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}} "link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
] ]
logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"})
# 2. get device types with targets return render_template("index.html", logs=logs)
device_type_service = crudkit.crud.get_service(DeviceType)
dt_rows = device_type_service.list({
'limit': 0,
'target__gt': 0,
'fields': [
'description',
'target'
],
"sort": "description",
})
# 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)
inv_rows = inventory_service.list({
'limit': 0,
**dt_filter,
'fields': ['device_type.description'],
'sort': 'device_type.description',
})
inventory_df = pd.DataFrame([i.as_dict() for i in inv_rows])
# 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") @bp_index.get("/LICENSE")
def license(): def license():

View file

@ -1,6 +1,4 @@
from datetime import datetime, timedelta from flask import Blueprint, render_template, url_for
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
@ -11,196 +9,83 @@ 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": [ "fields": ["id", "condition.description", "device_type.description"],
"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")
# Normalize text columns pt = df.pivot_table(
df["device_type.description"] = ( index="device_type.description",
df.get("device_type.description") columns="condition.description",
.fillna("(Unspecified)") values="id",
.astype(str) aggfunc="count",
fill_value=0,
) )
# condition.category might be Enum(StatusCategory). We want the human values, e.g. "Active". # Reorder/exclude like before
if "condition.category" in df.columns: order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
def _enum_value(x): exclude = ["Removed", "Disposed"]
# StatusCategory is str, enum.Enum, so x.value is the nice string ("Active"). 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]
try: pt = pt[cols]
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)
# Build the pivot by CATEGORY # Drop zero-only rows
cat_col = "condition.category" pt = pt.loc[(pt != 0).any(axis=1)]
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
if not pt.empty and ordered: pt["Total"] = pt.sum(axis=1)
# Per-row totals (counts only) total_row = pt.sum(axis=0).to_frame().T
pt["Total"] = pt[ordered].sum(axis=1) total_row.index = ["Total"]
# Build totals row (counts only). pt = pd.concat([pt, total_row], axis=0)
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)
# Strip pandas names # Names off
pt.index.name = None pt.index.name = None
pt.columns.name = None pt.columns.name = None
# Construct headers from the exact columns in the pivot (including Total if present) # 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") 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
columns_for_render = list(pt.columns) if not pt.empty else [] def q(h):
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 columns_for_render: for col in pt.columns.tolist():
# Only make category columns clickable; planning/Total are informational if col == "Total":
if col == "Total" or col in planning_cols: col_headers.append({"label": col, "href": None})
col_headers.append({"label": label_for(col), "href": None})
else: else:
col_headers.append({"label": label_for(col), "href": q({cat_col: col})}) col_headers.append({"label": col, "href": q({"condition": col})})
# Build rows. Cells iterate over the SAME list used for headers. No surprises. # Rows with header links and cell links
table_rows = [] table_rows = []
index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique()) for idx in pt.index.tolist():
for idx in index_for_render: # Row header link: only if not Total
is_total_row = (idx == "Total") if idx == "Total":
row_href = None if is_total_row else q({"device_type.description": idx}) row_href = None
else:
row_href = q({"device_type.description": idx})
# Cells: combine filters, respecting Total row/col rules
cells = [] cells = []
for col in columns_for_render: for col in pt.columns.tolist():
# Safe fetch val = int(pt.at[idx, col])
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 not is_total_row: if idx != "Total":
params["device_type.description"] = idx params["device_type.description"] = idx
if col not in ("Total", *planning_cols): if col != "Total":
params[cat_col] = col params["condition"] = col
href = q(params) if params else None href = q(params) if params else None # None for Total×Total
cells.append({"value": s, "href": href}) cells.append({"value": f"{val:,}", "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(
@ -209,6 +94,7 @@ 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

@ -53,6 +53,7 @@ def init_settings_routes(app):
], ],
}) })
statuses = render_table(statuses, opts={"object_class": 'status'}) 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) return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses)

View file

@ -1,8 +0,0 @@
{% 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

@ -1,8 +0,0 @@
{% 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,44 +5,9 @@
<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"> <div class="col pivot-cell ms-5">
<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>
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped table-hover">
{% if not needed_inventory.empty %}
<thead>
<tr>
<th>Device</th>
<th>Target</th>
<th>On Hand</th>
<th>Needed</th>
</tr>
</thead>
<tbody>
{% for row in needed_inventory.itertuples() %}
<tr class="{{ 'table-warning' if row.needed else '' }}"
onclick="location.href='{{ url_for('listing.show_list', model='inventory', device_type_id__eq=row.id) }}&condition.category=Available'"
style="cursor: pointer;">
<td>{{ row.description }}</td>
<td>{{ row.target }}</td>
<td>{{ row.actual }}</td>
<td class="{{ 'fw-bold' if row.needed else '' }}">{{ row.needed }}</td>
</tr>
{% endfor %}
</tbody>
{% else %}
<thead>
<tr>
<th colspan="4" class="text-center">No data.</th>
</tr>
</thead>
{% endif %}
</table>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,54 +1,40 @@
{% 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 overflow-y-auto border" style="max-height: 70vh;"> <div class="table-responsive mx-5">
<table class="table table-sm table-striped table-hover table-bordered align-middle mb-0"> <table class="table table-sm table-striped table-hover table-bordered align-middle">
<thead> <thead>
<tr class="position-sticky top-0 bg-body border"> <tr>
<th class="text-nowrap position-sticky top-0 bg-body border">Device Type</th> <th class="text-nowrap">Device Type</th>
{% for col in col_headers %} {% for col in col_headers %}
{% if col.href %} {% if col.href %}
<th class="text-end position-sticky top-0 bg-body border"><a <th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
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 %}
</tr>
</thead>
<tbody>
{% for row in table_rows %}
<tr>
<th class="text-nowrap">
{% if row.href %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %}
{{ row.label }}
{% endif %}
</th>
{% for cell in row.cells %}
{% if cell.href %}
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
{% else %}
<td class="text-end">{{ cell.value }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %} {% endfor %}
</tr> </tbody>
</thead> </table>
<tbody> </div>
{% for row in table_rows %}
{% set need_more = (row['cells'][-2]['value'] | int > 0) %}
<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 %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %}
{{ row.label }}
{% endif %}
</th>
{% for cell in row.cells %}
{% if cell.href %}
<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 %}
<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 %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View file

@ -1,33 +0,0 @@
<label class="form-label mt-2">Assigned Inventory</label>
{% set inv = field['template_ctx']['values']['inventory'] %}
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped table-hover">
{% if inv %}
<thead>
<tr>
<th>Device</th>
<th>Brand</th>
<th>Model</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for i in inv %}
<tr style="cursor: pointer;" onclick="location.href='{{ url_for('entry.entry', model='inventory', id=i.id) }}'">
<td>{{ i.label }}</td>
<td>{{ i['brand.name'] }}</td>
<td>{{ i.model }}</td>
<td>{{ i['device_type.description'] }}</td>
</tr>
{% endfor %}
</tbody>
{% else %}
<thead>
<tr>
<th colspan="4" class="text-center">No data.</th>
</tr>
</thead>
{% endif %}
</table>
</div>