Compare commits

..

No commits in common. "24e49341e845806e43f9ea650d5fa4a646be6090" and "5234cbdd616b8fb1b0054d3180fac977799b9a4d" have entirely different histories.

33 changed files with 252 additions and 1297 deletions

1
.gitignore vendored
View file

@ -2,7 +2,6 @@
inventory/static/uploads/* inventory/static/uploads/*
!inventory/static/uploads/.gitkeep !inventory/static/uploads/.gitkeep
.venv/ .venv/
.vscode/
.env .env
*.db* *.db*
*.db-journal *.db-journal

View file

@ -60,8 +60,11 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
DELETE /api/<model>/delete?id=123[&hard=1] DELETE /api/<model>/delete?id=123[&hard=1]
""" """
model_name = model.__name__.lower() model_name = model.__name__.lower()
# bikeshed if you want pluralization; this is the least-annoying default
collection = (base_prefix or model_name).lower()
plural = collection if collection.endswith('s') else f"{collection}s"
bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}") bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
@bp.errorhandler(Exception) @bp.errorhandler(Exception)
def _handle_any(e: Exception): def _handle_any(e: Exception):
@ -102,7 +105,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
obj = service.create(payload) obj = service.create(payload)
resp = jsonify(obj.as_dict()) resp = jsonify(obj.as_dict())
resp.status_code = 201 resp.status_code = 201
resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False) resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
return resp return resp
except Exception as e: except Exception as e:
return _json_error(e) return _json_error(e)

View file

@ -415,8 +415,6 @@ class CRUDService(Generic[T]):
opt = opt.load_only(*cols) opt = opt.load_only(*cols)
query = query.options(opt) query = query.options(opt)
# inside CRUDService._apply_firsthop_strategies
# ...
# NEW: if a first-hop to-one relationships target table is present in filter expressions, # NEW: if a first-hop to-one relationships target table is present in filter expressions,
# make sure we actually JOIN it (outer) so filters dont create a cartesian product. # make sure we actually JOIN it (outer) so filters dont create a cartesian product.
if plan.filter_tables: if plan.filter_tables:
@ -424,19 +422,14 @@ class CRUDService(Generic[T]):
for rel in mapper.relationships: for rel in mapper.relationships:
if rel.uselist: if rel.uselist:
continue # only first-hop to-one here continue # only first-hop to-one here
target_cls = rel.mapper.class_ target_tbl = getattr(rel.mapper.class_, "__table__", None)
target_tbl = getattr(target_cls, "__table__", None)
if target_tbl is None: if target_tbl is None:
continue continue
if target_tbl in plan.filter_tables: if target_tbl in plan.filter_tables:
if rel.key in joined_rel_keys: if rel.key in joined_rel_keys:
continue # already joined via join_paths continue # already joined via join_paths
query = query.join(getattr(root_alias, rel.key), isouter=True)
# alias when joining same-entity relationships (User->User supervisor)
ta = aliased(target_cls) if target_cls is self.model else target_cls
query = query.join(getattr(root_alias, rel.key).of_type(ta), isouter=True)
joined_rel_keys.add(rel.key) joined_rel_keys.add(rel.key)
if log.isEnabledFor(logging.DEBUG): if log.isEnabledFor(logging.DEBUG):
info = [] info = []
for base_alias, rel_attr, target_alias in plan.join_paths: for base_alias, rel_attr, target_alias in plan.join_paths:

View file

@ -11,8 +11,6 @@ from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import crudkit
_ALLOWED_ATTRS = { _ALLOWED_ATTRS = {
"class", "placeholder", "autocomplete", "inputmode", "pattern", "class", "placeholder", "autocomplete", "inputmode", "pattern",
"min", "max", "step", "maxlength", "minlength", "min", "max", "step", "maxlength", "minlength",
@ -109,53 +107,6 @@ def register_template_globals(app=None):
app.add_template_global(fn, name) app.add_template_global(fn, name)
installed.add(name) installed.add(name)
def _fields_for_label_params(label_spec, related_model):
"""
Build a 'fields' list suitable for CRUDService.list() so labels render
without triggering lazy loads. Always includes 'id'.
"""
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
fields = set(["id"])
for c in simple_cols:
fields.add(c)
for rel_name, col_name in rel_paths:
if col_name == "__all__":
# just ensure relationship object is present; ask for rel.id
fields.add(f"{rel_name}.id")
else:
fields.add(f"{rel_name}.{col_name}")
return list(fields)
def _fk_options_via_service(related_model, label_spec, *, options_params: dict | None = None):
svc = crudkit.crud.get_service(related_model)
# default to unlimited results for dropdowns
params = {"limit": 0}
if options_params:
params.update(options_params) # caller can override limit if needed
# ensure fields needed to render the label are present (avoid lazy loads)
fields = _fields_for_label_params(label_spec, related_model)
if fields:
existing = params.get("fields")
if isinstance(existing, str):
existing = [s.strip() for s in existing.split(",") if s.strip()]
if isinstance(existing, (list, tuple)):
params["fields"] = list(dict.fromkeys(list(existing) + fields))
else:
params["fields"] = fields
# only set a default sort if caller didnt supply one
if "sort" not in params:
simple_cols, _ = _extract_label_requirements(label_spec, related_model)
params["sort"] = (simple_cols[0] if simple_cols else "id")
rows = svc.list(params)
return [
{"value": str(r.id), "label": _label_from_obj(r, label_spec)}
for r in rows
]
def expand_projection(model_cls, fields): def expand_projection(model_cls, fields):
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {} req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
out = set(fields) out = set(fields)
@ -696,12 +647,10 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
if "label_deps" in spec: if "label_deps" in spec:
field["label_deps"] = spec["label_deps"] field["label_deps"] = spec["label_deps"]
opts_params = spec.get("options_params") or spec.get("options_filter") or spec.get("options_where")
if rel_prop: if rel_prop:
if field["type"] is None: if field["type"] is None:
field["type"] = "select" field["type"] = "select"
if field["type"] == "select" and field.get("options") is None: if field["type"] == "select" and field.get("options") is None and session is not None:
related_model = rel_prop.mapper.class_ related_model = rel_prop.mapper.class_
label_spec = ( label_spec = (
spec.get("label_spec") spec.get("label_spec")
@ -709,11 +658,7 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
or getattr(related_model, "__crud_label__", None) or getattr(related_model, "__crud_label__", None)
or "id" or "id"
) )
field["options"] = _fk_options_via_service( field["options"] = _fk_options(session, related_model, label_spec)
related_model,
label_spec,
options_params=opts_params
)
return field return field
col = mapper.columns.get(name) col = mapper.columns.get(name)

View file

@ -1,4 +1,4 @@
<!-- FIELD: {{ field_name }} ({{ field_type }}) --> {# show label unless hidden/custom #}
{% if field_type != 'hidden' and field_label %} {% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" <label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %} {% if label_attrs %}{% for k,v in label_attrs.items() %}

View file

@ -1,7 +1,5 @@
<!-- FORM: {{ model_name|lower }} -->
<form method="POST" id="{{ model_name|lower }}_form"> <form method="POST" id="{{ model_name|lower }}_form">
{% macro render_row(row) %} {% macro render_row(row) %}
<!-- ROW: {{ row['name'] }} -->
{% if row.fields or row.children or row.legend %} {% if row.fields or row.children or row.legend %}
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %} {% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
<fieldset <fieldset

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)}")
@ -91,7 +69,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
_models.Inventory, _models.Inventory,
_models.RoomFunction, _models.RoomFunction,
_models.Room, _models.Room,
_models.Status,
_models.User, _models.User,
_models.WorkLog, _models.WorkLog,
_models.WorkNote, _models.WorkNote,

View file

@ -12,12 +12,11 @@ from .image import Image
from .inventory import Inventory from .inventory import Inventory
from .room_function import RoomFunction from .room_function import RoomFunction
from .room import Room from .room import Room
from .status import Status
from .user import User from .user import User
from .work_log import WorkLog from .work_log import WorkLog
from .work_note import WorkNote from .work_note import WorkNote
__all__ = [ __all__ = [
"Area", "Brand", "DeviceType", "Image", "Inventory", "Area", "Brand", "DeviceType", "Image", "Inventory",
"RoomFunction", "Room", "Status", "User", "WorkLog", "WorkNote", "RoomFunction", "Room", "User", "WorkLog", "WorkNote",
] ]

View file

@ -1,6 +1,6 @@
from typing import List, Optional from typing import List, Optional
from sqlalchemy import Boolean, Integer, Unicode from sqlalchemy import Boolean, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql from sqlalchemy.sql import expression as sql
@ -10,7 +10,6 @@ class DeviceType(Base, CRUDMixin):
__tablename__ = 'item' __tablename__ = 'item'
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
target: Mapped[int] = mapped_column(Integer, nullable=True, default=0)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type') inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')

View file

@ -17,9 +17,7 @@ class Inventory(Base, CRUDMixin):
name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True) name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True) serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
condition: Mapped[Optional['Status']] = relationship('Status', back_populates='inventory') condition: Mapped[str] = mapped_column(Unicode(255))
condition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('status.id'), nullable=True, index=True)
model: Mapped[Optional[str]] = mapped_column(Unicode(255)) model: Mapped[Optional[str]] = mapped_column(Unicode(255))
notes: Mapped[Optional[str]] = mapped_column(Unicode(255)) notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false()) shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())

View file

@ -1,33 +0,0 @@
import enum
from typing import List
from sqlalchemy import Boolean, Enum as SAEnum, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql
from crudkit.core.base import Base, CRUDMixin
class StatusCategory(str, enum.Enum):
ACTIVE = "Active"
AVAILABLE = "Available"
PENDING = "Pending"
FAULTED = "Faulted"
DECOMMISSIONED = "Decommissioned"
DISPOSED = "Disposed"
ADMINISTRATIVE = "Administrative"
status_type = SAEnum(
StatusCategory,
name="status_category_enum",
validate_strings=True,
)
class Status(Base, CRUDMixin):
__tablename__ = "status"
description: Mapped[str] = mapped_column(Unicode(255), nullable=False, index=True, unique=True)
category: Mapped[StatusCategory] = mapped_column(status_type, nullable=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='condition')

View file

@ -17,8 +17,7 @@ class WorkLog(Base, CRUDMixin):
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs') contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan', updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan', order_by='WorkNote.timestamp.desc()')
order_by="desc(WorkNote.timestamp), desc(WorkNote.id)", lazy="selectin")
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs') work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True) work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True)

View file

@ -9,7 +9,7 @@ from crudkit.core import normalize_payload
bp_entry = Blueprint("entry", __name__) bp_entry = Blueprint("entry", __name__)
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room", "status"] ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
def _fields_for_model(model: str): def _fields_for_model(model: str):
fields: list[str] = [] fields: list[str] = []
@ -33,27 +33,35 @@ def _fields_for_model(model: str):
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}}, "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col form-floating"}, {"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"},
"attrs": {"class": "form-control", "placeholder": "Device Name"}, "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col form-floating"}, {"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"},
"attrs": {"class": "form-control", "placeholder": "Serial Number"}, "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col form-floating"}, {"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"},
"attrs": {"class": "form-control", "placeholder": "Bar Code"}, "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col form-floating"}, {"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}},
{"name": "model", "row": "device", "wrap": {"class": "col form-floating"}, {"name": "model", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control", "placeholder": "Model Number"}, "label": "Model #", "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}},
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col form-floating"}, {"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "ms-2"}}, "attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}},
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col form-floating"}, {"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}, "label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}},
"options_params": {"active__eq": True}}, {"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"},
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col form-floating"}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"},
"label_spec": "{name} - {room_function.description}"}, "label_spec": "{name} - {room_function.description}"},
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"}, {"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"},
"label_attrs": {"class": "ms-2"}, "label_spec": "{description}"}, "type": "select", "options": [
{"label": "Deployed", "value": "Deployed"},
{"label": "Working", "value": "Working"},
{"label": "Unverified", "value": "Unverified"},
{"label": "Partially Inoperable", "value": "Partially Inoperable"},
{"label": "Inoperable", "value": "Inoperable"},
{"label": "Removed", "value": "Removed"},
{"label": "Disposed", "value": "Disposed"},
],
"label_attrs": {"class": "form-label"}, "attrs": {"class": "form-control"}},
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}", {"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"}, "template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
"wrap": {"class": "h-100 w-100"}}, "wrap": {"class": "h-100 w-100"}},
@ -81,44 +89,30 @@ 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",
"inventory.condition.category",
"work_logs.work_item",
"work_logs.start_time",
"work_logs.end_time",
"work_logs.complete",
] ]
fields_spec = [ fields_spec = [
{"name": "label", "row": "label", "label": "", "type": "display", {"name": "label", "row": "label", "label": "", "type": "display",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"}, "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "ms-2"}, {"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}}, "attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "ms-2"}, {"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}}, "attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
{"name": "title", "label": "Title", "label_attrs": {"class": "ms-2"}, {"name": "title", "label": "Title", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "President of the Universe", "class": "form-control"}, "attrs": {"placeholder": "President of the Universe", "class": "form-control"},
"row": "name", "wrap": {"class": "col form-floating"}}, "row": "name", "wrap": {"class": "col-3"}},
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "ms-2 link-label"}, {"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
"label_spec": "{label}", "row": "details", "wrap": {"class": "col form-floating"}, "label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}, "attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
"options_params": {"active__eq": True, "staff__eq": True}}, {"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
{"name": "location", "label": "Room", "label_attrs": {"class": "ms-2"},
"label_spec": "{name} - {room_function.description}", "label_spec": "{name} - {room_function.description}",
"row": "details", "wrap": {"class": "col form-floating"}, "attrs": {"class": "form-control"}}, "row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"}, {"name": "active", "label": "Active", "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": "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", "wrap": {"class": "col"}},
{"name": "work_logs", "label": "Work Logs", "row": "inventory", "type": "template",
"template": "user_worklogs.html", "wrap": {"class": "col"}},
] ]
layout = [ layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
@ -126,7 +120,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, "attrs": {"class": "row"}},
] ]
elif model == "worklog": elif model == "worklog":
@ -141,28 +134,26 @@ 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}",
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}}, "attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "contact", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Contact", {"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"}, "label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}, "link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
"options_params": {"active__eq": True}}, {"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
{"name": "work_item", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Work Item", "label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"}, "link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}},
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}},
"options_params": {"condition__nin": ["Removed", "Disposed"]}},
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps", {"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "Start"}, "wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"},
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps", {"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "End"}, "wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"},
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"}, {"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}}, "attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "ms-2"}, {"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"},
"type": "template", "template": "update_list.html"}, "type": "template", "template": "update_list.html"},
] ]
layout = [ layout = [
@ -182,43 +173,18 @@ def _fields_for_model(model: str):
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"}, {"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col mb-3 form-floating"}}, "label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}},
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"}, {"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}, "label_spec": "{name}"}, "label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"},
{"name": "room_function", "label": "Description", "label_spec": "{description}", {"name": "room_function", "label": "Description", "label_spec": "{description}",
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"}, "row": "details", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
"wrap": {"class": "col form-floating"}}, "wrap": {"class": "col"}},
] ]
layout = [ layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
{"name": "name", "order": 10, "attrs": {"class": "row"}}, {"name": "name", "order": 10, "attrs": {"class": "row"}},
{"name": "details", "order": 20, "attrs": {"class": "row"}}, {"name": "details", "order": 20, "attrs": {"class": "row"}},
] ]
elif model == "status":
fields_spec = [
{"name": "label", "label": "", "type": "display", "attrs": {"class": "display-6 mb-3"},
"row": "label", "wrap": {"class": "col"}, "label_spec": "{description} ({category})"},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "description", "row": "details", "label": "Description", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}},
{"name": "category", "row": "details", "label": "Category", "attrs": {"class": "form-control"},
"type": "select", "wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "options": [
{"label": "Active", "value": "Active"},
{"label": "Available", "value": "Available"},
{"label": "Pending", "value": "Pending"},
{"label": "Faulted", "value": "Faulted"},
{"label": "Decommissioned", "value": "Decommissioned"},
{"label": "Disposed", "value": "Disposed"},
{"label": "Administrative", "value": "Administrative"},
]},
]
layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
{"name": "details", "order": 10, "attrs": {"class": "row"}},
]
return (fields, fields_spec, layout) return (fields, fields_spec, layout)

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

@ -36,7 +36,7 @@ def init_listing_routes(app):
if model.lower() == 'inventory': if model.lower() == 'inventory':
spec = {"fields": [ spec = {"fields": [
"label", "name", "barcode", "serial", "brand.name", "model", "label", "name", "barcode", "serial", "brand.name", "model",
"device_type.description", "condition.description", "owner.label", "location.label", "device_type.description", "condition", "owner.label", "location.label",
]} ]}
columns = [ columns = [
{"field": "label"}, {"field": "label"},
@ -46,7 +46,7 @@ def init_listing_routes(app):
{"field": "brand.name", "label": "Brand"}, {"field": "brand.name", "label": "Brand"},
{"field": "model"}, {"field": "model"},
{"field": "device_type.description", "label": "Device Type"}, {"field": "device_type.description", "label": "Device Type"},
{"field": "condition.description", "label": "Condition"}, {"field": "condition"},
{"field": "owner.label", "label": "Contact", {"field": "owner.label", "label": "Contact",
"link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}}, "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
{"field": "location.label", "label": "Room"}, {"field": "location.label", "label": "Room"},

View file

@ -1,206 +1,89 @@
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
from crudkit.ui.fragments import render_table
import crudkit 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", "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
df["device_type.description"] = (
df.get("device_type.description")
.fillna("(Unspecified)")
.astype(str)
)
# 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)
# 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( pt = df.pivot_table(
index="device_type.description", index="device_type.description",
columns=cat_col, columns="condition",
values="id", values="id",
aggfunc="count", aggfunc="count",
fill_value=0, fill_value=0,
) )
if "target" in needs.columns: # Reorder/exclude like before
needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64") order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
needs = needs.fillna({"target": pd.NA}) exclude = ["Removed", "Disposed"]
pt = pt.merge(needs, left_index=True, right_on="description") 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]
# Make the human label the index so the left-most column renders as names, not integers pt = pt[cols]
if "description" in pt.columns:
pt = pt.set_index("description")
# Keep a handle on the category columns produced by the pivot BEFORE merge # Drop zero-only rows
category_cols = list(df[cat_col].unique()) if cat_col in df.columns else [] pt = pt.loc[(pt != 0).any(axis=1)]
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).
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 = 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,83 +92,4 @@ def init_reports_routes(app):
table_rows=table_rows, table_rows=table_rows,
) )
@bp_reports.get("/problems")
def problems():
inventory_model = crudkit.crud.get_model('inventory')
inventory_svc = crudkit.crud.get_service(inventory_model)
rows = inventory_svc.list({
"limit": 0,
"$or": [
{"owner.active__eq": False},
{"owner_id": None}
],
"fields": [
"owner.label",
"label",
"brand.name",
"model",
"device_type.description",
"location.label",
"condition"
],
})
orphans = render_table(rows, [
{"field": "owner.label", "label": "Owner", "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
{"field": "label", "label": "Device"},
{"field": "brand.name", "label": "Brand"},
{"field": "model"},
{"field": "device_type.description", "label": "Device Type"},
{"field": "location.label", "label": "Location"},
{"field": "condition"},
], opts={"object_class": "inventory"})
rows = inventory_svc.list({
"fields": ["id", "name", "serial", "barcode", "brand.name", "model", "device_type.description", "owner.label", "location.label"],
"limit": 0,
"$or": [
{"name__ne": None},
{"serial__ne": None},
{"barcode__ne": None},
],
})
duplicates = pd.DataFrame([r.as_dict() for r in rows]).set_index("id", drop=True)
subset = ["name", "serial", "barcode"]
mask = (
(duplicates["name"].notna() & duplicates.duplicated("name", keep=False)) |
(duplicates["serial"].notna() & duplicates.duplicated("serial", keep=False)) |
(duplicates["barcode"].notna() & duplicates.duplicated("barcode", keep=False))
)
duplicates = duplicates.loc[mask].sort_values(subset)
# you already have this
cols = [
{"name": "name", "label": "Name"},
{"name": "serial", "label": "Serial #"},
{"name": "barcode", "label": "Bar Code"},
{"name": "brand.name", "label": "Brand"},
{"name": "model", "label": "Model"},
{"name": "device_type.description", "label": "Device Type"},
{"name": "owner.label", "label": "Owner"},
{"name": "location.label", "label": "Location"},
]
col_names = [c["name"] for c in cols if c["name"] in duplicates.columns]
col_labels = [c["label"] for c in cols if c["name"] in duplicates.columns]
out = duplicates[col_names].fillna("")
# Best for Jinja: list of dicts (each row keyed by column name)
duplicates = (
out.reset_index()
.rename(columns={"index": "id"})
.to_dict(orient="records")
)
headers_for_template = ["ID"] + col_labels
return render_template("problems.html", orphans=orphans, duplicates=duplicates, duplicate_columns=headers_for_template)
app.register_blueprint(bp_reports) app.register_blueprint(bp_reports)

View file

@ -32,7 +32,7 @@ def init_search_routes(app):
{"field": "location.label", "label": "Location"}, {"field": "location.label", "label": "Location"},
] ]
inventory_results = inventory_service.list({ inventory_results = inventory_service.list({
'notes|label|model|owner.label__icontains': q, 'notes|label|owner.label__icontains': q,
'fields': [ 'fields': [
"label", "label",
"name", "name",

View file

@ -19,11 +19,9 @@ def init_settings_routes(app):
function_service = crudkit.crud.get_service(function_model) function_service = crudkit.crud.get_service(function_model)
room_model = crudkit.crud.get_model('room') room_model = crudkit.crud.get_model('room')
room_service = crudkit.crud.get_service(room_model) room_service = crudkit.crud.get_service(room_model)
status_model = crudkit.crud.get_model('status')
status_service = crudkit.crud.get_service(status_model)
brands = brand_service.list({"sort": "name", "limit": 0}) brands = brand_service.list({"sort": "name", "limit": 0})
device_types = device_type_service.list({"sort": "description", "limit": 0, "fields": ["description", "target"]}) device_types = device_type_service.list({"sort": "description", "limit": 0})
areas = area_service.list({"sort": "name", "limit": 0}) areas = area_service.list({"sort": "name", "limit": 0})
functions = function_service.list({"sort": "description", "limit": 0}) functions = function_service.list({"sort": "description", "limit": 0})
rooms = room_service.list({ rooms = room_service.list({
@ -44,16 +42,6 @@ def init_settings_routes(app):
], ],
opts={"object_class": 'room'}) opts={"object_class": 'room'})
statuses = status_service.list({ return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms)
"sort": "category",
"limit": 0,
"fields": [
"description",
"category",
],
})
statuses = render_table(statuses, opts={"object_class": 'status'})
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses)
app.register_blueprint(bp_settings) app.register_blueprint(bp_settings)

View file

@ -2,11 +2,3 @@
border-color: rgb(222, 226, 230) !important; border-color: rgb(222, 226, 230) !important;
overflow-y: auto; overflow-y: auto;
} }
.link-label {
pointer-events: none;
}
.link-label a {
pointer-events: auto;
}

View file

@ -2,7 +2,7 @@ const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
DropDown.utilities = { DropDown.utilities = {
filterList(id) { filterList(id) {
value = document.getElementById(`${id}-filter`).value.toLowerCase(); value = document.getElementById(`${id}-filter`).value;
list = document.querySelectorAll(`#${id}-dropdown li`); list = document.querySelectorAll(`#${id}-dropdown li`);
list.forEach(item => { list.forEach(item => {
@ -21,7 +21,7 @@ DropDown.utilities = {
const inp = document.getElementById(id); const inp = document.getElementById(id);
btn.dataset.value = value; btn.dataset.value = value;
btn.value = txt; btn.textContent = txt;
inp.value = value; inp.value = value;
}, },

View file

@ -40,7 +40,6 @@
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a> <a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li> <li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
<li><a class="dropdown-item" href="{{ url_for('reports.problems') }}">Problems</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>

View file

@ -1,4 +1,16 @@
<!-- FIELD: {{ field_name }} ({{ field_type }}) --> <!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
%}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label>
{% endif %}
{% if field_type == 'select' %} {% if field_type == 'select' %}
{# {#
@ -18,41 +30,24 @@
#} #}
{% if options %} {% if options %}
{% if value %} {% if value %}
{% set opts = options or [] %} {% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %}
{% set selected = opts | selectattr('value', 'equalto', value) | list %}
{% if not selected %}
{% set selected = opts | selectattr('value', 'equalto', value|string) | list %}
{% endif %}
{% set sel = selected[0] if selected else none %}
{% if sel %}
{% set sel_label = sel['label'] %}
{% elif value_label %}
{% set sel_label = value_label %}
{% else %} {% else %}
{% set sel_label = "-- Select --" %} {% set sel_label = "-- Select --" %}
{% endif %} {% endif %}
{% else %} <button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
{% set sel_label = "-- Select --" %} id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button>
{% endif %}
<!-- button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button -->
<input type="button" class="form-control btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" value="{{ sel_label }}">
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown"> <div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
<input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0" <input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0"
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')"> id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
{% for opt in options %} {% for opt in options %}
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}" <li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No <button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No selection available --</button>
selection available --</button>
{% endif %} {% endif %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else '' }}"> <input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
{% elif field_type == 'textarea' %} {% elif field_type == 'textarea' %}
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not <textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
@ -91,15 +86,3 @@
{% if help %} {% if help %}
<div class="form-text">{{ help }}</div> <div class="form-text">{{ help }}</div>
{% endif %} {% endif %}
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
%}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label>
{% endif %}

View file

@ -1,4 +1,3 @@
<!-- TABLE {{ kwargs['object_class'] if kwargs else '(NO MODEL ASSOCIATED)' }} -->
<div class="table-responsive" style="max-height: 80vh;"> <div class="table-responsive" style="max-height: 80vh;">
<table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto"> <table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<thead> <thead>

View file

@ -1,101 +1,11 @@
<div class="btn-group"> <div class="btn-group">
<button type="submit" class="btn btn-outline-primary" id="submit">Save</button> <button type="submit" class="btn btn-primary" id="submit">Save</button>
<button type="button" class="btn btn-outline-success" <button type="button" class="btn btn-outline-primary" onclick="location.href='{{ url_for("entry.entry_new", model=(field['attrs']['data-model'])) }}'">New</button>
onclick="location.href='{{ url_for('entry.entry_new', model=field['attrs']['data-model']) }}'"
id="new">New</button>
<button type="button" class="btn btn-outline-danger" id="delete" onclick="deleteEntry()">Delete</button>
</div> </div>
<script> <script>
window.newDrafts = window.newDrafts || []; window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || []; window.deletedIds = window.deletedIds || [];
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
// Build delete URL only if we have an id, or leave it empty string
{% set model = field['attrs']['data-model'] %}
{% set obj_id = field['template_ctx']['values'].get('id') %}
{% set delete_url = obj_id and url_for('crudkit.' ~model ~ '.rest_delete', obj_id = obj_id) %}
const DELETE_URL = {{ (delete_url or '') | tojson }};
// Form metadata
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
if (!hasId) {
const delBtn = document.getElementById('delete');
delBtn.disabled = true;
delBtn.classList.add('disabled');
}
async function deleteEntry() {
const delBtn = document.getElementById('delete');
if (!DELETE_URL) return;
if (!window.confirm('Delete this entry?')) return;
delBtn.disabled = true;
try {
const res = await fetch(DELETE_URL, {
method: 'DELETE',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
});
let data = null;
if (res.status !== 204) {
const text = await res.text();
if (text) {
const ct = res.headers.get('content-type') || '';
data = ct.includes('application/json') ? JSON.parse(text) : { message: text };
}
}
if (!res.ok) {
const msg = (data && (data.detail || data.error || data.message)) ||
`Request failed with ${res.status} ${res.statusText}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
queueToast((data && (data.detail || data.message)) || 'Item deleted.', 'success');
location.assign(LIST_URL);
} catch (err) {
if (err?.name === 'AbortError') {
toastMessage('Network timeout while deleting item.', 'danger');
} else if (err?.status === 409) {
toastMessage(`Delete blocked: ${err.message}`, 'warning');
} else {
toastMessage(`Network error: ${String(err?.message || err)}`, 'danger');
}
} finally {
delBtn.disabled = false;
}
}
{% if field['attrs']['data-model'] == 'worklog' %}
function collectExistingUpdateIds() {
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
function collectEditedUpdates() {
const updates = [];
const deleted = new Set(collectDeletedIds());
for (const id of collectExistingUpdateIds()) {
if (deleted.has(id)) continue;
updates.push({ id, content: getMarkdown(id) });
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
{% endif %}
function formToJson(form) { function formToJson(form) {
const fd = new FormData(form); const fd = new FormData(form);
@ -126,21 +36,45 @@
return out; return out;
} }
// URLs for create/update function collectExistingUpdateIds() {
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
function collectEditedUpdates() {
const updates = [];
const deleted = new Set(collectDeletedIds());
for (const id of collectExistingUpdateIds()) {
if(deleted.has(id)) continue; // skip ones marked for deletion
updates.push({ id, content: getMarkdown(id) });
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
// much simpler, and correct
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
// Never call url_for for update on the "new" page.
// Create URL is fine to build server-side:
const createUrl = {{ url_for('entry.create_entry', model=field['attrs']['data-model']) | tojson }}; const createUrl = {{ url_for('entry.create_entry', model=field['attrs']['data-model']) | tojson }};
// Update URL is assembled on the client to avoid BuildError on "new":
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null; const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
formEl.addEventListener('submit', async e => { formEl.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const submitBtn = document.getElementById('submit');
submitBtn.disabled = true;
const json = formToJson(formEl); const json = formToJson(formEl);
if (model === 'inventory' && typeof getMarkdown === 'function') { if (model === 'inventory' && typeof getMarkdown === 'function') {
const md = getMarkdown(); const md = getMarkdown();
json.notes = (typeof md === 'string') ? md.trim() : ''; json.notes = (typeof md === 'string') ? getMarkdown().trim() : '';
} else if (model === 'worklog') { } else if (model === 'worklog') {
json.updates = collectEditedUpdates(); json.updates = collectEditedUpdates();
json.delete_update_ids = collectDeletedIds(); json.delete_update_ids = collectDeletedIds();
@ -155,34 +89,33 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json), body: JSON.stringify(json),
credentials: 'same-origin'
}); });
const reply = await res.json(); const reply = await res.json();
if (reply.status === 'success') { if (reply.status === 'success') {
if (!hasId && reply.id) {
window.queueToast('Created successfully.', 'success');
window.newDrafts = []; window.newDrafts = [];
window.deletedIds = []; window.deletedIds = [];
if (!hasId && reply.id) { window.location.assign(`/entry/${model}/${reply.id}`);
queueToast('Created successfully.', 'success');
location.assign(`/entry/${model}/${reply.id}`);
return; return;
} } else {
queueToast('Updated successfully.', 'success'); window.queueToast('Updated successfully.', 'success');
if (model === 'worklog') { if (model === 'worklog') {
for (const id of collectDeletedIds()) { for (const id of collectDeletedIds()) {
document.getElementById(`note-${id}`)?.remove(); const li = document.getElementById(`note-${id}`);
if (li) li.remove();
} }
} }
location.replace(location.href); window.newDrafts = [];
} else { window.deletedIds = [];
toastMessage(reply.message || 'Server reported failure.', 'danger'); window.location.replace(window.location.href);
return;
}
} }
} catch (err) { } catch (err) {
toastMessage(`Network error: ${String(err)}`, 'danger'); toastMessage(`Network error: ${String(err)}`, 'danger');
} finally {
submitBtn.disabled = false;
} }
}); });
</script> </script>

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,36 +0,0 @@
{% extends 'base.html' %}
{% block main %}
<h1 class="display-4 mb-3 text-center">Records With Problems</h1>
<div class="container">
<p>Equipment Without Active Owner</p>
{{ orphans | safe }}
<p>Duplicate Inventory Entries</p>
<div class="table-responsive">
<table class="table table-sm table-info table-bordered table-striped table-hover">
<thead>
<tr>
{% for col in duplicate_columns %}
<th>
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for record in duplicates %}
<tr style="cursor: pointer;"
onclick="location.href='{{ url_for('entry.entry', model='inventory', id=record['id']) }}'">
{% for cell in record.values() %}
<td>
{{ cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -5,76 +5,17 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
{% endblock %} {% endblock %}
{% block style %}
.dt-target {
width: 6ch;
-moz-appearance: textfield;
appearance: textfield;
}
/* keep the row highlight, but keep the input looking normal */
.dt-option.selected .dt-target {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
}
/* nuke the blue focus ring/border inside selected rows */
.dt-option .dt-target:focus {
border-color: var(--bs-border-color);
box-shadow: none;
outline: 0;
}
.dt-target::-webkit-outer-spin-button,
.dt-target::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.dt-option {
cursor: default;
}
/* Selection styling for the row */
@supports (background-color: AccentColor) {
.dt-option.selected { background-color: AccentColor; }
.dt-option.selected .dt-label { color: AccentColorText; }
/* Hard force the input to be opaque and not inherit weirdness */
.dt-option .dt-target,
.dt-option .dt-target:focus {
background-color: Field !important; /* system input bg */
color: FieldText !important; /* system input text */
box-shadow: none; /* not the halo issue, but be thorough */
border-color: var(--bs-border-color); /* keep Bootstrap-ish border */
}
}
@supports not (background-color: AccentColor) {
.dt-option.selected { background-color: var(--bs-list-group-active-bg, #0d6efd); }
.dt-option.selected .dt-label { color: var(--bs-list-group-active-color, #fff); }
.dt-option .dt-target,
.dt-option .dt-target:focus {
background-color: var(--bs-body-bg) !important;
color: var(--bs-body-color) !important;
box-shadow: none;
border-color: var(--bs-border-color);
}
}
{% endblock %}
{% block main %} {% block main %}
<form id="settings_form" method="post"> <form id="settings_form" method="post">
<div class="container"> <div class="container">
<ul class="nav nav-pills nav-fill"> <ul class="nav nav-pills nav-fill">
<li class="nav-item"> <li class="nav-item">
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab" <button type="button" class="nav-link active" id="device-tab"
data-bs-target="#device-tab-pane">Devices</button> data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab" <button type="button" class="nav-link" id="location-tab"
data-bs-target="#location-tab-pane">Locations</button> data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
</li> </li>
</ul> </ul>
@ -83,51 +24,12 @@
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0"> <div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="brand" class="form-label">Brands</label> <label for="brand" class="form-label">Brand</label>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
</div> </div>
<div class="col"> <div class="col">
<label for="devicetype" class="form-label">Device Types</label> <label for="devicetype" class="form-label">Device Type</label>
{# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', {{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
'description') } #}
{# Going to specialize the combobox widget here. #}
<div class="combobox">
<div class="d-flex">
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
placeholder="Enter the description of a device type." id="input-devicetype"
oninput="enableDTAddButton()">
<button type="button"
class="btn btn-primary border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 disabled"
id="add-devicetype" onclick="addDTItem()" disabled>Add</button>
<button type="button"
class="btn btn-info border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
id="edit-devicetype" onclick="editDTItem()">Edit</button>
<button type="button" class="btn btn-danger border-bottom-0 rounded-bottom-0 rounded-start-0 disabled"
id="remove-devicetype" onclick="deleteDTItem()" disabled>Remove</button>
</div>
<div class="border h-100 ps-3 pe-0 overflow-auto" id="device-type-list">
{% for t in device_types %}
<div id="devicetype-option-{{ t['id'] }}" data-inv-id="{{ t['id'] }}"
class="d-flex justify-content-between align-items-center user-select-none dt-option">
<span class="align-middle dt-label">{{ t['description'] }}</span>
<input type="number"
class="form-control form-control-sm dt-target"
id="devicetype-target-{{ t['id'] }}" name="devicetype-target-{{ t['id'] }}"
value="{{ t['target'] if t['target'] else 0 }}" min="0" max="999">
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<label for="status" class="form-label">
Conditions
<a href="{{ url_for('entry.entry_new', model='status') }}"
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label>
{{ statuses | safe }}
</div> </div>
</div> </div>
</div> </div>
@ -135,11 +37,11 @@
<div class="tab-pane fade" id="location-tab-pane" tabindex="0"> <div class="tab-pane fade" id="location-tab-pane" tabindex="0">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="area" class="form-label">Areas</label> <label for="area" class="form-label">Area</label>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
</div> </div>
<div class="col"> <div class="col">
<label for="roomfunction" class="form-label">Descriptions</label> <label for="roomfunction" class="form-label">Description</label>
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }} {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div> </div>
</div> </div>
@ -147,8 +49,7 @@
<div class="row mt-3"> <div class="row mt-3">
<label for="rooms" class="form-label"> <label for="rooms" class="form-label">
Rooms Rooms
<a href="{{ url_for('entry.entry_new', model='room') }}" <a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a>
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label> </label>
<div class="col"> <div class="col">
{{ rooms | safe }} {{ rooms | safe }}
@ -164,264 +65,3 @@
{% block scriptincludes %} {% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script> <script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
{% endblock %} {% endblock %}
{% block script %}
const brands = document.getElementById('brand');
const dtlist = document.getElementById('device-type-list');
const dtinput = document.getElementById('input-devicetype');
const height = getComputedStyle(brands).height;
dtlist.style.height = height;
dtlist.style.maxHeight = height;
dtlist.style.minHeight = height;
document.querySelectorAll('.dt-target').forEach((el) => {
el.addEventListener('change', async (ev) => {
const num = ev.target.value;
const id = ev.target.parentElement.dataset.invId;
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({target: num})
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 200) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error setting target number', 'danger');
return;
}
toastMessage('Updated target number.', 'success');
});
});
document.addEventListener('click', (ev) => {
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const deleteButton = document.getElementById('remove-devicetype');
if (!ev.target.closest('#device-type-list')) return;
// Do not toggle selection when interacting with the input itself
if (ev.target.closest('.dt-target')) return;
const node = ev.target.closest('.dt-option');
if (!node) return;
// clear others
document.querySelectorAll('.dt-option')
.forEach(n => { n.classList.remove('selected', 'active'); n.removeAttribute('aria-selected'); });
// select this one
node.classList.add('selected', 'active');
node.setAttribute('aria-selected', 'true');
// set the visible input to the label, not the whole row
const label = node.querySelector('.dt-label');
dtinput.value = (label ? label.textContent : node.textContent).replace(/\s+/g, ' ').trim();
addButton.classList.add('d-none');
editButton.classList.remove('d-none');
deleteButton.classList.remove('disabled');
deleteButton.disabled = false;
});
window.enableDTAddButton = function enableDTAddButton() {
const addButton = document.getElementById('add-devicetype');
if (addButton.classList.contains('d-none')) return;
addButton.disabled = dtinput.value === '';
if (addButton.disabled) {
addButton.classList.add('disabled');
} else {
addButton.classList.remove('disabled');
}
};
window.addDTItem = async function addDTItem() {
const input = document.getElementById('input-devicetype');
const list = document.getElementById('device-type-list');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const value = (input.value || '').trim();
if (!value) {
toastMessage('Type a device type first.', 'warning');
return;
}
addButton.disabled = true;
addButton.classList.add('disabled');
let res, data;
try {
const res = await fetch('{{ url_for("crudkit.devicetype.rest_create") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ description: value, target: 0 })
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 201) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error creating device type.', 'danger');
return;
} finally {
addButton.disabled = false;
addButton.classList.remove('disabled');
}
const id = data?.id ?? data?.obj?.id;
const description = String(data?.description ?? value);
const row = document.createElement('div');
row.id = `devicetype-option-${id}`;
row.dataset.invId = id;
row.className = 'd-flex justify-content-between align-items-center user-select-none dt-option';
const label = document.createElement('span');
label.className = 'align-middle dt-label';
label.textContent = description;
const qty = document.createElement('input');
qty.type = 'number';
qty.min = '0';
qty.max = '999';
qty.value = '0';
qty.id = `devicetype-target-${id}`;
qty.name = `devicetype-target-${id}`;
qty.className = 'form-control form-control-sm dt-target';
row.append(label, qty);
list.appendChild(row);
list.querySelectorAll('.dt-option').forEach(n => {
n.classList.remove('selected', 'active');
n.removeAttribute('aria-selected');
});
input.value = '';
row.scrollIntoView({ block: 'nearest' });
toastMessage(`Created new device type: ${description}`, 'success');
};
window.editDTItem = async function editDTItem() {
const input = document.getElementById('input-devicetype');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const option = document.querySelector('.dt-option.selected');
const value = (input.value || option.dataset.invId).trim();
if (!value) {
toastMessage('Type a device type first.', 'warning');
return;
}
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ description: value })
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 200) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error creating device type.', 'danger');
return;
} finally {
editButton.disabled = true;
editButton.classList.add('disabled', 'd-none');
addButton.classList.remove('d-none');
input.value = '';
}
option.querySelector('.dt-label').textContent = value;
toastMessage(`Updated device type: ${value}`, 'success');
}
window.deleteDTItem = async function deleteDTItem() {
const input = document.getElementById('input-devicetype');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const option = document.querySelector('.dt-option.selected');
const deleteButton = document.getElementById('remove-devicetype');
const value = (input.value || '').trim();
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
method: 'DELETE',
headers: { 'Accept': 'application/json' }
});
if (res.ok) {
option.remove();
toastMessage(`Deleted ${value} successfully.`, 'success');
editButton.disabled = true;
editButton.classList.add('disabled', 'd-none');
deleteButton.disabled = true;
deleteButton.classList.add('disabled');
addButton.classList.remove('d-none');
input.value = '';
return;
}
let msg = 'Delete failed.';
try {
const err = await res.json();
msg = err?.error || msg;
} catch {
const txt = await res.text();
if (txt) msg = txt;
}
toastMessage(msg, 'danger');
} catch (e) {
toastMessage(`Delete failed: ${e?.message || e}`, 'danger');
}
}
{% endblock %}

