Add inventory management templates and utility functions

- Created HTML templates for inventory index, layout, search, settings, table, user, and worklog.
- Implemented utility functions for eager loading relationships in SQLAlchemy.
- Added validation mixin for form submissions.
- Updated project configuration files (pyproject.toml and setup.cfg) for package management.
This commit is contained in:
Yaro Kasear 2025-07-08 11:47:22 -05:00
parent 602bb77e22
commit 9803db17ab
51 changed files with 76 additions and 16 deletions

32
inventory/__init__.py Normal file
View file

@ -0,0 +1,32 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import logging
import os
db = SQLAlchemy()
logger = logging.getLogger('sqlalchemy.engine')
logger.setLevel(logging.INFO)
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
logger.addHandler(handler)
def create_app():
from config import Config
app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-unsafe') # You know what to do for prod
app.config.from_object(Config)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
db.init_app(app)
with app.app_context():
from . import models
# db.create_all()
from .routes import main
app.register_blueprint(main)
return app

6
inventory/app.py Normal file
View file

@ -0,0 +1,6 @@
from . import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

66
inventory/config.py Normal file
View file

@ -0,0 +1,66 @@
import os
import urllib.parse
from dotenv import load_dotenv
load_dotenv()
def quote(value: str) -> str:
return urllib.parse.quote_plus(value or '')
class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False
DEBUG = False
TESTING = False
DB_BACKEND = os.getenv('DB_BACKEND', 'sqlite').lower()
DB_WINDOWS_AUTH = os.getenv('DB_WINDOWS_AUTH', 'false').strip().lower() in ['true', '1', 'yes']
DB_USER = os.getenv('DB_USER', '')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = os.getenv('DB_PORT', '')
DB_NAME = os.getenv('DB_NAME', 'app.db') # default SQLite filename
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SQLALCHEMY_DATABASE_URI = None # This will definitely be set below
if DB_BACKEND == 'mssql':
driver = os.getenv('DB_DRIVER', 'ODBC Driver 17 for SQL Server')
quoted_driver = quote(driver)
if DB_WINDOWS_AUTH:
SQLALCHEMY_DATABASE_URI = (
f"mssql+pyodbc://@{DB_HOST}/{DB_NAME}?driver={quoted_driver}&Trusted_Connection=yes"
)
else:
SQLALCHEMY_DATABASE_URI = (
f"mssql+pyodbc://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '1433'}/{DB_NAME}"
f"?driver={quoted_driver}"
)
elif DB_BACKEND == 'postgres':
SQLALCHEMY_DATABASE_URI = (
f"postgresql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '5432'}/{DB_NAME}"
)
elif DB_BACKEND in ['mariadb', 'mysql']:
SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{quote(DB_USER)}:{quote(DB_PASSWORD)}@{DB_HOST}:{DB_PORT or '3306'}/{DB_NAME}"
)
elif DB_BACKEND == 'sqlite':
if DB_NAME == ':memory:':
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
else:
full_path = os.path.join(BASE_DIR, DB_NAME)
SQLALCHEMY_DATABASE_URI = f"sqlite:///{full_path}"
else:
raise ValueError(
f"Unsupported DB_BACKEND: {DB_BACKEND}. "
"Supported backends: mssql, postgres, mariadb, mysql, sqlite."
)
# Optional: confirm config during development
print(f"Using database URI: {SQLALCHEMY_DATABASE_URI}")

View file

@ -0,0 +1,24 @@
from typing import TYPE_CHECKING
from inventory import db # Yes, this works when run from project root with Alembic
from .areas import Area
from .brands import Brand
from .items import Item
from .inventory import Inventory
from .room_functions import RoomFunction
from .users import User
from .work_log import WorkLog
from .rooms import Room
__all__ = [
"db",
"Area",
"Brand",
"Item",
"Inventory",
"RoomFunction",
"User",
"WorkLog",
"Room",
]

123
inventory/models/areas.py Normal file
View file

@ -0,0 +1,123 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from ..temp import is_temp_id
from ..utils.validation import ValidatableMixin
class Area(ValidatableMixin, db.Model):
__tablename__ = 'Areas'
VALIDATION_LABEL = "Area"
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column("Area", Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='area')
def __init__(self, name: Optional[str] = None):
self.name = name
def __repr__(self):
return f"<Area(id={self.id}, name={repr(self.name)})>"
def serialize(self):
return {
'id': self.id,
'name': self.name
}
@classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
"""
Syncs the Area table (aka 'sections') with the submitted list.
Supports add, update, and delete.
Also returns a mapping of temp IDs to real IDs for resolution.
"""
submitted_clean = []
seen_ids = set()
temp_id_map = {}
for item in submitted_items:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
raw_id = item.get("id")
if not name:
continue
submitted_clean.append({"id": raw_id, "name": name})
# Record real (non-temp) IDs
try:
if raw_id is not None:
parsed_id = int(raw_id)
if parsed_id >= 0:
seen_ids.add(parsed_id)
except (ValueError, TypeError):
pass
existing_query = db.session.query(cls)
existing_by_id = {area.id: area for area in existing_query.all()}
existing_ids = set(existing_by_id.keys())
print(f"Existing area IDs: {existing_ids}")
print(f"Submitted area IDs: {seen_ids}")
for entry in submitted_clean:
submitted_id_raw = entry.get("id")
submitted_name = entry["name"]
if is_temp_id(submitted_id_raw):
new_area = cls(name=submitted_name)
db.session.add(new_area)
db.session.flush() # Get the real ID
temp_id_map[submitted_id_raw] = new_area.id
print(f" Adding area: {submitted_name}")
else:
try:
submitted_id = int(submitted_id_raw)
except (ValueError, TypeError):
continue # Skip malformed ID
if submitted_id in existing_by_id:
area = existing_by_id[submitted_id]
if area.name != submitted_name:
print(f"✏️ Updating area {area.id}: '{area.name}''{submitted_name}'")
area.name = submitted_name
for existing_id in existing_ids - seen_ids:
area = existing_by_id[existing_id]
db.session.delete(area)
print(f"🗑️ Removing area: {area.name}")
id_map = {
**{str(i): i for i in seen_ids}, # "1" → 1
**{str(temp): real for temp, real in temp_id_map.items()} # "temp-1" → 5
}
return id_map
@classmethod
def validate_state(cls, submitted_items: list[dict]) -> list[str]:
errors = []
for index, item in enumerate(submitted_items):
if not isinstance(item, dict):
errors.append(f"Area entry #{index + 1} is not a valid object.")
continue
name = item.get('name')
if not name or not str(name).strip():
errors.append(f"Area entry #{index + 1} is missing a name.")
raw_id = item.get('id')
if raw_id is not None:
try:
_ = int(raw_id)
except (ValueError, TypeError):
if not is_temp_id(raw_id):
errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}")
return errors

115
inventory/models/brands.py Normal file
View file

@ -0,0 +1,115 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from ..temp import is_temp_id
from ..utils.validation import ValidatableMixin
class Brand(ValidatableMixin, db.Model):
__tablename__ = 'Brands'
VALIDATION_LABEL = 'Brand'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[str] = mapped_column("Brand", Unicode(255), nullable=False)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='brand')
def __init__(self, name: str):
self.name = name
def __repr__(self):
return f"<Brand(id={self.id}, name={repr(self.name)})>"
def serialize(self):
return {
'id': self.id,
'name': self.name
}
@classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
submitted_clean = []
seen_ids = set()
temp_id_map = {}
for item in submitted_items:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
raw_id = item.get("id")
if not name:
continue
submitted_clean.append({"id": raw_id, "name": name})
try:
if raw_id:
parsed_id = int(raw_id)
if parsed_id >= 0:
seen_ids.add(parsed_id)
except (ValueError, TypeError):
pass
existing_by_id = {b.id: b for b in db.session.query(cls).all()}
existing_ids = set(existing_by_id.keys())
print(f"Existing brand IDs: {existing_ids}")
print(f"Submitted brand IDs: {seen_ids}")
for entry in submitted_clean:
submitted_id = entry.get("id")
name = entry["name"]
if is_temp_id(submitted_id):
obj = cls(name=name)
db.session.add(obj)
db.session.flush()
temp_id_map[submitted_id] = obj.id
print(f" Adding brand: {name}")
else:
try:
parsed_id = int(submitted_id)
except (ValueError, TypeError):
continue
if parsed_id in existing_by_id:
obj = existing_by_id[parsed_id]
if obj.name != name:
print(f"✏️ Updating brand {obj.id}: '{obj.name}''{name}'")
obj.name = name
for id_to_remove in existing_ids - seen_ids:
db.session.delete(existing_by_id[id_to_remove])
print(f"🗑️ Removing brand ID {id_to_remove}")
id_map = {
**{str(i): i for i in seen_ids}, # "1" → 1
**{str(temp): real for temp, real in temp_id_map.items()} # "temp-1" → 5
}
return id_map
@classmethod
def validate_state(cls, submitted_items: list[dict]) -> list[str]:
errors = []
for index, item in enumerate(submitted_items):
if not isinstance(item, dict):
errors.append(f"Area entry #{index + 1} is not a valid object.")
continue
name = item.get('name')
if not name or not str(name).strip():
errors.append(f"Area entry #{index + 1} is missing a name.")
raw_id = item.get('id')
if raw_id is not None:
try:
_ = int(raw_id)
except (ValueError, TypeError):
if not is_temp_id(raw_id):
errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}")
return errors

View file

