diff --git a/.gitignore b/.gitignore index 6d493b7..28c8309 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ __pycache__/ .venv/ .env -app.db \ No newline at end of file +*.db +*.db-journal +*.sqlite +*.sqlite3 diff --git a/__init__.py b/__init__.py index 6494705..a241106 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy import logging +import os db = SQLAlchemy() @@ -14,6 +15,7 @@ if not logger.handlers: 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) db.init_app(app) diff --git a/models/__pycache__/work_log.cpython-313.pyc b/models/__pycache__/work_log.cpython-313.pyc index 85fa3ee..d2834b4 100644 Binary files a/models/__pycache__/work_log.cpython-313.pyc and b/models/__pycache__/work_log.cpython-313.pyc differ diff --git a/models/work_log.py b/models/work_log.py index bec28c6..74db981 100644 --- a/models/work_log.py +++ b/models/work_log.py @@ -1,7 +1,7 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from .inventory import Inventory - from .inventory import User + from .users import User from sqlalchemy import Boolean, ForeignKeyConstraint, Identity, Integer, ForeignKey, Unicode, DateTime, text from sqlalchemy.orm import Mapped, mapped_column, relationship diff --git a/routes.py b/routes.py index ed51a34..4cb7cd9 100644 --- a/routes.py +++ b/routes.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template, url_for, request, redirect +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.orm import aliased @@ -7,6 +8,8 @@ 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 import pandas as pd +import traceback +import json main = Blueprint('main', __name__) @@ -76,63 +79,8 @@ worklog_form_fields = { "notes": lambda log: {"label": "Notes", "type": "textarea", "value": log.notes or "", "rows": 15} } -def make_paginated_data( - query, - page: int, - per_page=15 -): - model = query.column_descriptions[0]['entity'] - items = ( - query.order_by(model.id) - .limit(per_page) - .offset((page - 1) * per_page) - .all() - ) - has_next = len(items) == per_page - has_prev = page > 1 - total_items = query.count() - total_pages = (total_items + per_page - 1) // per_page - return { - "items": items, - "has_next": has_next, - "has_prev": has_prev, - "total_pages": total_pages, - "page": page - } - -def render_paginated_table( - query, - page: int, - title: str, - headers: dict, - entry_route: str, - row_fn: Callable[[Any], List[dict]], - endpoint: str, - per_page=15, - breadcrumb=[], - extra_args={} -): - data = make_paginated_data(query, page, per_page) - return render_template( - "table.html", - header=headers.keys(), - rows=[{"id": item.id, "cells": row_fn(item)} for item in data['items']], - title=title, - has_next=data['has_next'], - has_prev=data['has_prev'], - page=page, - endpoint=endpoint, - total_pages=data['total_pages'], - headers=headers, - entry_route=entry_route, - breadcrumb=breadcrumb, - extra_args=extra_args - ) - @main.route("/") def index(): - cutoff = datetime.utcnow() - timedelta(days=14) - worklog_query = eager_load_worklog_relationships( db.session.query(WorkLog) ).filter( @@ -171,7 +119,6 @@ def index(): 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) @@ -210,7 +157,6 @@ def index(): datasets=datasets ) - def link(text, endpoint, **values): return {"text": text, "url": url_for(endpoint, **values)} @@ -287,7 +233,7 @@ def inventory_index(): return render_template('inventory_index.html', title=f"Inventory ({category.capitalize()} Index)" if category else "Inventory", category=category, listing=listing) -@main.route("/inventory_item/") +@main.route("/inventory_item/", methods=['GET', 'POST']) def inventory_item(id): inventory_query = db.session.query(Inventory) item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first() @@ -312,6 +258,23 @@ def inventory_item(id): types=types ) +@main.route("/inventory_item/new", methods=['GET', 'POST']) +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() + + if request.method == 'POST': + # Handle form submission logic here + pass + # If GET request, render the form for creating a new inventory item + if not brands: + return render_template("error.html", title="No Brands Found", message="Please add at least one brand before creating an inventory item.") + + return render_template("inventory.html", title="New Inventory Item", + brands=brands, users=users, rooms=rooms, types=types) + @main.route("/users") def list_users(): query = eager_load_user_relationships(db.session.query(User)).order_by(User.last_name, User.first_name) @@ -435,11 +398,99 @@ def search(): return render_template('search.html', title=f"Database Search ({query})" if query else "Database Search", results=results, query=query) -@main.route('/settings') +@main.route('/settings', methods=['GET', 'POST']) def settings(): + if request.method == 'POST': + print("⚠️⚠️⚠️ POST /settings reached! ⚠️⚠️⚠️") + form = request.form + print("📝 Raw form payload:", form) + + try: + state = json.loads(form['formState']) + except Exception as e: + flash("Invalid form state submitted. JSON decode failed.", "danger") + traceback.print_exc() + return redirect(url_for('main.settings')) + + # === 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 else None + function_id = int(raw_function) if raw_function else None + + # Resolve negative or unmapped IDs + if section_id is None or section_id < 0: + section_name = state['sections'][abs(section_id + 1)] + 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_name = state['functions'][abs(function_id + 1)] + 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.") + 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() 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() 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) + diff --git a/static/js/widget.js b/static/js/widget.js index c126afb..fb6067a 100644 --- a/static/js/widget.js +++ b/static/js/widget.js @@ -15,6 +15,27 @@ const ComboBoxWidget = (() => { sorted.forEach(option => selectElement.appendChild(option)); } + function handleComboAdd(inputId, listId, stateArray, label = 'entry') { + const input = document.getElementById(inputId); + const value = input.value.trim(); + if (!value) { + alert(`Please enter a ${label}.`); + return; + } + + const select = document.getElementById(listId); + const exists = Array.from(select.options).some(opt => opt.textContent === value); + if (exists) { + alert(`${label.charAt(0).toUpperCase() + label.slice(1)} "${value}" already exists.`); + return; + } + + const option = new Option(value, value); + select.add(option); + formState[stateArray].push(value); + input.value = ''; + } + function initComboBox(ns, config = {}) { const input = document.querySelector(`#${ns}-input`); const list = document.querySelector(`#${ns}-list`); @@ -76,8 +97,23 @@ const ComboBoxWidget = (() => { if (config.onAdd) { config.onAdd(newItem, list, createOption); } else { - const option = createOption(newItem); - list.appendChild(option); + // Default fallback here + if (config.stateArray && formState && formState[config.stateArray]) { + 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); + formState[config.stateArray].push(newItem); + if (config.sort !== false) { + sortOptions(list); + } + } else { + const option = createOption(newItem); + list.appendChild(option); + } } } @@ -85,12 +121,9 @@ const ComboBoxWidget = (() => { addBtn.disabled = true; removeBtn.disabled = true; updateAddButtonIcon(); - - if (config.sort !== false) { - sortOptions(list); - } }); + removeBtn.addEventListener('click', () => { Array.from(list.selectedOptions).forEach(option => { if (config.onRemove) { @@ -110,6 +143,7 @@ const ComboBoxWidget = (() => { return { initComboBox, createOption, - sortOptions + sortOptions, + handleComboAdd }; })(); diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..605c4ed --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +{% extends 'layout.html' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+ {{ message }} +
+{% endblock %} \ No newline at end of file diff --git a/templates/fragments/_combobox_fragment.html b/templates/fragments/_combobox_fragment.html index 98d5ba8..fef929e 100644 --- a/templates/fragments/_combobox_fragment.html +++ b/templates/fragments/_combobox_fragment.html @@ -21,13 +21,19 @@ - + }); + {% endmacro %} diff --git a/templates/settings.html b/templates/settings.html index ad2229b..791f691 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -4,7 +4,9 @@ {% block content %} -
+ + + {{ breadcrumbs.breadcrumb_header( title=title, submit_button=True @@ -42,18 +44,18 @@
{{ combos.render_combobox( - id='section', - options=sections, - label='Sections', - placeholder='Add a new section' + id='section', + options=sections, + label='Sections', + placeholder='Add a new section' ) }}
{{ combos.render_combobox( - id='function', - options=functions, - label='Functions', - placeholder='Add a new function' + id='function', + options=functions, + label='Functions', + placeholder='Add a new function' ) }}
@@ -67,11 +69,11 @@ {% endset %}
{{ combos.render_combobox( - id='room', - options=rooms, - label='Rooms', - placeholder='Add a new room', - onAdd=room_editor + id='room', + options=rooms, + label='Rooms', + placeholder='Add a new room', + onAdd=room_editor ) }}
@@ -88,24 +90,24 @@
-
- + {% for section in sections %} {% endfor %}
- - + {% for function in functions %} {% endfor %} @@ -125,33 +127,82 @@ {% endblock %} {% block script %} + const formState = { + brands: [], + types: [], + sections: [], + functions: [], + rooms: [] + }; + document.addEventListener('DOMContentLoaded', () => { const modal = document.getElementById('roomEditor'); const saveButton = document.getElementById('roomEditorSaveButton'); const cancelButton = document.getElementById('roomEditorCancelButton'); - + const form = document.getElementById('settingsForm'); + + // Replace the whole submission logic with just JSON + form.addEventListener('submit', () => { + document.getElementById('formStateField').value = JSON.stringify(formState); + }); + + // Modal populates dropdowns fresh from the page every time it opens + modal.addEventListener('show.bs.modal', () => { + const modalSections = document.getElementById('roomSection'); + const modalFunctions = document.getElementById('roomFunction'); + const pageSections = document.getElementById('section-list'); + const pageFunctions = document.getElementById('function-list'); + + modalSections.innerHTML = ''; + modalFunctions.innerHTML = ''; + + modalSections.appendChild(new Option("Select a section", "")); + modalFunctions.appendChild(new Option("Select a function", "")); + + Array.from(pageSections.options).forEach(opt => + modalSections.appendChild(new Option(opt.textContent, opt.value)) + ); + Array.from(pageFunctions.options).forEach(opt => + modalFunctions.appendChild(new Option(opt.textContent, opt.value)) + ); + }); + saveButton.addEventListener('click', () => { - console.log('Save button clicked'); - - const name = document.getElementById('roomName').value; + const name = document.getElementById('roomName').value.trim(); const section = document.getElementById('roomSection').value; const func = document.getElementById('roomFunction').value; - - if (name.trim()) { - const newRoom = ComboBoxWidget.createOption(`${name}`, null); - const selectWidget = document.getElementById('room-list'); - - selectWidget.appendChild(newRoom); - ComboBoxWidget.sortOptions(selectWidget); - bootstrap.Modal.getInstance(modal).hide(); - } else { + + if (!name) { alert('Please enter a room name.'); + return; } + + // Avoid duplicate visible names + const roomList = document.getElementById('room-list'); + const exists = Array.from(roomList.options).some(opt => opt.textContent.trim() === name); + if (exists) { + alert(`Room "${name}" already exists.`); + return; + } + + // Add to select box visibly + const option = ComboBoxWidget.createOption(name); + roomList.appendChild(option); + ComboBoxWidget.sortOptions(roomList); + + // Track in state object + formState.rooms.push({ + name, + section_id: section, + function_id: func + }); + + bootstrap.Modal.getInstance(modal).hide(); }); - + cancelButton.addEventListener('click', () => { - console.log('Cancel button clicked'); bootstrap.Modal.getInstance(modal).hide(); }); }); -{% endblock %} \ No newline at end of file +{% endblock %} +