Refactor .gitignore; add patterns for SQLite database files and improve ignored file management

Enhance app initialization; set secret key from environment variable for better security practices

Update work_log model import; change User import path for improved module structure

Refactor routes; add new inventory item creation route and enhance settings handling with JSON form state

Improve ComboBoxWidget; add handleComboAdd function for better option management and integrate with render_combobox macro

Revamp settings template; implement form state management and improve modal functionality for room creation

Add error template; create a new error handling page for better user feedback
This commit is contained in:
Yaro Kasear 2025-06-23 10:05:31 -05:00
parent 142e909a88
commit c6fc1a4795
9 changed files with 269 additions and 113 deletions

5
.gitignore vendored
View file

@ -1,4 +1,7 @@
__pycache__/ __pycache__/
.venv/ .venv/
.env .env
app.db *.db
*.db-journal
*.sqlite
*.sqlite3

View file

@ -1,6 +1,7 @@
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import logging import logging
import os
db = SQLAlchemy() db = SQLAlchemy()
@ -14,6 +15,7 @@ if not logger.handlers:
def create_app(): def create_app():
from config import Config from config import Config
app = Flask(__name__) 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.config.from_object(Config)
db.init_app(app) db.init_app(app)

View file

@ -1,7 +1,7 @@
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .inventory import Inventory 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 import Boolean, ForeignKeyConstraint, Identity, Integer, ForeignKey, Unicode, DateTime, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship

171
routes.py
View file

@ -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 .models import Brand, Item, Inventory, RoomFunction, User, WorkLog, Room, Area
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import aliased 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 .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 datetime import datetime, timedelta
import pandas as pd import pandas as pd
import traceback
import json
main = Blueprint('main', __name__) main = Blueprint('main', __name__)
@ -76,63 +79,8 @@ worklog_form_fields = {
"notes": lambda log: {"label": "Notes", "type": "textarea", "value": log.notes or "", "rows": 15} "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("/") @main.route("/")
def index(): def index():
cutoff = datetime.utcnow() - timedelta(days=14)
worklog_query = eager_load_worklog_relationships( worklog_query = eager_load_worklog_relationships(
db.session.query(WorkLog) db.session.query(WorkLog)
).filter( ).filter(
@ -171,7 +119,6 @@ def index():
else: else:
pivot = pd.Series([0] * len(expected_conditions), index=expected_conditions) pivot = pd.Series([0] * len(expected_conditions), index=expected_conditions)
# Convert pandas/numpy int64s to plain old Python ints # Convert pandas/numpy int64s to plain old Python ints
pivot = pivot.astype(int) pivot = pivot.astype(int)
labels = list(pivot.index) labels = list(pivot.index)
@ -210,7 +157,6 @@ def index():
datasets=datasets datasets=datasets
) )
def link(text, endpoint, **values): def link(text, endpoint, **values):
return {"text": text, "url": url_for(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) return render_template('inventory_index.html', title=f"Inventory ({category.capitalize()} Index)" if category else "Inventory", category=category, listing=listing)
@main.route("/inventory_item/<int:id>") @main.route("/inventory_item/<int:id>", methods=['GET', 'POST'])
def inventory_item(id): def inventory_item(id):
inventory_query = db.session.query(Inventory) inventory_query = db.session.query(Inventory)
item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first() item = eager_load_inventory_relationships(inventory_query).filter(Inventory.id == id).first()
@ -312,6 +258,23 @@ def inventory_item(id):
types=types 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") @main.route("/users")
def list_users(): def list_users():
query = eager_load_user_relationships(db.session.query(User)).order_by(User.last_name, User.first_name) 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) 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(): 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() 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.id, Item.description.label("name")).order_by(Item.description).all()
sections = db.session.query(Area).order_by(Area.name).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.id, RoomFunction.description.label("name")).order_by(RoomFunction.description).all()
rooms = eager_load_room_relationships(db.session.query(Room).order_by(Room.name)).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=brands, types=types, sections=sections, functions=functions, rooms=rooms)

View file