@ -0,0 +1,128 @@
from typing import Any, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .brands import Brand
from .items import Item
from .work_log import WorkLog
from .rooms import Room
from sqlalchemy import Boolean, ForeignKey, Identity, Index, Integer, Unicode, DateTime, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
class Inventory(db.Model):
__tablename__ = 'Inventory'
__table_args__ = (
Index('Inventory$Bar Code', 'Bar Code'),
)
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
timestamp: Mapped[datetime.datetime] = mapped_column('Date Entered', DateTime)
condition: Mapped[str] = mapped_column('Working Condition', Unicode(255))
needed: Mapped[str] = mapped_column("Needed", Unicode(255))
type_id: Mapped[Optional[int]] = mapped_column('Item Type', Integer, ForeignKey("Items.ID"), nullable=True)
inventory_name: Mapped[Optional[str]] = mapped_column('Inventory #', Unicode(255))
serial: Mapped[Optional[str]] = mapped_column('Serial #', Unicode(255))
model: Mapped[Optional[str]] = mapped_column('Model #', Unicode(255))
notes: Mapped[Optional[str]] = mapped_column('Notes', Unicode(255))
owner_id = mapped_column('Owner', Integer, ForeignKey('Users.ID'))
brand_id: Mapped[Optional[int]] = mapped_column("Brand", Integer, ForeignKey("Brands.ID"))
# Photo: Mapped[Optional[str]] = mapped_column(String(8000)) Will be replacing with something that actually works.
location_id: Mapped[Optional[str]] = mapped_column(ForeignKey("Rooms.ID"))
barcode: Mapped[Optional[str]] = mapped_column('Bar Code', Unicode(255))
shared: Mapped[Optional[bool]] = mapped_column(Boolean, server_default=text('((0))'))
location: Mapped[Optional['Room']] = relationship('Room', back_populates='inventory')
owner = relationship('User', back_populates='inventory')
brand: Mapped[Optional['Brand']] = relationship('Brand', back_populates='inventory')
item: Mapped['Item'] = relationship('Item', back_populates='inventory')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='work_item')
def __init__(self, timestamp: datetime.datetime, condition: str, needed: str, type_id: Optional[int] = None,
inventory_name: Optional[str] = None, serial: Optional[str] = None,
model: Optional[str] = None, notes: Optional[str] = None, owner_id: Optional[int] = None,
brand_id: Optional[int] = None, location_id: Optional[str] = None, barcode: Optional[str] = None,
shared: bool = False):
self.timestamp = timestamp
self.condition = condition
self.needed = needed
self.type_id = type_id
self.inventory_name = inventory_name
self.serial = serial
self.model = model
self.notes = notes
self.owner_id = owner_id
self.brand_id = brand_id
self.location_id = location_id
self.barcode = barcode
self.shared = shared
def __repr__(self):
parts = [f"id={self.id}"]
if self.inventory_name:
parts.append(f"name={repr(self.inventory_name)}")
if self.item:
parts.append(f"item={repr(self.item.description)}")
if self.notes:
parts.append(f"notes={repr(self.notes)}")
if self.owner:
parts.append(f"owner={repr(self.owner.full_name)}")
if self.location:
parts.append(f"location={repr(self.location.full_name)}")
return f"<Inventory({', '.join(parts)})>"
@property
def identifier(self) -> str:
if self.inventory_name:
return f"Name: {self.inventory_name}"
elif self.barcode:
return f"Bar: {self.barcode}"
elif self.serial:
return f"Serial: {self.serial}"
else:
return f"ID: {self.id}"
def serialize(self) -> dict[str, Any]:
return {
'id': self.id,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'condition': self.condition,
'needed': self.needed,
'type_id': self.type_id,
'inventory_name': self.inventory_name,
'serial': self.serial,
'model': self.model,
'notes': self.notes,
'owner_id': self.owner_id,
'brand_id': self.brand_id,
'location_id': self.location_id,
'barcode': self.barcode,
'shared': self.shared
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Inventory":
timestamp_str = data.get("timestamp")
return cls(
timestamp = datetime.datetime.fromisoformat(str(timestamp_str)) if timestamp_str else datetime.datetime.now(),
condition=data.get("condition", "Unverified"),
needed=data.get("needed", ""),
type_id=data["type_id"],
inventory_name=data.get("inventory_name"),
serial=data.get("serial"),
model=data.get("model"),
notes=data.get("notes"),
owner_id=data.get("owner_id"),
brand_id=data.get("brand_id"),
location_id=data.get("location_id"),
barcode=data.get("barcode"),
shared=bool(data.get("shared", False))
)

116
inventory/models/items.py Normal file
View file

@ -0,0 +1,116 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from ..temp import is_temp_id
from ..utils.validation import ValidatableMixin
class Item(ValidatableMixin, db.Model):
__tablename__ = 'Items'
VALIDATION_LABEL = 'Item'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column("Description", Unicode(255), nullable=True)
category: Mapped[Optional[str]] = mapped_column("Category", Unicode(255), nullable=True)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='item')
def __init__(self, description: Optional[str] = None, category: Optional[str] = None):
self.description = description
self.category = category
def __repr__(self):
return f"<Item(id={self.id}, description={repr(self.description)}, category={repr(self.category)})>"
def serialize(self):
return {
'id': self.id,
'name': self.description,
'category': self.category
}
@classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
submitted_clean = []
seen_ids = set()
temp_id_map = {}
for item in submitted_items:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
raw_id = item.get("id")
if not name:
continue
try:
if raw_id:
parsed_id = int(raw_id)
if parsed_id >= 0:
seen_ids.add(parsed_id)
except (ValueError, TypeError):
pass
submitted_clean.append({"id": raw_id, "description": name})
existing_by_id = {t.id: t for t in db.session.query(cls).all()}
existing_ids = set(existing_by_id.keys())
print(f"Existing item IDs: {existing_ids}")
print(f"Submitted item IDs: {seen_ids}")
for entry in submitted_clean:
submitted_id = entry["id"]
description = entry["description"]
if is_temp_id(submitted_id):
obj = cls(description=description)
db.session.add(obj)
db.session.flush()
temp_id_map[submitted_id] = obj.id
print(f" Adding type: {description}")
elif isinstance(submitted_id, int) or submitted_id.isdigit():
submitted_id_int = int(submitted_id)
obj = existing_by_id.get(submitted_id_int)
if obj and obj.description != description:
print(f"✏️ Updating type {obj.id}: '{obj.description}''{description}'")
obj.description = description
for id_to_remove in existing_ids - seen_ids:
obj = existing_by_id[id_to_remove]
db.session.delete(obj)
print(f"🗑️ Removing type ID {id_to_remove}")
id_map = {
**{str(i): i for i in seen_ids},
**{str(temp): real for temp, real in temp_id_map.items()}
}
return id_map
@classmethod
def validate_state(cls, submitted_items: list[dict]) -> list[str]:
errors = []
for index, item in enumerate(submitted_items):
if not isinstance(item, dict):
errors.append(f"Area entry #{index + 1} is not a valid object.")
continue
name = item.get('name')
if not name or not str(name).strip():
errors.append(f"Area entry #{index + 1} is missing a name.")
raw_id = item.get('id')
if raw_id is not None:
try:
_ = int(raw_id)
except (ValueError, TypeError):
if not is_temp_id(raw_id):
errors.append(f"Area entry #{index + 1} has invalid ID: {raw_id}")
return errors

View file

@ -0,0 +1,90 @@
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .rooms import Room
from sqlalchemy import Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from ..temp import is_temp_id
from ..utils.validation import ValidatableMixin
class RoomFunction(ValidatableMixin, db.Model):
__tablename__ = 'Room Functions'
VALIDATION_LABEL = "Function"
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
description: Mapped[Optional[str]] = mapped_column("Function", Unicode(255), nullable=True)
rooms: Mapped[List['Room']] = relationship('Room', back_populates='room_function')
def __init__(self, description: Optional[str] = None):
self.description = description
def __repr__(self):
return f"<RoomFunction(id={self.id}, description={repr(self.description)})>"
def serialize(self):
return {
'id': self.id,
'name': self.description
}
@classmethod
def sync_from_state(cls, submitted_items: list[dict]) -> dict[str, int]:
submitted_clean = []
seen_ids = set()
temp_id_map = {}
for item in submitted_items:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
raw_id = item.get("id")
if not name:
continue
try:
if raw_id:
parsed_id = int(raw_id)
if parsed_id >= 0:
seen_ids.add(parsed_id)
except (ValueError, TypeError):
pass
submitted_clean.append({"id": raw_id, "description": name})
existing_by_id = {f.id: f for f in db.session.query(cls).all()}
existing_ids = set(existing_by_id.keys())
print(f"Existing function IDs: {existing_ids}")
print(f"Submitted function IDs: {seen_ids}")
for entry in submitted_clean:
submitted_id = entry.get("id")
description = entry["description"]
if is_temp_id(submitted_id):
obj = cls(description=description)
db.session.add(obj)
db.session.flush()
temp_id_map[submitted_id] = obj.id
print(f" Adding function: {description}")
elif isinstance(submitted_id, int) or submitted_id.isdigit():
submitted_id_int = int(submitted_id)
obj = existing_by_id.get(submitted_id_int)
if obj and obj.description != description:
print(f"✏️ Updating function {obj.id}: '{obj.description}''{description}'")
obj.description = description
for id_to_remove in existing_ids - seen_ids:
obj = existing_by_id[id_to_remove]
db.session.delete(obj)
print(f"🗑️ Removing function ID {id_to_remove}")
id_map = {
**{str(i): i for i in seen_ids},
**{str(temp): real for temp, real in temp_id_map.items()}
}
return id_map

199
inventory/models/rooms.py Normal file
View file

@ -0,0 +1,199 @@
from typing import Optional, TYPE_CHECKING, List
if TYPE_CHECKING:
from .areas import Area
from .room_functions import RoomFunction
from .inventory import Inventory
from .users import User
from sqlalchemy import ForeignKey, Identity, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
from ..utils.validation import ValidatableMixin
class Room(ValidatableMixin, db.Model):
__tablename__ = 'Rooms'
VALIDATION_LABEL = "Room"
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
name: Mapped[Optional[str]] = mapped_column("Room", Unicode(255), nullable=True)
area_id: Mapped[Optional[int]] = mapped_column("Area", Integer, ForeignKey("Areas.ID"))
function_id: Mapped[Optional[int]] = mapped_column("Function", Integer, ForeignKey("Room Functions.ID"))
area: Mapped[Optional['Area']] = relationship('Area', back_populates='rooms')
room_function: Mapped[Optional['RoomFunction']] = relationship('RoomFunction', back_populates='rooms')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='location')
users: Mapped[List['User']] = relationship('User', back_populates='location')
def __init__(self, name: Optional[str] = None, area_id: Optional[int] = None, function_id: Optional[int] = None):
self.name = name
self.area_id = area_id
self.function_id = function_id
def __repr__(self):
return f"<Room(id={self.id}, room={repr(self.name)}, area_id={self.area_id}, function_id={self.function_id})>"
@property
def full_name(self):
name = self.name or ""
func = self.room_function.description if self.room_function else ""
return f"{name} - {func}".strip(" -")
def serialize(self):
return {
'id': self.id,
'name': self.name,
'area_id': self.area_id,
'function_id': self.function_id
}
@classmethod
def sync_from_state(cls, submitted_rooms: list[dict], section_map: dict[str, int], function_map: dict[str, int]) -> None:
"""
Syncs the Rooms table with the submitted room list.
Resolves foreign keys using section_map and function_map.
Supports add, update, and delete.
"""
def resolve_fk(key, fk_map, label):
print(f"Resolving {label} ID: {key} using map: {fk_map}")
if key is None:
return None
key = str(key)
if key.startswith("temp") or not key.isdigit():
if key in fk_map:
return fk_map[key]
raise ValueError(f"Unable to resolve {label} ID: {key}")
return int(key) # It's already a real ID
submitted_clean = []
seen_ids = set()
for room in submitted_rooms:
if not isinstance(room, dict):
continue
name = str(room.get("name", "")).strip()
if not name:
continue
rid = room.get("id")
section_id = room.get("section_id")
function_id = room.get("function_id")
submitted_clean.append({
"id": rid,
"name": name,
"section_id": section_id,
"function_id": function_id
})
if rid and not str(rid).startswith("room-"):
try:
seen_ids.add(int(rid))
except ValueError:
pass # Not valid? Not seen.
existing_query = db.session.query(cls)
existing_by_id = {room.id: room for room in existing_query.all()}
existing_ids = set(existing_by_id.keys())
print(f"Existing room IDs: {existing_ids}")
print(f"Submitted room IDs: {seen_ids}")
for entry in submitted_clean:
rid = entry.get("id")
name = entry["name"]
resolved_section_id = resolve_fk(entry.get("section_id"), section_map, "section")
resolved_function_id = resolve_fk(entry.get("function_id"), function_map, "function")
if not rid or str(rid).startswith("room-"):
new_room = cls(name=name, area_id=resolved_section_id, function_id=resolved_function_id)
db.session.add(new_room)
print(f" Adding room: {new_room}")
else:
try:
rid_int = int(rid)
except ValueError:
print(f"⚠️ Invalid room ID format: {rid}")
continue
room = existing_by_id.get(rid_int)
if not room:
print(f"⚠️ No matching room in DB for ID: {rid_int}")
continue
updated = False
if room.name != name:
print(f"✏️ Updating room name {room.id}: '{room.name}''{name}'")
room.name = name
updated = True
if room.area_id != resolved_section_id:
print(f"✏️ Updating room area {room.id}: {room.area_id}{resolved_section_id}")
room.area_id = resolved_section_id
updated = True
if room.function_id != resolved_function_id:
print(f"✏️ Updating room function {room.id}: {room.function_id}{resolved_function_id}")
room.function_id = resolved_function_id
updated = True
if not updated:
print(f"✅ No changes to room {room.id}")
for existing_id in existing_ids - seen_ids:
room = existing_by_id.get(existing_id)
if not room:
continue
# Skip if a newly added room matches this one — likely duplicate
if any(
r["name"] == room.name and
resolve_fk(r["section_id"], section_map, "section") == room.area_id and
resolve_fk(r["function_id"], function_map, "function") == room.function_id
for r in submitted_clean
if r.get("id") is None or str(r.get("id")).startswith("room-")
):
print(f"⚠️ Skipping deletion of likely duplicate: {room}")
continue
db.session.delete(room)
print(f"🗑️ Removing room: {room}")
@classmethod
def validate_state(cls, submitted_items: list[dict]) -> list[str]:
print("VALIDATING")
errors = []
for index, item in enumerate(submitted_items):
label = f"Room #{index + 1}"
if not isinstance(item, dict):
errors.append(f"{label} is not a valid object.")
continue
name = item.get("name")
if not name or not str(name).strip():
errors.append(f"{label} is missing a name.")
raw_id = item.get("id")
if raw_id is not None:
try:
_ = int(raw_id)
except (ValueError, TypeError):
if not str(raw_id).startswith("room-"):
errors.append(f"{label} has an invalid ID: {raw_id}")
# These fields are FK IDs, so we're just checking for valid formats here.
for fk_field, fk_label in [("section_id", "Section"), ("function_id", "Function")]:
fk_val = item.get(fk_field)
if fk_val is None:
continue # Let the DB enforce nullability
try:
_ = int(fk_val)
except (ValueError, TypeError):
fk_val_str = str(fk_val)
if not fk_val_str.startswith("temp-"):
errors.append(f"{label} has invalid {fk_label} ID: {fk_val}")
return errors

