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:
parent
602bb77e22
commit
9803db17ab
51 changed files with 76 additions and 16 deletions
32
inventory/__init__.py
Normal file
32
inventory/__init__.py
Normal 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
6
inventory/app.py
Normal 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
66
inventory/config.py
Normal 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}")
|
||||
24
inventory/models/__init__.py
Normal file
24
inventory/models/__init__.py
Normal 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
123
inventory/models/areas.py
Normal 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
115
inventory/models/brands.py
Normal 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
|
||||
128
inventory/models/inventory.py
Normal file
128
inventory/models/inventory.py
Normal 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
116
inventory/models/items.py
Normal 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
|
||||
90
inventory/models/room_functions.py
Normal file
90
inventory/models/room_functions.py
Normal 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
199
inventory/models/rooms.py
Normal 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
68
inventory/models/users.py
Normal 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")
|
||||
)
|
||||
76
inventory/models/work_log.py
Normal file
76
inventory/models/work_log.py
Normal 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")
|
||||
)
|
||||
5
inventory/routes/__init__.py
Normal file
5
inventory/routes/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
from . import inventory, user, worklog, settings, index, search, hooks
|
||||
65
inventory/routes/helpers.py
Normal file
65
inventory/routes/helpers.py
Normal 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
18
inventory/routes/hooks.py
Normal 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
86
inventory/routes/index.py
Normal 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
|
||||
)
|
||||
207
inventory/routes/inventory.py
Normal file
207
inventory/routes/inventory.py
Normal 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
|
||||
|
||||
68
inventory/routes/search.py
Normal file
68
inventory/routes/search.py
Normal 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)
|
||||
107
inventory/routes/settings.py
Normal file
107
inventory/routes/settings.py
Normal 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
123
inventory/routes/user.py
Normal 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
131
inventory/routes/worklog.py
Normal 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
|
||||
20
inventory/static/css/widget.css
Normal file
20
inventory/static/css/widget.css
Normal 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; /* Bootstrap’s 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;
|
||||
}
|
||||
184
inventory/static/js/widget.js
Normal file
184
inventory/static/js/widget.js
Normal 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
6
inventory/temp.py
Normal 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-"))
|
||||
)
|
||||
9
inventory/templates/error.html
Normal file
9
inventory/templates/error.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends 'layout.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-danger text-center">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
inventory/templates/fragments/_breadcrumb_fragment.html
Normal file
44
inventory/templates/fragments/_breadcrumb_fragment.html
Normal 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 %}
|
||||
44
inventory/templates/fragments/_combobox_fragment.html
Normal file
44
inventory/templates/fragments/_combobox_fragment.html
Normal 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 %}
|
||||
6
inventory/templates/fragments/_icon_fragment.html
Normal file
6
inventory/templates/fragments/_icon_fragment.html
Normal 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 %}
|
||||
40
inventory/templates/fragments/_link_fragment.html
Normal file
40
inventory/templates/fragments/_link_fragment.html
Normal 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 %}
|
||||
52
inventory/templates/fragments/_table_fragment.html
Normal file
52
inventory/templates/fragments/_table_fragment.html
Normal 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 %}
|
||||
46
inventory/templates/index.html
Normal file
46
inventory/templates/index.html
Normal 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 %}
|
||||
225
inventory/templates/inventory.html
Normal file
225
inventory/templates/inventory.html
Normal 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 & 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 %}
|
||||
36
inventory/templates/inventory_index.html
Normal file
36
inventory/templates/inventory_index.html
Normal 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 %}
|
||||
94
inventory/templates/layout.html
Normal file
94
inventory/templates/layout.html
Normal 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>
|
||||
60
inventory/templates/search.html
Normal file
60
inventory/templates/search.html
Normal 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 %}
|
||||
313
inventory/templates/settings.html
Normal file
313
inventory/templates/settings.html
Normal 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 %}
|
||||
23
inventory/templates/table.html
Normal file
23
inventory/templates/table.html
Normal 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 %}
|
||||
142
inventory/templates/user.html
Normal file
142
inventory/templates/user.html
Normal 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 %}
|
||||
190
inventory/templates/worklog.html
Normal file
190
inventory/templates/worklog.html
Normal 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 %}
|
||||
0
inventory/utils/__init__.py
Normal file
0
inventory/utils/__init__.py
Normal file
46
inventory/utils/load.py
Normal file
46
inventory/utils/load.py
Normal 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}")
|
||||
28
inventory/utils/validation.py
Normal file
28
inventory/utils/validation.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue