diff --git a/.gitignore b/.gitignore
index 28c8309..bdc1a8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-__pycache__/
+**/__pycache__/
.venv/
.env
*.db
diff --git a/models/areas.py b/models/areas.py
index a3ebfe9..abb0906 100644
--- a/models/areas.py
+++ b/models/areas.py
@@ -17,3 +17,9 @@ class Area(db.Model):
def __repr__(self):
return f""
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name
+ }
diff --git a/models/brands.py b/models/brands.py
index 168894e..6eb5bf2 100644
--- a/models/brands.py
+++ b/models/brands.py
@@ -16,4 +16,10 @@ class Brand(db.Model):
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='brand')
def __repr__(self):
- return f""
\ No newline at end of file
+ return f""
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name
+ }
\ No newline at end of file
diff --git a/models/inventory.py b/models/inventory.py
index febd8e6..f487eb1 100644
--- a/models/inventory.py
+++ b/models/inventory.py
@@ -69,3 +69,21 @@ class Inventory(db.Model):
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
+ }
diff --git a/models/items.py b/models/items.py
index 0e0ae0e..447bee4 100644
--- a/models/items.py
+++ b/models/items.py
@@ -17,4 +17,11 @@ class Item(db.Model):
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='item')
def __repr__(self):
- return f"- "
\ No newline at end of file
+ return f"
- "
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.description,
+ 'category': self.category
+ }
\ No newline at end of file
diff --git a/models/room_functions.py b/models/room_functions.py
index 8683174..e52b2f6 100644
--- a/models/room_functions.py
+++ b/models/room_functions.py
@@ -16,4 +16,10 @@ class RoomFunction(db.Model):
rooms: Mapped[List['Room']] = relationship('Room', back_populates='room_function')
def __repr__(self):
- return f""
\ No newline at end of file
+ return f""
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.description
+ }
\ No newline at end of file
diff --git a/models/rooms.py b/models/rooms.py
index 9323535..8af94b0 100644
--- a/models/rooms.py
+++ b/models/rooms.py
@@ -31,4 +31,12 @@ class Room(db.Model):
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
+ }
diff --git a/models/users.py b/models/users.py
index 7df3c17..8c8df85 100644
--- a/models/users.py
+++ b/models/users.py
@@ -34,3 +34,14 @@ class User(db.Model):
def __repr__(self):
return f""
+
+ 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
+ }
diff --git a/models/work_log.py b/models/work_log.py
index 74db981..ef6ff31 100644
--- a/models/work_log.py
+++ b/models/work_log.py
@@ -32,3 +32,16 @@ class WorkLog(db.Model):
return f""
+
+ 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
+ }
\ No newline at end of file
diff --git a/routes.py b/routes.py
index 6d7697f..795b6f4 100644
--- a/routes.py
+++ b/routes.py
@@ -1,12 +1,10 @@
from flask import Blueprint, render_template, url_for, request, redirect, flash
from flask import current_app as app
from .models import Brand, Item, Inventory, RoomFunction, User, WorkLog, Room, Area
-from sqlalchemy import or_
+from sqlalchemy import or_, delete
from sqlalchemy.orm import aliased
-from typing import Callable, Any, List
from . import db
-from .utils import eager_load_user_relationships, eager_load_inventory_relationships, eager_load_room_relationships, eager_load_worklog_relationships, chunk_list
-from datetime import datetime, timedelta
+from .utils import eager_load_user_relationships, eager_load_inventory_relationships, eager_load_room_relationships, eager_load_worklog_relationships, chunk_list, add_named_entities
import pandas as pd
import traceback
import json
@@ -400,6 +398,37 @@ def search():
@main.route('/settings', methods=['GET', 'POST'])
def settings():
+ def add_named_entities(items: list[str], model, attr: str, mapper: dict | None = None):
+ for name in items:
+ clean = name.strip()
+ if clean:
+ new_obj = model(**{attr: clean}) # type: ignore
+ db.session.add(new_obj)
+ if mapper is not None:
+ db.session.flush()
+ mapper[clean] = new_obj.id
+
+ def resolve_id(raw_id, fallback_list, id_map, label):
+ try:
+ resolved_id = int(raw_id)
+ except (TypeError, ValueError):
+ resolved_id = None
+
+ if resolved_id is not None and resolved_id >= 0:
+ return resolved_id
+
+ index = abs(resolved_id + 1) if resolved_id is not None else 0
+ try:
+ entry = fallback_list[index]
+ key = entry["name"] if isinstance(entry, dict) else str(entry).strip()
+ except (IndexError, KeyError, TypeError):
+ raise ValueError(f"Invalid {label} index or format at index {index}: {entry}")
+
+ final_id = id_map.get(key)
+ if not final_id:
+ raise ValueError(f"Unresolved {label}: {key}")
+ return final_id
+
if request.method == 'POST':
print("⚠️⚠️⚠️ POST /settings reached! ⚠️⚠️⚠️")
form = request.form
@@ -407,108 +436,124 @@ def settings():
try:
state = json.loads(form['formState'])
- except Exception as e:
+ except Exception:
flash("Invalid form state submitted. JSON decode failed.", "danger")
traceback.print_exc()
return redirect(url_for('main.settings'))
-
- # === 0. Create new Brands ===
- for name in state.get('brands', []):
- clean = name.strip()
- if clean:
- print(f"🧪 Creating new brand: {clean}")
- new_brand = Brand(name=clean) # type: ignore
- db.session.add(new_brand)
- # === 0.5 Create new Types ===
- for name in state.get('types', []):
- clean = name.strip()
- if clean:
- print(f"🧪 Creating new item type: {clean}")
- new_type = Item(description=clean) # type: ignore
- db.session.add(new_type)
-
- # === 1. Create new Sections ===
- section_map = {}
- for name in state.get('sections', []):
- clean = name.strip()
- if clean:
- print(f"🧪 Creating new section: {clean}")
- new_section = Area(name=clean) # type: ignore
- db.session.add(new_section)
- db.session.flush()
- section_map[clean] = new_section.id
- print(f"✅ New section '{clean}' ID: {new_section.id}")
-
- # === 2. Create new Functions ===
- function_map = {}
- for name in state.get('functions', []):
- clean = name.strip()
- if clean:
- print(f"🧪 Creating new function: {clean}")
- new_function = RoomFunction(description=clean) # type: ignore
- db.session.add(new_function)
- db.session.flush()
- function_map[clean] = new_function.id
- print(f"✅ New function '{clean}' ID: {new_function.id}")
-
- # === 3. Create new Rooms ===
- for idx, room in enumerate(state.get('rooms', [])):
- name = room.get('name', '').strip()
- raw_section = room.get('section_id')
- raw_function = room.get('function_id')
-
- if not name:
- print(f"⚠️ Skipping room at index {idx} due to missing name.")
- continue
-
- try:
- section_id = int(raw_section) if raw_section is not None else None
- function_id = int(raw_function) if raw_function is not None else None
-
- # Resolve negative or unmapped IDs
- if section_id is None or section_id < 0:
- section_idx = abs(section_id + 1) if section_id is not None else 0
- section_name = state['sections'][section_idx]
- section_id = section_map.get(section_name)
- if not section_id:
- raise ValueError(f"Unresolved section: {section_name}")
-
- if function_id is None or function_id < 0:
- function_idx = abs(function_id + 1) if function_id is not None else 0
- function_name = state['functions'][function_idx]
- function_id = function_map.get(function_name)
- if not function_id:
- raise ValueError(f"Unresolved function: {function_name}")
-
- print(f"🏗️ Creating room '{name}' with section ID {section_id} and function ID {function_id}")
- new_room = Room(name=name, area_id=section_id, function_id=function_id) # type: ignore
- db.session.add(new_room)
-
- except Exception as e:
- print(f"❌ Failed to process room at index {idx}: {e}")
- traceback.print_exc()
- flash(f"Error processing room '{name}': {e}", "danger")
-
-
- # === 4. Commit changes ===
try:
- print("🚀 Attempting commit...")
- db.session.commit()
- print("✅ Commit succeeded.")
+ with db.session.begin():
+ # === BRANDS ===
+ existing_brands = {b.name for b in db.session.query(Brand).all()}
+ submitted_brands = {
+ str(b.get("name", "")).strip()
+ for b in state.get("brands", [])
+ if isinstance(b, dict) and str(b.get("name", "")).strip()
+ }
+ print(f"🔍 Brands in DB: {existing_brands}")
+ print(f"🆕 Brands submitted: {submitted_brands}")
+ print(f"➖ Brands to delete: {existing_brands - submitted_brands}")
+ for name in submitted_brands - existing_brands:
+ db.session.add(Brand(name=name))
+ for name in existing_brands - submitted_brands:
+ print(f"🗑️ Deleting brand: {name}")
+ db.session.execute(delete(Brand).where(Brand.name == name))
+
+ # === TYPES ===
+ existing_types = {i.description for i in db.session.query(Item).all()}
+ submitted_types = {
+ str(t.get("name") or t.get("description") or "").strip()
+ for t in state.get("types", [])
+ if isinstance(t, dict) and str(t.get("name") or t.get("description") or "").strip()
+ }
+ print(f"🔍 Types in DB: {existing_types}")
+ print(f"🆕 Types submitted: {submitted_types}")
+ print(f"➖ Types to delete: {existing_types - submitted_types}")
+ for desc in submitted_types - existing_types:
+ db.session.add(Item(description=desc))
+ for desc in existing_types - submitted_types:
+ print(f"🗑️ Deleting type: {desc}")
+ db.session.execute(delete(Item).where(Item.description == desc))
+
+ # === SECTIONS ===
+ existing_sections = {a.name for a in db.session.query(Area).all()}
+ submitted_sections = {
+ str(s.get("name", "")).strip()
+ for s in state.get("sections", [])
+ if isinstance(s, dict) and str(s.get("name", "")).strip()
+ }
+ print(f"🔍 Sections in DB: {existing_sections}")
+ print(f"🆕 Sections submitted: {submitted_sections}")
+ print(f"➖ Sections to delete: {existing_sections - submitted_sections}")
+ for name in submitted_sections - existing_sections:
+ db.session.add(Area(name=name))
+ for name in existing_sections - submitted_sections:
+ print(f"🗑️ Deleting section: {name}")
+ db.session.execute(delete(Area).where(Area.name == name))
+
+ # === FUNCTIONS ===
+ existing_funcs = {f.description for f in db.session.query(RoomFunction).all()}
+ submitted_funcs = {
+ str(f.get("name", "")).strip()
+ for f in state.get("functions", [])
+ if isinstance(f, dict) and str(f.get("name", "")).strip()
+ }
+ print(f"🔍 Functions in DB: {existing_funcs}")
+ print(f"🆕 Functions submitted: {submitted_funcs}")
+ print(f"➖ Functions to delete: {existing_funcs - submitted_funcs}")
+ for desc in submitted_funcs - existing_funcs:
+ db.session.add(RoomFunction(description=desc))
+ for desc in existing_funcs - submitted_funcs:
+ print(f"🗑️ Deleting function: {desc}")
+ db.session.execute(delete(RoomFunction).where(RoomFunction.description == desc))
+
+ # === REPOPULATE MAPS ===
+ section_map = {a.name: a.id for a in db.session.query(Area).all()}
+ function_map = {f.description: f.id for f in db.session.query(RoomFunction).all()}
+
+ # === ROOMS ===
+ existing_rooms = {r.name: r.id for r in db.session.query(Room).all()}
+ submitted_rooms = {
+ str(r.get("name", "")).strip(): r
+ for r in state.get("rooms", [])
+ if str(r.get("name", "")).strip()
+ }
+ print(f"🔍 Rooms in DB: {list(existing_rooms.keys())}")
+ print(f"🆕 Rooms submitted: {list(submitted_rooms.keys())}")
+ print(f"➖ Rooms to delete: {set(existing_rooms.keys()) - set(submitted_rooms.keys())}")
+ for name, room_data in submitted_rooms.items():
+ if name not in existing_rooms:
+ section_id = resolve_id(room_data.get("section_id"), state["sections"], section_map, "section") if room_data.get("section_id") is not None else None
+ function_id = resolve_id(room_data.get("function_id"), state["functions"], function_map, "function") if room_data.get("function_id") is not None else None
+ db.session.add(Room(name=name, area_id=section_id, function_id=function_id))
+
+ for name in set(existing_rooms.keys()) - set(submitted_rooms.keys()):
+ print(f"🗑️ Deleting room: {name}")
+ db.session.execute(delete(Room).where(Room.name == name))
+
+ print("✅ COMMIT executed.")
+ flash("Changes saved.", "success")
+
except Exception as e:
- db.session.rollback()
print("❌ COMMIT FAILED ❌")
traceback.print_exc()
flash(f"Error saving changes: {e}", "danger")
- flash("Changes saved.", "success")
return redirect(url_for('main.settings'))
# === GET ===
brands = db.session.query(Brand).order_by(Brand.name).all()
- types = db.session.query(Item.id, Item.description.label("name")).order_by(Item.description).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.id, RoomFunction.description.label("name")).order_by(RoomFunction.description).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=brands, types=types, sections=sections, functions=functions, rooms=rooms)
+
+ 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],
+ )
+
diff --git a/static/js/widget.js b/static/js/widget.js
index 323edbc..cdbffbe 100644
--- a/static/js/widget.js
+++ b/static/js/widget.js
@@ -30,7 +30,7 @@ const ComboBoxWidget = (() => {
return;
}
- const option = new Option(value, value);
+ const option = createOption(value); // Already built to handle temp IDs
select.add(option);
formState[stateArray].push(value);
input.value = '';
@@ -94,6 +94,11 @@ const ComboBoxWidget = (() => {
}
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.`);
@@ -105,7 +110,7 @@ const ComboBoxWidget = (() => {
const key = config.stateArray ?? `${ns}s`; // fallback to pluralization
if (Array.isArray(formState?.[key])) {
- formState[key].push(newItem);
+ formState[key].push({ name: newItem });
}
if (config.sort !== false) {
diff --git a/templates/layout.html b/templates/layout.html
index c6758f7..4f6fd27 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -52,7 +52,12 @@
- {% block content %}{% endblock %}
+
+
+