68
inventory/models/users.py Normal file
View file

@ -0,0 +1,68 @@
from typing import Any, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .rooms import Room
from .work_log import WorkLog
from sqlalchemy import Boolean, ForeignKey, Identity, Integer, Unicode, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from . import db
class User(db.Model):
__tablename__ = 'Users'
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
staff: Mapped[Optional[bool]] = mapped_column("Staff", Boolean, server_default=text('((0))'))
active: Mapped[Optional[bool]] = mapped_column("Active", Boolean, server_default=text('((0))'))
last_name: Mapped[Optional[str]] = mapped_column("Last", Unicode(255), nullable=True)
first_name: Mapped[Optional[str]] = mapped_column("First", Unicode(255), nullable=True)
location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Rooms.ID"), nullable=True)
supervisor_id: Mapped[Optional[int]] = mapped_column("Supervisor", Integer, ForeignKey("Users.ID"))
supervisor: Mapped[Optional['User']] = relationship('User', remote_side='User.id', back_populates='subordinates')
subordinates: Mapped[List['User']] = relationship('User', back_populates='supervisor')
work_logs: Mapped[List['WorkLog']] = relationship('WorkLog', back_populates='contact')
location: Mapped[Optional['Room']] = relationship('Room', back_populates='users')
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='owner')
@property
def full_name(self) -> str:
return f"{self.first_name or ''} {self.last_name or ''}".strip()
def __init__(self, first_name: Optional[str] = None, last_name: Optional[str] = None,
location_id: Optional[int] = None, supervisor_id: Optional[int] = None,
staff: Optional[bool] = False, active: Optional[bool] = False):
self.first_name = first_name
self.last_name = last_name
self.location_id = location_id
self.supervisor_id = supervisor_id
self.staff = staff
self.active = active
def __repr__(self):
return f"<User(id={self.id}, first_name={repr(self.first_name)}, last_name={repr(self.last_name)}, " \
f"location={repr(self.location)}, staff={self.staff}, active={self.active})>"
def serialize(self):
return {
'id': self.id,
'first_name': self.first_name,
'last_name': self.last_name,
'location_id': self.location_id,
'supervisor_id': self.supervisor_id,
'staff': self.staff,
'active': self.active
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "User":
return cls(
staff=bool(data.get("staff", False)),
active=bool(data.get("active", False)),
last_name=data.get("last_name"),
first_name=data.get("first_name"),
location_id=data.get("location_id"),
supervisor_id=data.get("supervisor_id")
)

View file

@ -0,0 +1,76 @@
from typing import Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from .inventory import Inventory
from .users import User
from sqlalchemy import Boolean, ForeignKeyConstraint, Identity, Integer, ForeignKey, Unicode, DateTime, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
import datetime
from . import db
class WorkLog(db.Model):
__tablename__ = 'Work Log'
__table_args__ = (
ForeignKeyConstraint(['Work Item'], ['Inventory.ID'], name='Work Log$InventoryWork Log'),
)
id: Mapped[int] = mapped_column("ID", Integer, Identity(start=1, increment=1), primary_key=True)
start_time: Mapped[Optional[datetime.datetime]] = mapped_column('Start Timestamp', DateTime)
end_time: Mapped[Optional[datetime.datetime]] = mapped_column('End Timestamp', DateTime)
notes: Mapped[Optional[str]] = mapped_column('Description & Notes', Unicode())
complete: Mapped[Optional[bool]] = mapped_column("Complete", Boolean, server_default=text('((0))'))
followup: Mapped[Optional[bool]] = mapped_column('Needs Follow-Up', Boolean, server_default=text('((0))'))
contact_id: Mapped[Optional[int]] = mapped_column('Point of Contact', Integer, ForeignKey("Users.ID"))
analysis: Mapped[Optional[bool]] = mapped_column('Needs Quick Analysis', Boolean, server_default=text('((0))'))
work_item_id: Mapped[Optional[int]] = mapped_column("Work Item", Integer, ForeignKey("Inventory.ID"))
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
def __init__(self, start_time: Optional[datetime.datetime] = None, end_time: Optional[datetime.datetime] = None,
notes: Optional[str] = None, complete: Optional[bool] = False,
followup: Optional[bool] = False, contact_id: Optional[int] = None,
analysis: Optional[bool] = False, work_item_id: Optional[int] = None):
self.start_time = start_time
self.end_time = end_time
self.notes = notes
self.complete = complete
self.followup = followup
self.contact_id = contact_id
self.analysis = analysis
self.work_item_id = work_item_id
def __repr__(self):
return f"<WorkLog(id={self.id}, start_time={self.start_time}, end_time={self.end_time}, " \
f"notes={repr(self.notes)}, complete={self.complete}, followup={self.followup}, " \
f"contact_id={self.contact_id}, analysis={self.analysis}, work_item_id={self.work_item_id})>"
def serialize(self):
return {
'id': self.id,
'start_time': self.start_time.isoformat() if self.start_time else None,
'end_time': self.end_time.isoformat() if self.end_time else None,
'notes': self.notes,
'complete': self.complete,
'followup': self.followup,
'contact_id': self.contact_id,
'analysis': self.analysis,
'work_item_id': self.work_item_id
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkLog":
start_time_str = data.get("start_time")
end_time_str = data.get("end_time")
return cls(
start_time=datetime.datetime.fromisoformat(str(start_time_str)) if start_time_str else datetime.datetime.now(),
end_time=datetime.datetime.fromisoformat(str(end_time_str)) if end_time_str else None,
notes=data.get("notes"),
complete=bool(data.get("complete", False)),
followup=bool(data.get("followup", False)),
analysis=bool(data.get("analysis", False)),
contact_id=data.get("contact_id"),
work_item_id=data.get("work_item_id")
)

View file

@ -0,0 +1,5 @@
from flask import Blueprint
main = Blueprint('main', __name__)
from . import inventory, user, worklog, settings, index, search, hooks

View file

@ -0,0 +1,65 @@
from flask import url_for
from ..models import Inventory
inventory_headers = {
"Date Entered": lambda i: {"text": i.timestamp.strftime("%Y-%m-%d") if i.timestamp else None},
"Identifier": lambda i: {"text": i.identifier},
"Inventory #": lambda i: {"text": i.inventory_name},
"Serial #": lambda i: {"text": i.serial},
"Bar Code #": lambda i: {"text": i.barcode},
"Brand": lambda i: {"text": i.brand.name} if i.brand else {"text": None},
"Model": lambda i: {"text": i.model},
"Item Type": lambda i: {"text": i.item.description} if i.item else {"text": None},
"Shared?": lambda i: {"text": i.shared, "type": "bool", "html": checked_box if i.shared else unchecked_box},
"Owner": lambda i: {"text": i.owner.full_name, "url": url_for("main.user", id=i.owner.id)} if i.owner else {"text": None},
"Location": lambda i: {"text": i.location.full_name} if i.location else {"Text": None},
"Condition": lambda i: {"text": i.condition},
# "Notes": lambda i: {"text": i.notes}
}
checked_box = '''
<i class="bi bi-check2"></i>
'''
unchecked_box = ''
ACTIVE_STATUSES = [
"Working",
"Deployed",
"Partially Inoperable",
"Unverified"
]
INACTIVE_STATUSES = [
"Inoperable",
"Removed",
"Disposed"
]
FILTER_MAP = {
'user': Inventory.owner_id,
'location': Inventory.location_id,
'type': Inventory.type_id,
}
user_headers = {
"Last Name": lambda i: {"text": i.last_name},
"First Name": lambda i: {"text": i.first_name},
"Supervisor": lambda i: {"text": i.supervisor.full_name, "url": url_for("main.user", id=i.supervisor.id)} if i.supervisor else {"text": None},
"Location": lambda i: {"text": i.location.full_name} if i.location else {"text": None},
"Staff?": lambda i: {"text": i.staff, "type": "bool", "html": checked_box if i.staff else unchecked_box},
"Active?": lambda i: {"text": i.active, "type": "bool", "html": checked_box if i.active else unchecked_box}
}
worklog_headers = {
"Contact": lambda i: {"text": i.contact.full_name, "url": url_for("main.user", id=i.contact.id)} if i.contact else {"Text": None},
"Work Item": lambda i: {"text": i.work_item.identifier, "url": url_for('main.inventory_item',id=i.work_item.id)} if i.work_item else {"text": None},
"Start Time": lambda i: {"text": i.start_time.strftime("%Y-%m-%d")},
"End Time": lambda i: {"text": i.end_time.strftime("%Y-%m-%d")} if i.end_time else {"text": None},
"Complete?": lambda i: {"text": i.complete, "type": "bool", "html": checked_box if i.complete else unchecked_box},
"Follow Up?": lambda i: {"text": i.followup, "type": "bool", "html": checked_box if i.followup else unchecked_box, "highlight": i.followup},
"Quick Analysis?": lambda i: {"text": i.analysis, "type": "bool", "html": checked_box if i.analysis else unchecked_box},
}
def link(text, endpoint, **values):
return {"text": text, "url": url_for(endpoint, **values)}

18
inventory/routes/hooks.py Normal file
View file

@ -0,0 +1,18 @@
from bs4 import BeautifulSoup
from flask import current_app as app
from . import main
@main.after_request
def prettify_html_response(response):
if app.debug and response.content_type.startswith("text/html"):
try:
soup = BeautifulSoup(response.get_data(as_text=True), 'html5lib')
pretty_html = soup.prettify()
response.set_data(pretty_html.encode("utf-8")) # type: ignore
response.headers['Content-Type'] = 'text/html; charset=utf-8'
except Exception as e:
print(f"⚠️ Prettifying failed: {e}")
return response

86
inventory/routes/index.py Normal file
View file

@ -0,0 +1,86 @@
from flask import render_template
import pandas as pd
from . import main
from .helpers import worklog_headers
from .. import db
from ..models import WorkLog, Inventory
from ..utils.load import eager_load_worklog_relationships, eager_load_inventory_relationships
@main.route("/")
def index():
worklog_query = eager_load_worklog_relationships(
db.session.query(WorkLog)
).filter(
(WorkLog.complete == False)
)
active_worklogs = worklog_query.all()
active_count = len(active_worklogs)
active_worklog_headers = {
k: v for k, v in worklog_headers.items()
if k not in ['End Time', 'Quick Analysis?', 'Complete?', 'Follow Up?']
}
inventory_query = eager_load_inventory_relationships(
db.session.query(Inventory)
)
results = inventory_query.all()
data = [{
'id': item.id,
'condition': item.condition
} for item in results]
df = pd.DataFrame(data)
# Count items per condition
expected_conditions = [
'Deployed','Inoperable', 'Partially Inoperable',
'Unverified', 'Working'
]
print(df)
if 'condition' in df.columns:
pivot = df['condition'].value_counts().reindex(expected_conditions, fill_value=0)
else:
pivot = pd.Series([0] * len(expected_conditions), index=expected_conditions)
# Convert pandas/numpy int64s to plain old Python ints
pivot = pivot.astype(int)
labels = list(pivot.index)
data = [int(x) for x in pivot.values]
datasets = [{
'type': 'pie',
'labels': labels,
'values': data,
'name': 'Inventory Conditions'
}]
active_worklog_rows = []
for log in active_worklogs:
# Create a dictionary of {column name: cell dict}
cells_by_key = {k: fn(log) for k, fn in worklog_headers.items()}
# Use original, full header set for logic
highlight = cells_by_key.get("Follow Up?", {}).get("highlight", False)
# Use only filtered headers — and in exact order
cells = [cells_by_key[k] for k in active_worklog_headers]
active_worklog_rows.append({
"id": log.id,
"cells": cells,
"highlight": highlight
})
return render_template(
"index.html",
active_count=active_count,
active_worklog_headers=active_worklog_headers,
active_worklog_rows=active_worklog_rows,
labels=labels,
datasets=datasets
)

View file

@ -0,0 +1,207 @@
import datetime
from flask import request, render_template, url_for, jsonify
from . import main
from .helpers import FILTER_MAP, inventory_headers, worklog_headers
from .. import db
from ..models import Inventory, User, Room, Item, RoomFunction, Brand, WorkLog
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships, eager_load_room_relationships, chunk_list
@main.route("/inventory")
def list_inventory():
filter_by = request.args.get('filter_by', type=str)
id = request.args.get('id', type=int)
filter_name = None
query = db.session.query(Inventory)
query = eager_load_inventory_relationships(query)
query = query.order_by(Inventory.inventory_name, Inventory.barcode, Inventory.serial)
if filter_by and id:
column = FILTER_MAP.get(filter_by)
if column is not None:
filter_name = None
if filter_by == 'user':
if not (user := db.session.query(User).filter(User.id == id).first()):
return "Invalid User ID", 400
filter_name = user.full_name
elif filter_by == 'location':
if not (room := db.session.query(Room).filter(Room.id == id).first()):
return "Invalid Location ID", 400
filter_name = room.full_name
else:
if not (item := db.session.query(Item).filter(Item.id == id).first()):
return "Invalid Type ID", 400
filter_name = item.description
query = query.filter(column == id)
else:
return "Invalid filter_by parameter", 400
inventory = query.all()
return render_template(
'table.html',
title=f"Inventory Listing ({filter_name})" if filter_by else "Inventory Listing",
breadcrumb=[{'label': 'Inventory', 'url': url_for('main.inventory_index')}],
header=inventory_headers,
rows=[{"id": item.id, "cells": [row_fn(item) for row_fn in inventory_headers.values()]} for item in inventory],
entry_route = 'inventory_item'
)
@main.route("/inventory/index")
def inventory_index():
category = request.args.get('category')
listing = None
if category == 'user':
users = db.session.query(User.id, User.first_name, User.last_name).order_by(User.first_name, User.last_name).all()
listing = chunk_list([(user.id, f"{user.first_name or ''} {user.last_name or ''}".strip()) for user in users], 12)
elif category == 'location':
rooms = (
db.session.query(Room.id, Room.name, RoomFunction.description)
.join(RoomFunction, Room.function_id == RoomFunction.id)
.order_by(Room.name, RoomFunction.description)
.all()
)
listing = chunk_list([(room.id, f"{room.name or ''} - {room.description or ''}".strip()) for room in rooms], 12)
elif category == 'type':
types = db.session.query(Item.id, Item.description).order_by(Item.description).all()
listing = chunk_list(types, 12)
elif category:
return f"Dude, why {category}?"
return render_template(
'inventory_index.html',
title=f"Inventory ({category.capitalize()} Index)" if category else "Inventory",
category=category,
listing=listing
)
@main.route("/inventory_item/<id>", methods=['GET', 'POST'])
def inventory_item(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title="Bad ID", message="ID must be an integer", endpoint='inventory_item', endpoint_args={'id': -1})
inventory_query = db.session.query(Inventory)
item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first()
brands = db.session.query(Brand).order_by(Brand.name).all()
users = eager_load_user_relationships(db.session.query(User).order_by(User.first_name)).all()
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
worklog_query = db.session.query(WorkLog).filter(WorkLog.work_item_id == id)
worklog = eager_load_worklog_relationships(worklog_query).all()
types = db.session.query(Item).order_by(Item.description).all()
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Work Item', 'Contact', 'Follow Up?', 'Quick Analysis?']}
if item:
title = f"Inventory Record - {item.identifier}"
else:
title = "Inventory Record - Not Found"
return render_template('error.html',
title=title,
message=f'Inventory item with id {id} not found!',
endpoint='inventory_item',
endpoint_args={'id': -1})
return render_template("inventory.html", title=title, item=item,
brands=brands, users=users, rooms=rooms,
worklog=worklog,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog],
types=types
)
@main.route("/inventory_item/new", methods=["GET"])
def new_inventory_item():
brands = db.session.query(Brand).all()
users = eager_load_user_relationships(db.session.query(User)).all()
rooms = eager_load_room_relationships(db.session.query(Room)).all()
types = db.session.query(Item).all()
item = Inventory(
timestamp=datetime.datetime.now(),
condition="Unverified",
needed="",
type_id=None,
)
return render_template(
"inventory.html",
item=item,
brands=brands,
users=users,
rooms=rooms,
types=types,
worklog=[],
worklog_headers={},
worklog_rows=[]
)
@main.route("/api/inventory", methods=["POST"])
def create_inventory_item():
try:
data = request.get_json(force=True)
new_item = Inventory.from_dict(data)
db.session.add(new_item)
db.session.commit()
return jsonify({"success": True, "id": new_item.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/inventory/<int:id>", methods=["PUT"])
def update_inventory_item(id):
try:
data = request.get_json(force=True)
item = db.session.query(Inventory).get(id)
if not item:
return jsonify({"success": False, "error": f"Inventory item with ID {id} not found."}), 404
item.timestamp = datetime.datetime.fromisoformat(data.get("timestamp")) if data.get("timestamp") else item.timestamp
item.condition = data.get("condition", item.condition)
item.needed = data.get("needed", item.needed)
item.type_id = data.get("type_id", item.type_id)
item.inventory_name = data.get("inventory_name", item.inventory_name)
item.serial = data.get("serial", item.serial)
item.model = data.get("model", item.model)
item.notes = data.get("notes", item.notes)
item.owner_id = data.get("owner_id", item.owner_id)
item.brand_id = data.get("brand_id", item.brand_id)
item.location_id = data.get("location_id", item.location_id)
item.barcode = data.get("barcode", item.barcode)
item.shared = bool(data.get("shared", item.shared))
db.session.commit()
return jsonify({"success": True, "id": item.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/inventory/<int:id>", methods=["DELETE"])
def delete_inventory_item(id):
try:
item = db.session.query(Inventory).get(id)
if not item:
return jsonify({"success": False, "error": f"Item with ID {id} not found"}), 404
db.session.delete(item)
db.session.commit()
return jsonify({"success": True}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400

View file

@ -0,0 +1,68 @@
from flask import request, redirect, url_for, render_template
from sqlalchemy import or_
from sqlalchemy.orm import aliased
from . import main
from .helpers import inventory_headers, user_headers, worklog_headers
from .. import db
from ..models import Inventory, User, WorkLog
from ..utils.load import eager_load_inventory_relationships, eager_load_user_relationships, eager_load_worklog_relationships
@main.route("/search")
def search():
query = request.args.get('q', '').strip()
if not query:
return redirect(url_for('main.index'))
InventoryAlias = aliased(Inventory)
UserAlias = aliased(User)
inventory_query = eager_load_inventory_relationships(db.session.query(Inventory).join(UserAlias, Inventory.owner)).filter(
or_(
Inventory.inventory_name.ilike(f"%{query}%"),
Inventory.serial.ilike(f"%{query}%"),
Inventory.barcode.ilike(f"%{query}%"),
Inventory.notes.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%")
))
inventory_results = inventory_query.all()
user_query = eager_load_user_relationships(db.session.query(User).join(UserAlias, User.supervisor)).filter(
or_(
User.first_name.ilike(f"%{query}%"),
User.last_name.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%")
))
user_results = user_query.all()
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog).join(UserAlias, WorkLog.contact).join(InventoryAlias, WorkLog.work_item)).filter(
or_(
WorkLog.notes.ilike(f"%{query}%"),
UserAlias.first_name.ilike(f"%{query}%"),
UserAlias.last_name.ilike(f"%{query}%"),
InventoryAlias.inventory_name.ilike(f"%{query}%"),
InventoryAlias.serial.ilike(f"%{query}%"),
InventoryAlias.barcode.ilike(f"%{query}%")
))
worklog_results = worklog_query.all()
results = {
'inventory': {
'results': inventory_query,
'headers': inventory_headers,
'rows': [{"id": item.id, "cells": [fn(item) for fn in inventory_headers.values()]} for item in inventory_results]
},
'users': {
'results': user_query,
'headers': user_headers,
'rows': [{"id": user.id, "cells": [fn(user) for fn in user_headers.values()]} for user in user_results]
},
'worklog': {
'results': worklog_query,
'headers': worklog_headers,
'rows': [{"id": log.id, "cells": [fn(log) for fn in worklog_headers.values()]} for log in worklog_results]
}
}
return render_template('search.html', title=f"Database Search ({query})" if query else "Database Search", results=results, query=query)

View file

@ -0,0 +1,107 @@
import json
import traceback
from flask import request, flash, redirect, url_for, render_template, jsonify
from . import main
from .. import db
from ..models import Brand, Item, Area, RoomFunction, Room
from ..utils.load import eager_load_room_relationships
@main.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
form = request.form
try:
state = json.loads(form['formState'])
except Exception:
flash("Invalid form state submitted. JSON decode failed.", "danger")
traceback.print_exc()
return redirect(url_for('main.settings'))
try:
with db.session.begin():
# Sync each table and grab temp ID maps
brand_map = Brand.sync_from_state(state.get("brands", []))
type_map = Item.sync_from_state(state.get("types", []))
section_map = Area.sync_from_state(state.get("sections", []))
function_map = RoomFunction.sync_from_state(state.get("functions", []))
# Fix up room foreign keys based on real IDs
submitted_rooms = []
for room in state.get("rooms", []):
room = dict(room) # shallow copy
sid = room.get("section_id")
fid = room.get("function_id")
if sid is not None:
sid_key = str(sid)
if sid_key in section_map:
room["section_id"] = section_map[sid_key]
if fid is not None:
fid_key = str(fid)
if fid_key in function_map:
room["function_id"] = function_map[fid_key]
submitted_rooms.append(room)
Room.sync_from_state(
submitted_rooms=submitted_rooms,
section_map=section_map,
function_map=function_map
)
flash("Changes saved.", "success")
except Exception as e:
flash(f"Error saving changes: {e}", "danger")
return redirect(url_for('main.settings'))
# === GET ===
brands = db.session.query(Brand).order_by(Brand.name).all()
types = db.session.query(Item).order_by(Item.description).all()
sections = db.session.query(Area).order_by(Area.name).all()
functions = db.session.query(RoomFunction).order_by(RoomFunction.description).all()
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).all()
return render_template('settings.html',
title="Settings",
brands=[b.serialize() for b in brands],
types=[{"id": t.id, "name": t.description} for t in types],
sections=[s.serialize() for s in sections],
functions=[f.serialize() for f in functions],
rooms=[r.serialize() for r in rooms]
)
@main.route("/api/settings", methods=["POST"])
def api_settings():
try:
payload = request.get_json(force=True)
except Exception as e:
return jsonify({"error": "Invalid JSON"}), 400
errors = []
errors += Brand.validate_state(payload.get("brands", []))
errors += Item.validate_state(payload.get("types", []))
errors += Area.validate_state(payload.get("sections", []))
errors += RoomFunction.validate_state(payload.get("functions", []))
errors += Room.validate_state(payload.get("rooms", []))
if errors:
return jsonify({"errors": errors}), 400
try:
with db.session.begin():
section_map = Area.sync_from_state(payload["sections"])
function_map = RoomFunction.sync_from_state(payload["functions"])
Brand.sync_from_state(payload["brands"])
Item.sync_from_state(payload["types"])
Room.sync_from_state(payload["rooms"], section_map, function_map)
except Exception as e:
db.session.rollback()
return jsonify({"errors": [str(e)]}), 500
return jsonify({"message": "Settings updated successfully."}), 200

123
inventory/routes/user.py Normal file
View file

@ -0,0 +1,123 @@
from flask import render_template, request, jsonify
from . import main
from .helpers import ACTIVE_STATUSES, user_headers, inventory_headers, worklog_headers
from .. import db
from ..utils.load import eager_load_user_relationships, eager_load_room_relationships, eager_load_inventory_relationships, eager_load_worklog_relationships
from ..models import User, Room, Inventory, WorkLog
@main.route("/users")
def list_users():
query = eager_load_user_relationships(db.session.query(User)).order_by(User.last_name, User.first_name)
users = query.all()
return render_template(
'table.html',
header = user_headers,
rows = [{"id": user.id, "cells": [fn(user) for fn in user_headers.values()]} for user in users],
title = "Users",
entry_route = 'user'
)
@main.route("/user/<id>")
def user(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='user', endpoint_args={'id': -1})
users_query = db.session.query(User).order_by(User.first_name, User.last_name)
users = eager_load_user_relationships(users_query).all()
user = next((u for u in users if u.id == id), None)
rooms_query = db.session.query(Room)
rooms = eager_load_room_relationships(rooms_query).all()
inventory_query = (
eager_load_inventory_relationships(db.session.query(Inventory))
.filter(Inventory.owner_id == id) # type: ignore
.filter(Inventory.condition.in_(ACTIVE_STATUSES))
)
inventory = inventory_query.all()
filtered_inventory_headers = {k: v for k, v in inventory_headers.items() if k not in ['Date Entered', 'Inventory #', 'Serial #',
'Bar Code #', 'Condition', 'Owner', 'Notes',
'Brand', 'Model', 'Shared?', 'Location']}
worklog_query = eager_load_worklog_relationships(db.session.query(WorkLog)).filter(WorkLog.contact_id == id)
worklog = worklog_query.order_by(WorkLog.start_time.desc()).all()
filtered_worklog_headers = {k: v for k, v in worklog_headers.items() if k not in ['Contact', 'Follow Up?', 'Quick Analysis?']}
if user:
title = f"User Record - {user.full_name}" if user.active else f"User Record - {user.full_name} (Inactive)"
else:
title = f"User Record - User Not Found"
return render_template(
'error.html',
title=title,
message=f"User with id {id} not found!"
)
return render_template(
"user.html",
title=title,
user=user, users=users, rooms=rooms, assets=inventory,
inventory_headers=filtered_inventory_headers,
inventory_rows=[{"id": item.id, "cells": [fn(item) for fn in filtered_inventory_headers.values()]} for item in inventory],
worklog=worklog,
worklog_headers=filtered_worklog_headers,
worklog_rows=[{"id": log.id, "cells": [fn(log) for fn in filtered_worklog_headers.values()]} for log in worklog]
)
@main.route("/user/new", methods=["GET"])
def new_user():
rooms = eager_load_room_relationships(db.session.query(Room)).all()
users = eager_load_user_relationships(db.session.query(User)).all()
user = User(
active=True
)
return render_template(
"user.html",
title="New User",
user=user,
users=users,
rooms=rooms
)
@main.route("/api/user", methods=["POST"])
def create_user():
try:
data = request.get_json(force=True)
new_user = User.from_dict(data)
db.session.add(new_user)
db.session.commit()
return jsonify({"success": True, "id": new_user.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/user/<int:id>", methods=["PUT"])
def update_user(id):
try:
data = request.get_json(force=True)
user = db.session.query(User).get(id)
if not user:
return jsonify({"success": False, "error": f"User with ID {id} not found."}), 404
user.staff = bool(data.get("staff", user.staff))
user.active = bool(data.get("active", user.active))
user.last_name = data.get("last_name", user.last_name)
user.first_name = data.get("first_name", user.first_name)
user.location_id = data.get("location_id", user.location_id)
user.supervisor_id = data.get("supervisor_id", user.supervisor_id)
db.session.commit()
return jsonify({"success": True, "id": user.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400

131
inventory/routes/worklog.py Normal file
View file

@ -0,0 +1,131 @@
import datetime
from flask import request, render_template, jsonify
from . import main
from .helpers import worklog_headers
from .. import db
from ..models import WorkLog, User, Inventory
from ..utils.load import eager_load_worklog_relationships, eager_load_user_relationships, eager_load_inventory_relationships
@main.route("/worklog")
def list_worklog(page=1):
page = request.args.get('page', default=1, type=int)
query = eager_load_worklog_relationships(db.session.query(WorkLog))
return render_template(
'table.html',
header=worklog_headers,
rows=[{"id": log.id, "cells": [fn(log) for fn in worklog_headers.values()]} for log in query.all()],
title="Work Log",
entry_route='worklog_entry'
)
@main.route("/worklog/<id>")
def worklog_entry(id):
try:
id = int(id)
except ValueError:
return render_template('error.html', title='Bad ID', message='ID must be an integer.', endpoint='worklog_entry', endpoint_args={'id': -1})
log = eager_load_worklog_relationships(db.session.query(WorkLog)).filter(WorkLog.id == id).first()
user_query = db.session.query(User).order_by(User.first_name)
users = eager_load_user_relationships(user_query).all()
item_query = db.session.query(Inventory)
items = eager_load_inventory_relationships(item_query).all()
items = sorted(items, key=lambda i: i.identifier)
if log:
title = f'Work Log - Entry #{id}'
else:
title = "Work Log - Entry Not Found"
return render_template(
'error.html',
title=title,
message=f"The work log with ID {id} is not found!"
)
return render_template(
"worklog.html",
title=title,
log=log,
users=users,
items=items
)
@main.route("/worklog_entry/new", methods=["GET"])
def new_worklog():
items = eager_load_inventory_relationships(db.session.query(Inventory)).all()
users = eager_load_user_relationships(db.session.query(User)).all()
log = WorkLog(
start_time=datetime.datetime.now(),
followup=True
)
return render_template(
"worklog.html",
title="New Entry",
log=log,
users=users,
items=items
)
@main.route("/api/worklog", methods=["POST"])
def create_worklog():
try:
data = request.get_json(force=True)
new_worklog = WorkLog.from_dict(data)
db.session.add(new_worklog)
db.session.commit()
return jsonify({"success": True, "id": new_worklog.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/worklog/<int:id>", methods=["PUT"])
def update_worklog(id):
try:
data = request.get_json(force=True)
print(data)
log = db.session.query(WorkLog).get(id)
if not log:
return jsonify({"success": False, "error": f"Work Log with ID {id} not found."}), 404
log.start_time = datetime.datetime.fromisoformat(data.get("start_time")) if data.get("start_time") else log.start_time
log.end_time = datetime.datetime.fromisoformat(data.get("end_time")) if data.get("end_time") else log.end_time
log.notes = data.get("notes", log.notes)
log.complete = bool(data.get("complete", log.complete))
log.followup = bool(data.get("followup", log.followup))
log.analysis = bool(data.get("analysis", log.analysis))
log.contact_id = data.get("contact_id", log.contact_id)
log.work_item_id = data.get("work_item_id", log.work_item_id)
db.session.commit()
return jsonify({"success": True, "id": log.id}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400
@main.route("/api/worklog/<int:id>", methods=["DELETE"])
def delete_worklog(id):
try:
log = db.session.query(WorkLog).get(id)
if not log:
return jsonify({"success": False, "errpr": f"Item with ID {id} not found!"}), 404
db.session.delete(log)
db.session.commit()
return jsonify({"success": True}), 200
except Exception as e:
db.session.rollback()
return jsonify({"success": False, "error": str(e)}), 400

View file

@ -0,0 +1,20 @@
.combo-box-widget .form-control:focus,
.combo-box-widget .form-select:focus,
.combo-box-widget .btn:focus {
box-shadow: none !important;
outline: none !important;
border-color: #ced4da !important; /* Bootstraps default neutral border */
background-color: inherit; /* Or explicitly #fff if needed */
color: inherit;
}
.combo-box-widget .btn-primary:focus,
.combo-box-widget .btn-danger:focus {
background-color: inherit; /* Keep button from darkening */
color: inherit;
}
.combo-box-widget:focus-within {
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
border-radius: 0.375rem;
}

View file

@ -0,0 +1,184 @@
const ToastConfig = {
containerId: 'toast-container',
positionClasses: 'toast-container position-fixed bottom-0 end-0 p-3',
defaultType: 'info',
defaultTimeout: 3000
};
function updateToastConfig(overrides = {}) {
Object.assign(ToastConfig, overrides);
}
function renderToast({ message, type = ToastConfig.defaultType, timeout = ToastConfig.defaultTimeout }) {
if (!message) {
console.warn('renderToast was called without a message.');
return;
}
// Auto-create the toast container if it doesn't exist
let container = document.getElementById(ToastConfig.containerId);
if (!container) {
container = document.createElement('div');
container.id = ToastConfig.containerId;
container.className = ToastConfig.positionClasses;
document.body.appendChild(container);
}
const toastId = `toast-${Date.now()}`;
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div id="${toastId}" class="toast align-items-center text-bg-${type}" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
const toastElement = wrapper.firstElementChild;
container.appendChild(toastElement);
const toast = new bootstrap.Toast(toastElement, { delay: timeout });
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
// Clean up container if empty
if (container.children.length === 0) {
container.remove();
}
});
}
const ComboBoxWidget = (() => {
let tempIdCounter = 1;
function createTempId(prefix = "temp") {
return `${prefix}-${tempIdCounter++}`;
}
function createOption(text, value = null) {
const option = document.createElement('option');
option.textContent = text;
option.value = value ?? createTempId();
return option;
}
function sortOptions(selectElement) {
const sorted = Array.from(selectElement.options)
.sort((a, b) => a.text.localeCompare(b.text));
selectElement.innerHTML = '';
sorted.forEach(option => selectElement.appendChild(option));
}
function initComboBox(ns, config = {}) {
const input = document.querySelector(`#${ns}-input`);
const list = document.querySelector(`#${ns}-list`);
const addBtn = document.querySelector(`#${ns}-add`);
const removeBtn = document.querySelector(`#${ns}-remove`);
let currentlyEditing = null;
if (!input || !list || !addBtn || !removeBtn) {
console.warn(`ComboBoxWidget: Missing elements for namespace '${ns}'`);
return;
}
function updateAddButtonIcon() {
const iconEl = addBtn.querySelector('.icon-state');
const iconClass = currentlyEditing ? 'bi-pencil' : 'bi-plus-lg';
iconEl.classList.forEach(cls => {
if (cls.startsWith('bi-') && cls !== 'icon-state') {
iconEl.classList.remove(cls);
}
});
iconEl.classList.add(iconClass);
}
input.addEventListener('input', () => {
addBtn.disabled = input.value.trim() === '';
updateAddButtonIcon();
});
list.addEventListener('change', () => {
const selected = list.selectedOptions;
removeBtn.disabled = selected.length === 0;
if (selected.length === 1) {
input.value = selected[0].textContent.trim();
currentlyEditing = selected[0];
addBtn.disabled = input.value.trim() === '';
} else {
input.value = '';
currentlyEditing = null;
addBtn.disabled = true;
}
updateAddButtonIcon();
});
addBtn.addEventListener('click', () => {
const newItem = input.value.trim();
if (!newItem) return;
if (currentlyEditing) {
if (config.onEdit) {
config.onEdit(currentlyEditing, newItem);
} else {
currentlyEditing.textContent = newItem;
}
currentlyEditing = null;
} else {
if (config.onAdd) {
config.onAdd(newItem, list, createOption);
return; // Skip the default logic!
}
const exists = Array.from(list.options).some(opt => opt.textContent === newItem);
if (exists) {
alert(`"${newItem}" already exists.`);
return;
}
const option = createOption(newItem);
list.appendChild(option);
const key = config.stateArray ?? `${ns}s`; // fallback to pluralization
if (config.sort !== false) {
sortOptions(list);
}
}
input.value = '';
addBtn.disabled = true;
removeBtn.disabled = true;
updateAddButtonIcon();
});
removeBtn.addEventListener('click', () => {
Array.from(list.selectedOptions).forEach(option => {
if (config.onRemove) {
config.onRemove(option);
}
option.remove();
});
currentlyEditing = null;
input.value = '';
addBtn.disabled = true;
removeBtn.disabled = true;
updateAddButtonIcon();
});
}
return {
initComboBox,
createOption,
sortOptions,
createTempId
};
})();

6
inventory/temp.py Normal file
View file

@ -0,0 +1,6 @@
def is_temp_id(val):
return (
val is None or
(isinstance(val, int) and val < 0) or
(isinstance(val, str) and val.startswith("temp-"))
)

View file

@ -0,0 +1,9 @@
{% extends 'layout.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="alert alert-danger text-center">
{{ message }}
</div>
{% endblock %}

View file

@ -0,0 +1,44 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro breadcrumb_header(breadcrumbs=[], title=None, save_button=False, delete_button=False, create_button=False) %}
<!-- Breadcrumb Fragment -->
<nav class="row d-flex mb-3 justify-content-between">
<div class="col">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{{ url_for('main.index') }}" class="link-success link-underline-opacity-0">
{{ icons.render_icon('house', 16) }}
</a>
</li>
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item">
{% if crumb.url %}
<a href="{{ crumb.url }}" class="link-success link-underline-opacity-0">{{ crumb.label }}</a>
{% else %}
{{ crumb.label }}
{% endif %}
</li>
{% endfor %}
{% if title %}
<li class="breadcrumb-item active">{{ title }}</li>
{% endif %}
</ol>
</div>
{% if save_button or delete_button or create_button %}
<div class="col text-end">
<div class="btn-group">
{% if save_button %}
<button type="submit" class="btn btn-primary" id="saveButton">{{ icons.render_icon('floppy', 16) }}</button>
{% endif %}
{% if delete_button %}
<button type="submit" class="btn btn-danger" id="deleteButton">{{ icons.render_icon('trash', 16) }}</button>
{% endif %}
{% if create_button %}
<button type="submit" class="btn btn-primary" id="createButton">{{ icons.render_icon('plus', 16) }}</button>
{% endif %}
</div>
</div>
{% endif %}
</nav>
{% endmacro %}

View file

@ -0,0 +1,44 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_combobox(id, options, label=none, placeholder=none, onAdd=none, onRemove=none, onEdit=none, data_attributes=none) %}
<!-- Combobox Widget Fragment -->
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div class="combo-box-widget" id="{{ id }}-container">
<div class="input-group">
<input type="text" class="form-control rounded-bottom-0" id="{{ id }}-input"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}>
<button type="button" class="btn btn-primary rounded-bottom-0" id="{{ id }}-add" disabled>
{{ icons.render_icon('plus-lg', 16, 'icon-state') }}
</button>
<button type="button" class="btn btn-danger rounded-bottom-0" id="{{ id }}-remove" disabled>
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select class="form-select border-top-0 rounded-top-0" id="{{ id }}-list" name="{{ id }}" size="10" multiple>
{% for option in options %}
<option value="{{ option.id }}"
{% if data_attributes %}
{% for key, data_attr in data_attributes.items() %}
{% if option[key] is defined %}
data-{{ data_attr }}="{{ option[key] }}"
{% endif %}
{% endfor %}
{% endif %}>
{{ option.name }}
</option>
{% endfor %}
</select>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
ComboBoxWidget.initComboBox("{{ id }}"{% if onAdd or onRemove or onEdit %}, {
{% if onAdd %}onAdd: function(newItem, list, createOption) { {{ onAdd | safe }} },{% endif %}
{% if onRemove %}onRemove: function(option) { {{ onRemove | safe }} },{% endif %}
{% if onEdit %}onEdit: function(option) { {{ onEdit | safe }} }{% endif %}
}{% endif %});
});
</script>
{% endmacro %}

View file

@ -0,0 +1,6 @@
{% macro render_icon(icon, size=24, extra_class='') %}
<!-- Icon Fragment -->
<i class="bi bi-{{ icon }} {{ extra_class }}" style="font-size: {{ size }}px;"></i>
{% endmacro %}

View file

@ -0,0 +1,40 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro category_link(endpoint, label, icon_html=none, arguments={}) %}
<!-- Category Link Fragment -->
<div class="col text-center">
<a href="{{ url_for('main.' + endpoint, **arguments) }}"
class="d-flex flex-column justify-content-center link-success link-underline-opacity-0">
{% if icon_html %}
{{ icon_html | safe }}
{% endif %}
{{ label }}
</a>
</div>
{% endmacro %}
{% macro navigation_link(endpoint, label, icon_html=none, arguments={}, active=false) %}
<!-- Navigation Link Fragment -->
{% if not active %}
{% set active = request.endpoint == 'main.' + endpoint %}
{% endif %}
<li class="nav-item">
<a href="{{ url_for('main.' + endpoint, **arguments) }}" class="nav-link{% if active %} active{% endif %}">
{% if icon_html %}
{{ icon_html | safe }}
{% endif %}
{{ label }}
</a>
</li>
{% endmacro %}
{% macro entry_link(endpoint, id) %}
<!-- Entry Link Fragment -->
<a href="{{ url_for('main.' + endpoint, id=id) }}" class="link-success link-underline-opacity-0">
{{ icons.render_icon('link', 12) }}
</a>
{% endmacro %}

View file

@ -0,0 +1,52 @@
{% macro render_table(headers, rows, id, entry_route=None, title=None, per_page=15) %}
<!-- Table Fragment -->
{% if rows %}
{% if title %}
<label for="datatable-{{ id|default('table')|replace(' ', '-')|lower }}" class="form-label">{{ title }}</label>
{% endif %}
<div class="table-responsive">
<table id="datatable-{{ id|default('table')|replace(' ', '-')|lower }}"
class="table table-bordered table-sm table-hover table-striped table-light m-0{% if title %} caption-top{% endif %}">
<thead class="sticky-top">
<tr>
{% for h in headers %}
<th class="text-nowrap">{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr {% if entry_route %}onclick="window.location='{{ url_for('main.' + entry_route, id=row.id) }}'"
style="cursor: pointer;"{% endif %}{% if row['highlight'] %} class="table-info"{% endif %}>
{% for cell in row.cells %}
<td class="text-nowrap{% if cell.type=='bool' %} text-center{% endif %}">
{% if cell.type == 'bool' %}
{{ cell.html | safe }}
{% elif cell.url %}
<a class="link-success link-underline-opacity-0" href="{{ cell.url }}">{{ cell.text }}</a>
{% else %}
{{ cell.text or '-' }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
new DataTable('#datatable-{{ id|default('table')|replace(' ', ' - ')|lower }}', {
pageLength: {{ per_page }},
scrollX: true,
scrollY: '60vh',
scrollCollapse: true,
})
})
</script>
{% else %}
<div class="container text-center">No data.</div>
{% endif %}
{% endmacro %}

View file

@ -0,0 +1,46 @@
<!-- templates/index.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container text-center">
<h1 class="display-4">Welcome to Inventory Manager</h1>
<p class="lead">Find out about all of your assets.</p>
<div class="row">
{% if active_worklog_rows %}
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Worklogs</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">You have {{ active_count }} active worklogs.</h6>
{{ tables.render_table(
headers = active_worklog_headers,
rows = active_worklog_rows,
id = 'Active Worklog',
entry_route = 'worklog_entry',
per_page = 10
)}}
</div>
</div>
</div>
{% endif %}
{% if (datasets[0]['values'] | sum) > 0 %}
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Summary</h5>
<div id="summary"></div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
const data = {{ datasets|tojson }};
const layout = { title: 'Summary' };
Plotly.newPlot('summary', data, layout)
{% endblock %}

View file

@ -0,0 +1,225 @@
<!-- templates/inventory.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ breadcrumbs.breadcrumb_header(
breadcrumbs=[
{'label': "Inventory", 'url': url_for('main.list_inventory')}
],
title=title,
save_button=True,
delete_button= item.id != None
) }}
<input type="hidden" id="inventoryId" value="{{ item.id }}">
<div class="container">
<div class="row">
<div class="col-6">
<label for="timestamp" class="form-label">Date Entered</label>
<input type="date" class="form-control" name="timestamp" value="{{ item.timestamp.date().isoformat() }}">
</div>
<div class="col-6">
<label for="identifier" class="form-label">Identifier</label>
<input type="text" class="form-control-plaintext" value="{{ item.identifier }}" readonly>
</div>
</div>
<div class="row">
<div class="col-4">
<label for="inventory_name" class="form-label">Inventory #</label>
<input type="text" class="form-control" name="inventory_name" placeholder="-"
value="{{ item.inventory_name or '' }}">
</div>
<div class="col-4">
<label for="serial" class="form-label">Serial #</label>
<input type="text" class="form-control" name="serial" placeholder="-"
value="{{ item.serial if item.serial else '' }}">
</div>
<div class="col-4">
<label for="barcode" class="form-label">Bar Code #</label>
<input type="text" class="form-control" name="barcode" placeholder="-"
value="{{ item.barcode if item.barcode else '' }}">
</div>
</div>
<div class="row">
<div class="col-4">
<label for="brand" class="form-label">Brand</label>
<select class="form-select" id="brand" name="brand">
<option>-</option>
{% for brand in brands %}
<option value="{{ brand.id }}" {% if brand.id==item.brand_id %} selected{% endif %}>{{ brand.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-4">
<label for="model" class="form-label">Model</label>
<input type="text" class="form-control" name="model" placeholder="-" value="{{ item.model if item.model else '' }}">
</div>
<div class="col-4">
<label for="type" class="form-label">Category</label>
<select name="type" id="type" class="form-select">
<option>-</option>
{% for t in types %}
<option value="{{ t.id }}" {% if t.id==item.type_id %} selected{% endif %}>{{ t.description }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<div class="col-4">
<label for="owner" class="form-label">
Contact
{% if item.owner %}
{{ links.entry_link('user', item.owner_id) }}
{% endif %}
</label>
<select class="form-select" id="userList">
<option>-</option>
{% for user in users %}
<option value="{{ user.id }}" {% if user.id==item.owner_id %} selected{% endif %}>{{ user.full_name
}}</option>
{% endfor %}
</select>
</div>
<div class="col-4">
<label for="location" class="form-label">Location</label>
<select class="form-select" id="room">
<option>-</option>
{% for room in rooms %}
<option value="{{ room.id }}" {% if room.id==item.location_id %} selected{% endif %}>{{
room.full_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-2">
<label for="condition" class="form-label">Condition</label>
<select name="condition" id="condition" class="form-select">
<option>-</option>
{% for condition in ["Working", "Deployed", "Partially Inoperable", "Inoperable", "Unverified",
"Removed", "Disposed"] %}
<option value="{{ condition }}" {% if item.condition==condition %} selected{% endif %}>{{ condition }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 d-flex align-items-center justify-content-center" style="margin-top: 1.9rem;">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="shared" name="shared" {% if item.shared %}checked{%
endif %}>
<label for="shared" class="form-check-label">Shared?</label>
</div>
</div>
</div>
<div class="row">
<div class="col">
<label for="notes" class="form-label">Notes &amp; Comments</label>
<textarea name="notes" id="notes" class="form-control"
rows="10">{{ item.notes if item.notes else '' }}</textarea>
</div>
{% if worklog %}
<div class="col">
{{ tables.render_table(headers=worklog_headers, rows=worklog_rows, id='worklog',
entry_route='worklog_entry', title='Work Log') }}
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
const saveButton = document.getElementById("saveButton");
const deleteButton = document.getElementById("deleteButton");
if (saveButton) {
saveButton.addEventListener("click", async (e) => {
e.preventDefault();
const payload = {
timestamp: document.querySelector("input[name='timestamp']").value,
condition: document.querySelector("select[name='condition']").value,
needed: "", // ← either add a field for this or drop it if obsolete
type_id: parseInt(document.querySelector("select[name='type']").value),
inventory_name: document.querySelector("input[name='inventory_name']").value || null,
serial: document.querySelector("input[name='serial']").value || null,
model: document.querySelector("input[name='model']").value || null,
notes: document.querySelector("textarea[name='notes']").value || null,
owner_id: parseInt(document.querySelector("select#userList").value) || null,
brand_id: parseInt(document.querySelector("select[name='brand']").value) || null,
location_id: parseInt(document.querySelector("select#room").value) || null,
barcode: document.querySelector("input[name='barcode']").value || null,
shared: document.querySelector("input[name='shared']").checked
};
try {
const id = document.querySelector("#inventoryId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/inventory/${id}` : "/api/inventory";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "Inventory item updated!" : "Inventory item created!",
type: "success"
}));
window.location.href = `/inventory_item/${result.id}`;
} else {
renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
renderToast({ message: `Error: ${err}`, type: "danger" });
}
});
}
if (deleteButton) {
deleteButton.addEventListener("click", async () => {
const id = document.querySelector("#inventoryId").value;
if (!id || id === "None") {
renderToast({ message: "No item ID found to delete.", type: "danger"} );
return;
}
if (!confirm("Are you sure you want to delete this inventory item? This action cannot be undone.")) {
return;
}
try {
const response = await fetch(`/api/inventory/${id}`, {
method: "DELETE"
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: "Inventory item deleted.",
type: "success"
}));
window.location.href = "/inventory";
} else {
renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
renderToast({ message: `Error: ${err}`, type: "danger" });
}
});
}
{% endblock %}

View file

@ -0,0 +1,36 @@
<!-- templates/inventory_index.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ breadcrumbs.breadcrumb_header(
title=title
) }}
<div class="container">
{% if not category %}
<div class="row">
<div class="col">
<h2 class="display-6 text-center mt-5">Browse</h2>
</div>
</div>
<div class="row text-center">
{{ links.category_link(endpoint = 'list_inventory', label = "Full Listing", icon_html = icons.render_icon('table', 32)) }}
{{ links.category_link(endpoint = 'inventory_index', label = "By User", icon_html = icons.render_icon('person', 32), arguments = {'category': 'user'}) }}
{{ links.category_link(endpoint = 'inventory_index', label = 'By Location', icon_html = icons.render_icon('map', 32), arguments = {'category': 'location'}) }}
{{ links.category_link(endpoint = 'inventory_index', label = 'By Type', icon_html = icons.render_icon('motherboard', 32), arguments = {'category': 'type'}) }}
</div>
{% else %}
<div class="container">
{% for line in listing %}
<div class="row my-3">
{% for id, name in line %}
{{ links.category_link(endpoint = 'list_inventory', label = name, arguments = {'filter_by': category, 'id': id}) }}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,94 @@
{% import "fragments/_breadcrumb_fragment.html" as breadcrumbs %}
{% import "fragments/_combobox_fragment.html" as combos %}
{% import "fragments/_icon_fragment.html" as icons %}
{% import "fragments/_link_fragment.html" as links %}
{% import "fragments/_table_fragment.html" as tables %}
<!-- templates/layout.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inventory Manager{% if title %} - {% endif %}{% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
<link
href="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/dt-2.3.2/b-3.2.3/b-colvis-3.2.3/b-print-3.2.3/cr-2.1.1/cc-1.0.4/r-3.0.4/rg-1.5.1/rr-1.5.0/sc-2.4.3/sr-1.4.1/datatables.min.css"
rel="stylesheet" integrity="sha384-gdnBcErvPbrURVoR9w3NhVMliw+ZmcTCmq+64xj2Ksx21nRJFX3qW0zFvBotL5rm"
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/widget.css') }}">
<style>
{% block style %}
{% endblock %}
</style>
</head>
<body class="bg-tertiary text-primary-emphasis">
<nav class="navbar navbar-expand bg-body-tertiary border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
Inventory Manager
</a>
<button class="navbar-toggler">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{{ links.navigation_link(endpoint = 'inventory_index', label = 'Inventory') }}
{{ links.navigation_link(endpoint = 'list_users', label = 'Users') }}
{{ links.navigation_link(endpoint = 'list_worklog', label = 'Worklog') }}
</ul>
<form class="d-flex" method="GET" action="{{ url_for('main.search') }}">
<input type="text" class="form-control me-2" placeholder="Search" name="q" id="search" />
<button class="btn btn-primary" type="submit" id="searchButton" disabled>Search</button>
</form>
<ul class="navbar-nav ms-2">
{{ links.navigation_link(endpoint='settings', label = '', icon_html = icons.render_icon('gear')) }}
</ul>
</div>
</div>
</nav>
<main class="container-flex m-5">
{% block content %}{% endblock %}
</main>
<!-- Toast Container -->
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js"
integrity="sha512-5CYOlHXGh6QpOFA/TeTylKLWfB3ftPsde7AnmhuitiTX4K5SqCLBeKro6sPS8ilsz1Q4NRx3v8Ko2IBiszzdww=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
<script
src="https://cdn.datatables.net/v/bs5/jq-3.7.0/moment-2.29.4/dt-2.3.2/b-3.2.3/b-colvis-3.2.3/b-print-3.2.3/cr-2.1.1/cc-1.0.4/r-3.0.4/rg-1.5.1/rr-1.5.0/sc-2.4.3/sr-1.4.1/datatables.min.js"
integrity="sha384-tNYRX2RiDDDRKCJgPF8Pw3rTxC1GUe1pt5qH1SBmwcazrEUj7Ii4C1Tz9wCCRUI4"
crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='js/widget.js') }}"></script>
<script>
const searchInput = document.querySelector('#search');
const searchButton = document.querySelector('#searchButton');
searchInput.addEventListener('input', () => {
searchButton.disabled = searchInput.value.trim() === '';
});
document.addEventListener("DOMContentLoaded", () => {
const toastData = localStorage.getItem("toastMessage");
if (toastData) {
const { message, type } = JSON.parse(toastData);
renderToast({ message, type });
localStorage.removeItem("toastMessage");
}
{% block script %} {% endblock %}
});
</script>
</body>
</html>