View file

@ -1,28 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% 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> <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 position-sticky top-0 bg-body border">{{ col.label }}</th> <th class="text-end">{{ col.label }}</th>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in table_rows %} {% for row in table_rows %}
{% set need_more = (row['cells'][-2]['value'] | int > 0) %} <tr>
<tr <th class="text-nowrap">
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 %}
@ -31,13 +27,9 @@
</th> </th>
{% for cell in row.cells %} {% for cell in row.cells %}
{% if cell.href %} {% if cell.href %}
<td <td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></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 <td class="text-end">{{ cell.value }}</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>

View file

@ -1,8 +1,8 @@
{% set items = (field.template_ctx.instance.updates or []) %} {% set items = (field.template_ctx.instance.updates or []) %}
<div class="mt-3 form-floating"> <div class="mt-3">
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
<label class="form-label">Add update</label> <label class="form-label">Add update</label>
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
<div class="mt-2 d-flex gap-2"> <div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button> <button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>

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 border overflow-y-auto" style="max-height: 45vh;">
<table class="table table-sm table-bordered table-striped table-hover mb-0">
{% if inv %}
<thead>
<tr>
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
<th class="position-sticky top-0 bg-body z-1 border">Brand</th>
<th class="position-sticky top-0 bg-body z-1 border">Model</th>
<th class="position-sticky top-0 bg-body z-1 border">Type</th>
</tr>
</thead>
<tbody>
{% for i in inv if i['condition.category'] not in ['Disposed', 'Administrative'] %}
<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>

