diff --git a/inventory/__init__.py b/inventory/__init__.py index f19a417..8c282b4 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -2,13 +2,12 @@ from __future__ import annotations import os, logging, sys -from flask import Flask, render_template, request, current_app +from flask import Flask 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 @@ -43,27 +42,6 @@ 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/entry.py b/inventory/routes/entry.py index 2f8ab7d..951d56e 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -81,11 +81,7 @@ def _fields_for_model(model: str): "title", "active", "staff", - "supervisor.id", - "inventory.label", - "inventory.brand.name", - "inventory.model", - "inventory.device_type.description" + "supervisor.id" ] fields_spec = [ {"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"}}, {"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"}}, @@ -118,7 +113,6 @@ 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": @@ -133,7 +127,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 240089b..b6e156a 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -7,7 +7,6 @@ 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 @@ -16,7 +15,6 @@ 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, @@ -34,69 +32,10 @@ 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) - 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) + return render_template("index.html", logs=logs) @bp_index.get("/LICENSE") def license(): diff --git a/inventory/routes/reports.py b/inventory/routes/reports.py index 58afe61..d1568f1 100644 --- a/inventory/routes/reports.py +++ b/inventory/routes/reports.py @@ -1,6 +1,4 @@ -from datetime import datetime, timedelta -from email.utils import format_datetime -from flask import Blueprint, render_template, url_for, make_response, request +from flask import Blueprint, render_template, url_for from urllib.parse import urlencode import pandas as pd @@ -11,196 +9,83 @@ 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", - "device_type.description", - "condition.category", # enum - "condition.description", # not used for pivot, but handy to have - ], + "fields": ["id", "condition.description", "device_type.description"], }) + 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") - # Normalize text columns - df["device_type.description"] = ( - df.get("device_type.description") - .fillna("(Unspecified)") - .astype(str) + pt = df.pivot_table( + index="device_type.description", + columns="condition.description", + values="id", + aggfunc="count", + fill_value=0, ) - # 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) + # 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] - # 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] + # Drop zero-only rows + pt = pt.loc[(pt != 0).any(axis=1)] # Totals - 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) + 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) - # Strip pandas names + # Names off pt.index.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") - 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 [] - - # 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) + def q(h): + return f"{base_list_url}?{urlencode(h)}" if h else None + # Column headers with links (except Total) col_headers = [] - 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}) + for col in pt.columns.tolist(): + if col == "Total": + col_headers.append({"label": col, "href": None}) 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 = [] - 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}) + 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}) + # Cells: combine filters, respecting Total row/col rules cells = [] - 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):,}" + for col in pt.columns.tolist(): + val = int(pt.at[idx, col]) params = {} - if not is_total_row: + if idx != "Total": params["device_type.description"] = idx - if col not in ("Total", *planning_cols): - params[cat_col] = col - href = q(params) if params else None - cells.append({"value": s, "href": href}) - + if col != "Total": + params["condition"] = col + href = q(params) if params else None # None for Total×Total + cells.append({"value": f"{val:,}", "href": href}) table_rows.append({"label": idx, "href": row_href, "cells": cells}) return render_template( @@ -209,6 +94,7 @@ 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/routes/settings.py b/inventory/routes/settings.py index 4885d1b..1128422 100644 --- a/inventory/routes/settings.py +++ b/inventory/routes/settings.py @@ -53,6 +53,7 @@ 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/errors/500.html b/inventory/templates/errors/500.html deleted file mode 100644 index 453586d..0000000 --- a/inventory/templates/errors/500.html +++ /dev/null @@ -1,8 +0,0 @@ -{% 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 deleted file mode 100644 index 500ed49..0000000 --- a/inventory/templates/errors/default.html +++ /dev/null @@ -1,8 +0,0 @@ -{% 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 eaaf426..a6a3d6e 100644 --- a/inventory/templates/index.html +++ b/inventory/templates/index.html @@ -5,44 +5,9 @@

Find out about all of your assets.

-
+

Active Worklogs

{{ logs | safe }}
-
-

Supply Status

-
- - {% 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 %} \ No newline at end of file +{% endblock %} diff --git a/inventory/templates/summary.html b/inventory/templates/summary.html index e7833ab..4a76680 100644 --- a/inventory/templates/summary.html +++ b/inventory/templates/summary.html @@ -1,54 +1,40 @@ {% 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 %} +

Inventory Summary

+
+
Device Type{{ col.label }}{{ col.label }}
+ + + + {% 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 %} + {% 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 %} - - {% endfor %} - -
Device Type{{ col.label }}{{ col.label }}
+ {% if row.href %} + {{ row.label }} + {% else %} + {{ row.label }} + {% endif %} + {{ cell.value }}{{ cell.value }}
- {% if row.href %} - {{ row.label }} - {% else %} - {{ row.label }} - {% endif %} - - {{ cell.value }} - {{ cell.value }}
-
-{% endblock %} \ No newline at end of file + + +
+{% endblock %} diff --git a/inventory/templates/user_inventory.html b/inventory/templates/user_inventory.html deleted file mode 100644 index b2d3c40..0000000 --- a/inventory/templates/user_inventory.html +++ /dev/null @@ -1,33 +0,0 @@ - -{% 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