View file

@ -0,0 +1,60 @@
<!-- templates/search.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ breadcrumbs.breadcrumb_header(
title=title,
) }}
<div class="container">
{% if results['inventory']['rows'] %}
<div>
{{ tables.render_table(
headers = results['inventory']['headers'],
rows = results['inventory']['rows'],
entry_route = 'inventory_item',
title='Inventory Results',
id='search-inventory'
)}}
</div>
{% endif %}
{% if results['users']['rows'] %}
<div>
{{ tables.render_table(
headers = results['users']['headers'],
rows = results['users']['rows'],
entry_route = 'user',
title='User Results',
id='search-users'
)}}
</div>
{% endif %}
{% if results['worklog']['rows'] %}
<div>
{{ tables.render_table(
headers = results['worklog']['headers'],
rows = results['worklog']['rows'],
entry_route = 'worklog_entry',
title='Worklog Results',
id='search-worklog'
)}}
</div>
{% endif %}
{% if not results['inventory']['rows'] and not results['users']['rows'] and not results['worklog']['rows'] %}
<div>There are no results for "{{ query }}".</div>
{% endif %}
</div>
{% endblock %}
{% block script %}
{% if query and (results['inventory']['rows'] or results['users']['rows'] or results['worklog']['rows']) %}
const query = "{{ query|e }}";
if (query) {
const instance = new Mark(document.querySelector("main"));
instance.mark(query);
}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,313 @@
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<form id="settingsForm">
{{ breadcrumbs.breadcrumb_header(
title=title,
save_button=True
) }}
<div class="container">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">
Inventory Settings
</h5>
<div class="row">
<div class="col">
{{ combos.render_combobox(
id='brand',
options=brands,
label='Brands',
placeholder='Add a new brand'
) }}
</div>
<div class="col">
{{ combos.render_combobox(
id='type',
options=types,
label='Inventory Types',
placeholder='Add a new type'
) }}
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Location Settings</h5>
<div class="row">
<div class="col">
{{ combos.render_combobox(
id='section',
options=sections,
label='Sections',
placeholder='Add a new section'
) }}
</div>
<div class="col">
{{ combos.render_combobox(
id='function',
options=functions,
label='Functions',
placeholder='Add a new function'
) }}
</div>
</div>
<div class="row">
{% set room_editor %}
const roomEditorModal = new bootstrap.Modal(document.getElementById('roomEditor'));
const input = document.getElementById('room-input');
const name = input.value.trim();
const existingOption = Array.from(document.getElementById('room-list').options)
.find(opt => opt.textContent.trim() === name);
const event = new CustomEvent('roomEditor:prepare', {
detail: {
id: existingOption?.value ?? '',
name: name,
sectionId: existingOption?.dataset.sectionId ?? '',
functionId: existingOption?.dataset.functionId ?? ''
}
});
document.getElementById('roomEditor').dispatchEvent(event);
roomEditorModal.show();
document.getElementById('room-input').value = '';
{% endset %}
<div class="col">
{{ combos.render_combobox(
id='room',
options=rooms,
label='Rooms',
placeholder='Add a new room',
onAdd=room_editor,
onEdit=room_editor,
data_attributes={'area_id': 'section-id', 'function_id': 'function-id'}
) }}
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="roomEditor" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="roomEditorLabel">Room Editor</h1>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<label for="roomName" class="form-label">Room Name</label>
<input type="text" class="form-input" id="roomName" placeholder="Enter room name">
<input type="hidden" id="roomId">
</div>
</div>
<div class="row">
<div class="col">
<label for="roomSection" class="form-label">Section</label>
<select id="roomSection" class="form-select">
<option value="">Select a section</option>
{% for section in sections %}
<option value="{{ section.id }}">{{ section.name }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Function</label>
<select id="roomFunction" class="form-select">
<option value="">Select a function</option>
{% for function in functions %}
<option value="{{ function.id }}">{{ function.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
id="roomEditorCancelButton">Cancel</button>
<button type="button" class="btn btn-primary" id="editorSaveButton">Save</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
{% block script %}
function isSerializable(obj) {
try {
JSON.stringify(obj);
return true;
} catch {
return false;
}
}
function buildFormState() {
function extractOptions(id) {
const select = document.getElementById(`${id}-list`);
return Array.from(select.options).map(opt => {
const name = opt.textContent.trim();
const rawId = opt.value?.trim();
return {
name,
...(rawId ? { id: /^\d+$/.test(rawId) ? parseInt(rawId, 10) : rawId } : {})
};
});
}
function sanitizeFk(val) {
if (val && val !== "null" && val !== "" && val !== "None") {
return /^\d+$/.test(val) ? parseInt(val, 10) : val;
}
return null;
}
const roomOptions = Array.from(document.getElementById('room-list').options);
const rooms = roomOptions.map(opt => {
const id = opt.value?.trim();
const name = opt.textContent.trim();
const sectionId = sanitizeFk(opt.dataset.sectionId);
const functionId = sanitizeFk(opt.dataset.functionId);
const result = {
name,
...(id ? { id } : {}),
section_id: sectionId,
function_id: functionId
};
return result;
});
return {
brands: extractOptions("brand"),
types: extractOptions("type"),
sections: extractOptions("section"),
functions: extractOptions("function"),
rooms
};
}
const modal = document.getElementById('roomEditor');
const saveButton = document.getElementById('saveButton')
const editorSaveButton = document.getElementById('editorSaveButton');
const cancelButton = document.getElementById('roomEditorCancelButton');
const form = document.getElementById('settingsForm');
modal.addEventListener('roomEditor:prepare', (event) => {
const { id, name, sectionId, functionId } = event.detail;
document.getElementById('roomId').value = id;
document.getElementById('roomName').value = name;
// Populate dropdowns before assigning selection
const modalSections = document.getElementById('roomSection');
const modalFunctions = document.getElementById('roomFunction');
const pageSections = document.getElementById('section-list');
const pageFunctions = document.getElementById('function-list');
modalSections.innerHTML = '<option value="">Select a section</option>';
modalFunctions.innerHTML = '<option value="">Select a function</option>';
Array.from(pageSections.options).forEach(opt => {
const option = new Option(opt.textContent, opt.value);
if (opt.value === sectionId) {
option.selected = true;
}
modalSections.appendChild(option);
});
Array.from(pageFunctions.options).forEach(opt => {
const option = new Option(opt.textContent, opt.value);
if (opt.value === functionId) {
option.selected = true;
}
modalFunctions.appendChild(option);
});
});
saveButton.addEventListener('click', async (event) => {
event.preventDefault();
console.log("Test")
const state = buildFormState();
if (!isSerializable(state)) {
console.warn("🚨 Payload is not serializable:", state);
alert("Invalid payload — check console.");
return;
}
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
const contentType = response.headers.get("content-type");
if (!response.ok) {
if (contentType && contentType.includes("application/json")) {
const data = await response.json();
throw new Error(data.errors?.join("\n") || "Unknown error");
} else {
const text = await response.text();
throw new Error("Unexpected response:\n" + text.slice(0, 200));
}
}
const data = await response.json();
console.log("Sync result:", data);
renderToast({ message: 'Settings updated successfully.', type: 'success' });
} catch (err) {
console.error("Submission error:", err);
renderToast({ message: `Failed to update settings, ${err}`, type: 'danger' });
}
});
editorSaveButton.addEventListener('click', () => {
const name = document.getElementById('roomName').value.trim();
const sectionVal = document.getElementById('roomSection').value;
const funcVal = document.getElementById('roomFunction').value;
let idRaw = document.getElementById('roomId').value;
if (!name) {
alert('Please enter a room name.');
return;
}
const roomList = document.getElementById('room-list');
let existingOption = Array.from(roomList.options).find(opt => opt.value === idRaw);
if (!idRaw) {
idRaw = ComboBoxWidget.createTempId("room");
}
if (!existingOption) {
existingOption = ComboBoxWidget.createOption(name, idRaw);
roomList.appendChild(existingOption);
}
existingOption.textContent = name;
existingOption.value = idRaw;
existingOption.dataset.sectionId = sectionVal || "";
existingOption.dataset.functionId = funcVal || "";
ComboBoxWidget.sortOptions(roomList);
bootstrap.Modal.getInstance(modal).hide();
});
cancelButton.addEventListener('click', () => {
bootstrap.Modal.getInstance(modal).hide();
});
{% endblock %}

View file

@ -0,0 +1,23 @@
<!-- templates/table.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ breadcrumbs.breadcrumb_header(
title=title,
breadcrumbs=breadcrumb,
create_button=True
) }}
{{ tables.render_table(headers=header, rows=rows, id='table', entry_route=entry_route) }}
{% endblock %}
{% block script %}
createButton = document.getElementById("createButton");
createButton.addEventListener("click", async () => {
window.location.href = "/{{ entry_route }}/new";
})
{% endblock %}