@ -15,6 +15,27 @@ const ComboBoxWidget = (() => {
sorted.forEach(option => selectElement.appendChild(option)); 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 = {}) { function initComboBox(ns, config = {}) {
const input = document.querySelector(`#${ns}-input`); const input = document.querySelector(`#${ns}-input`);
const list = document.querySelector(`#${ns}-list`); const list = document.querySelector(`#${ns}-list`);
@ -76,8 +97,23 @@ const ComboBoxWidget = (() => {
if (config.onAdd) { if (config.onAdd) {
config.onAdd(newItem, list, createOption); config.onAdd(newItem, list, createOption);
} else { } else {
// 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); const option = createOption(newItem);
list.appendChild(option); 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; addBtn.disabled = true;
removeBtn.disabled = true; removeBtn.disabled = true;
updateAddButtonIcon(); updateAddButtonIcon();
if (config.sort !== false) {
sortOptions(list);
}
}); });
removeBtn.addEventListener('click', () => { removeBtn.addEventListener('click', () => {
Array.from(list.selectedOptions).forEach(option => { Array.from(list.selectedOptions).forEach(option => {
if (config.onRemove) { if (config.onRemove) {
@ -110,6 +143,7 @@ const ComboBoxWidget = (() => {
return { return {
initComboBox, initComboBox,
createOption, createOption,
sortOptions sortOptions,
handleComboAdd
}; };
})(); })();

9
templates/error.html Normal file
View file

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

View file

@ -21,13 +21,19 @@
</select> </select>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
ComboBoxWidget.initComboBox("{{ id }}", { ComboBoxWidget.initComboBox("{{ id }}", {
{% if onAdd %}onAdd: function(newItem, list, createOption) { {{ onAdd | safe }} },{% endif %} onAdd: function(newItem, list, createOption) {
{% if onAdd %}
{{ onAdd | safe }}
{% else %}
ComboBoxWidget.handleComboAdd('{{ id }}-input', '{{ id }}-list', '{{ id }}s', '{{ label or id }}');
{% endif %}
},
{% if onRemove %}onRemove: function(option) { {{ onRemove | safe }} },{% endif %} {% if onRemove %}onRemove: function(option) { {{ onRemove | safe }} },{% endif %}
{% if onEdit %}onEdit: function(option) { {{ onEdit | safe }} }{% endif %} {% if onEdit %}onEdit: function(option) { {{ onEdit | safe }} }{% endif %}
}); });
}); });
</script> </script>
{% endmacro %} {% endmacro %}

View file

@ -4,7 +4,9 @@
{% block content %} {% block content %}
<form method="POST" action="{{ url_for('main.settings') }}"> <form method="POST" id="settingsForm" action="{{ url_for('main.settings') }}">
<input type="hidden" name="formState" id="formStateField">
{{ breadcrumbs.breadcrumb_header( {{ breadcrumbs.breadcrumb_header(
title=title, title=title,
submit_button=True submit_button=True
@ -88,24 +90,24 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="roomName" class="form-label">Room Name</label> <label for="roomName" class="form-label">Room Name</label>
<input type="text" class="form-input" name="roomName" id="roomName" <input type="text" class="form-input" id="roomName"
placeholder="Enter room name"> placeholder="Enter room name">
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="roomSection" class="form-label">Section</label> <label for="roomSection" class="form-label">Section</label>
<select name="roomSection" id="roomSection" class="form-select"> <select id="roomSection" class="form-select">
<option>Select a section</option> <option value="">Select a section</option>
{% for section in sections %} {% for section in sections %}
<option value="{{ section.id }}">{{ section.name }}</option> <option value="{{ section.id }}">{{ section.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col"> <div class="col">
<label for="roomFunction" class="form-label">Function</label> <label class="form-label">Function</label>
<select name="roomFunction" id="roomFunction" class="form-select"> <select id="roomFunction" class="form-select">
<option>Select a function</option> <option value="">Select a function</option>
{% for function in functions %} {% for function in functions %}
<option value="{{ function.id }}">{{ function.name }}</option> <option value="{{ function.id }}">{{ function.name }}</option>
{% endfor %} {% endfor %}
@ -125,33 +127,82 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
const formState = {
brands: [],
types: [],
sections: [],
functions: [],
rooms: []
};
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('roomEditor'); const modal = document.getElementById('roomEditor');
const saveButton = document.getElementById('roomEditorSaveButton'); const saveButton = document.getElementById('roomEditorSaveButton');
const cancelButton = document.getElementById('roomEditorCancelButton'); 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', () => { saveButton.addEventListener('click', () => {
console.log('Save button clicked'); const name = document.getElementById('roomName').value.trim();
const name = document.getElementById('roomName').value;
const section = document.getElementById('roomSection').value; const section = document.getElementById('roomSection').value;
const func = document.getElementById('roomFunction').value; const func = document.getElementById('roomFunction').value;
if (name.trim()) { if (!name) {
const newRoom = ComboBoxWidget.createOption(`${name}`, null);
const selectWidget = document.getElementById('room-list');
selectWidget.appendChild(newRoom);
ComboBoxWidget.sortOptions(selectWidget);
bootstrap.Modal.getInstance(modal).hide();
} else {
alert('Please enter a room 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', () => { cancelButton.addEventListener('click', () => {
console.log('Cancel button clicked');
bootstrap.Modal.getInstance(modal).hide(); bootstrap.Modal.getInstance(modal).hide();
}); });
}); });
{% endblock %} {% endblock %}