View file

@ -1,37 +0,0 @@
<!-- WORK LOGS -->
<label class="form-label mt-2">Work Logs</label>
{% set wl = field['template_ctx']['values']['work_logs'] %}
{% set check %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg text-success" viewBox="0 0 16 16">
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/>
</svg>
{% endset %}
{% set x %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg text-danger" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
{% endset %}
<div class="table-responsive border overflow-y-auto" style="max-height: 45vh;">
<table class="table table-sm table-striped table-bordered table-hover mb-0">
<thead>
<tr>
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
<th class="position-sticky top-0 bg-body z-1 border">Start</th>
<th class="position-sticky top-0 bg-body z-1 border">End</th>
<th class="position-sticky top-0 bg-body z-1 border"></th>
</tr>
</thead>
<tbody>
{% for l in wl %}
<tr onclick="location.href='{{ url_for('entry.entry', model='worklog', id=l.id) }}'" style="cursor: pointer;">
<td>{{ l['work_item']['label'] if l['work_item'] else '-' }}</td>
<td>{{ l.start_time if l.start_time else '-' }}</td>
<td>{{ l.end_time if l.end_time else '-' }}</td>
<td class="text-center align-items-center {{ 'bg-success-subtle' if l.complete else 'bg-danger-subtle' }}">{{ check if l.complete else x }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>