View file

@ -0,0 +1,142 @@
<!-- templates/user.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ breadcrumbs.breadcrumb_header(
title=title,
breadcrumbs=[
{'label': 'Users', 'url': url_for('main.list_users')}
],
save_button = True
) }}
{% if not user.active %}
<div class="alert alert-danger">This user is inactive. You will not be able to make any changes to this record.</div>
{% endif %}
<input type="hidden" id="userId" value="{{ user.id }}">
<div class="container">
<form action="POST">
<div class="row">
<div class="col-6">
<label for="lastName" class="form-label">Last Name</label>
<input type="text" class="form-control" id="lastName" name="lastName" placeholder="Doe" value="{{ user.last_name if user.last_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
</div>
<div class="col-6">
<label for="firstName" class="form-label">First Name</label>
<input type="text" class="form-control" id="firstName" name="firstName" placeholder="John" value="{{ user.first_name if user.first_name else '' }}"{% if not user.active %} disabled readonly{% endif %}>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<label for="supervisor" class="form-label">
Supervisor
{% if user.supervisor %}
{{ links.entry_link('user', user.supervisor_id) }}
{% endif %}
</label>
<select class="form-select" id="supervisor" name="supervisor"
value="{{ supervisor.id if supervisor else '' }}"{% if not user.active %} disabled readonly{% endif %}>
<option>-</option>
{% for supervisor in users %}
<option value="{{ supervisor.id }}"{% if supervisor.id==user.supervisor_id %} selected{% endif %}>
{{ supervisor.full_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6">
<label for="location" class="form-label">Location</label>
<select class="form-select" id="location" name="location"{% if not user.active %} disabled readonly{% endif %}>
<option>-</option>
{% for location in rooms %}
<option value="{{ location.id }}"{% if location.id==user.location_id %} selected{% endif %}>{{
location.full_name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mt-4">
<div class="col-6">
<input type="checkbox" class="form-check-input" id="activeCheck" name="activeCheck"{% if user.active %} checked{% endif %}>
<label for="activeCheck" class="form-check-label">Active</label>
</div>
<div class="col-6">
<input type="checkbox" class="form-check-input" id="staffCheck" name="staffCheck"{% if user.staff %} checked{% endif %}{% if not user.active %} disabled readonly{% endif %}>
<label for="staffCheck" class="form-check-label">Staff</label>
</div>
</div>
</form>
<div class="row mt-3">
{% if inventory_rows %}
<div class="col">
<div class="row">
{{ tables.render_table(headers=inventory_headers, rows=inventory_rows, id='assets', entry_route='inventory_item', title='Assets', per_page=8) }}
</div>
</div>
{% endif %}
{% if worklog_rows %}
<div class="col">
<div class="row">
{{ tables.render_table(headers=worklog_headers, rows=worklog_rows, id='worklog', entry_route='worklog_entry', title='Work Done', per_page=8) }}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block script %}
const saveButton = document.getElementById("saveButton");
const deleteButton = document.getElementById("deleteButton");
if (saveButton) {
saveButton.addEventListener("click", async (e) => {
e.preventDefault();
const payload = {
staff: document.querySelector("input[name='staffCheck']").checked,
active: document.querySelector("input[name='activeCheck']").checked,
last_name: document.querySelector("input[name='lastName']").value,
first_name: document.querySelector("input[name='firstName']").value,
supervisor_id: parseInt(document.querySelector("select[name='supervisor']").value) || null,
location_id: parseInt(document.querySelector("select[name='location']").value) || null
};
try {
const id = document.querySelector("#userId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/user/${id}` : "/api/user";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "User updated!" : "User created!",
type: "success"
}));
window.location.href = `/user/${result.id}`;
} else {
renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err);
}
});
}
{% endblock %}

View file

@ -0,0 +1,190 @@
<!-- templates/worklog.html -->
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<nav>
{{ breadcrumbs.breadcrumb_header(
breadcrumbs=[
{'label': 'Work Log', 'url': url_for('main.list_worklog')}
],
title=title,
save_button=True,
delete_button=log.id != None
) }}
<input type="hidden" id="logId" value="{{ log.id }}">
<div class="container">
<div class="row">
<div class="col-6">
<label for="start" class="form-label">Start Timestamp</label>
<input type="date" class="form-control" name="start" placeholder="-"
value="{{ log.start_time.date().isoformat() if log.start_time }}">
</div>
<div class="col-6">
<label for="end" class="form-label">End Timestamp</label>
<input type="date" class="form-control" name="end" placeholder="-"
value="{{ log.end_time.date().isoformat() if log.end_time }}">
</div>
</div>
<div class="row">
<div class="col-4">
<label for="contact" class="form-label">
Contact
{% if log.contact_id %}
{{ links.entry_link('user', log.contact_id) }}
{% endif %}
</label>
<select class="form-select" name="contact" id="contact">
<option value="">-</option>
{% for contact in users %}
<option value="{{ contact.id }}"{% if contact.id == log.contact_id %} selected{% endif %}>{{ contact.full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-4">
<label for="item" class="form-label">
Work Item
{% if log.work_item_id %}
{{ links.entry_link('inventory_item', log.work_item_id) }}
{% endif %}
</label>
<select id="item" name="item" class="form-select">
<option value="">-</option>
{% for item in items %}
<option value="{{ item.id }}"{% if item.id == log.work_item_id %} selected{% endif %}>{{ item.identifier }}</option>
{% endfor %}
</select>
</div>
<div class="col-4">
<div class="row">
<div class="col">
<input type="checkbox" id="complete" class="form-check-input" name="complete"{% if log.complete %} checked{%
endif %}>
<label for="complete" class="form-check-label">
Complete?
</label>
</div>
</div>
<div class="row">
<div class="col">
<input type="checkbox" id="followup" class="form-check-input" name="followup"{% if log.followup %} checked{%
endif %}>
<label for="followup" class="form-check-label">
Follow Up?
</label>
</div>
</div>
<div class="row">
<div class="col">
<input type="checkbox" id="analysis" class="form-check-input" name="analysis"{% if log.analysis %} checked{%
endif %}>
<label for="analysis" class="form-check-label">
Quick Analysis?
</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<label for="notes" class="form-label">Notes</label>
<textarea name="notes" id="notes" class="form-control"
rows="15">{{ log.notes if log.notes else '' }}</textarea>
</div>
</div>
</div>
</nav>
{% endblock %}
{% block script %}
const saveButton = document.getElementById("saveButton");
const deleteButton = document.getElementById("deleteButton");
if (saveButton) {
saveButton.addEventListener("click", async (e) => {
e.preventDefault();
const payload = {
start_time: document.querySelector("input[name='start']").value,
end_time: document.querySelector("input[name='end']").value,
notes: document.querySelector("textarea[name='notes']").value,
complete: document.querySelector("input[name='complete']").checked,
analysis: document.querySelector("input[name='analysis']").checked,
followup: document.querySelector("input[name='followup']").checked,
contact_id: document.querySelector("select[name='contact']").value || null,
work_item_id: document.querySelector("select[name='item']").value || null,
};
try {
const id = document.querySelector("#logId").value;
const isEdit = id && id !== "None";
const endpoint = isEdit ? `/api/worklog/${id}` : "/api/worklog";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(endpoint, {
method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: isEdit ? "Work Log entry updated!" : "Work Log entry created!",
type: "success"
}));
window.location.href = `/worklog/${result.id}`;
} else {
renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.error(err)
renderToast({ message: `Error: ${err}`, type: "danger" });
}
});
}
if (deleteButton) {
deleteButton.addEventListener("click", async () => {
const id = document.querySelector("#logId").value;
if (!id || id === "None") {
renderToast({ message: "No item ID found to delete.", type: "danger" });
return;
}
if (!confirm("Are you sure you want to delete this work log entry? This action cannot be undone.")) {
return;
}
try {
const response = await fetch(`/api/worklog/${id}`, {
method: "DELETE"
});
const result = await response.json();
if (result.success) {
localStorage.setItem("toastMessage", JSON.stringify({
message: "Work log entry deleted.",
type: "success"
}));
window.location.href = "/worklog";
} else {
renderToast({ message: `Error: ${result.error}`, type: "danger" });
}
} catch (err) {
console.log(err);
renderToast({ message: `Error: ${err}`, type: "danger" });
}
});
}
{% endblock %}

View file

46
inventory/utils/load.py Normal file
View file

@ -0,0 +1,46 @@
from sqlalchemy.orm import joinedload, selectinload
from ..models import User, Room, Inventory, WorkLog
from .. import db
def eager_load_user_relationships(query):
return query.options(
joinedload(User.supervisor),
joinedload(User.location).joinedload(Room.room_function)
)
def eager_load_inventory_relationships(query):
return query.options(
joinedload(Inventory.owner),
joinedload(Inventory.brand),
joinedload(Inventory.item),
selectinload(Inventory.location).selectinload(Room.room_function)
)
def eager_load_room_relationships(query):
return query.options(
joinedload(Room.area),
joinedload(Room.room_function),
selectinload(Room.inventory),
selectinload(Room.users)
)
def eager_load_worklog_relationships(query):
return query.options(
joinedload(WorkLog.contact),
joinedload(WorkLog.work_item)
)
def chunk_list(lst, chunk_size):
return [lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)]
def add_named_entities(items: list[str], model, attr: str, mapper: dict | None = None):
for name in items:
clean = name.strip()
if clean:
print(f"Creating new {attr}: {clean}")
new_obj = model(**{attr: clean})
db.session.add(new_obj)
if mapper is not None:
db.session.flush()
mapper[clean] = new_obj.id
print(f"New {attr} '{clean}' added with ID {new_obj.id}")

View file

@ -0,0 +1,28 @@
from ..temp import is_temp_id
class ValidatableMixin:
VALIDATION_LABEL = "entry"
@classmethod
def validate_state(cls, submitted_items: list[dict]) -> list[str]:
errors = []
label = cls.VALIDATION_LABEL or cls.__name__
for index, item in enumerate(submitted_items):
if not isinstance(item, dict):
errors.append(f"{label.capitalize()} #{index + 1} is not a valid object.")
continue
name = item.get("name")
if not name or not str(name).strip():
errors.append(f"{label.capitalize()} #{index + 1} is missing a name.")
raw_id = item.get('id')
if raw_id is not None:
try:
_ = int(raw_id)
except (ValueError, TypeError):
if not is_temp_id(raw_id):
errors.append(f"{label.capitalize()} #{index + 1} has invalid ID: {raw_id}")
return errors