Add quote bridge and quote handoff flow
This commit is contained in:
+123
-124
@@ -1,147 +1,146 @@
|
||||
# Product App - Modular Flask Application
|
||||
# Product Finder Application
|
||||
|
||||
## 🏗️ Structure
|
||||
## Overview
|
||||
|
||||
This folder contains the active Flask application for the Product Finder project.
|
||||
The app is structured around blueprints and serves a guided product-selection flow,
|
||||
admin tooling, and image preview APIs from the same codebase.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
product_app/
|
||||
├── app.py # Main application file
|
||||
├── passenger_wsgi.py # Passenger WSGI entry point for production
|
||||
├── requirements.txt # Python dependencies
|
||||
├── blueprints/ # Modular routes organized by feature
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # Login/logout/session management
|
||||
│ └── users.py # User CRUD operations
|
||||
├── templates/ # HTML templates
|
||||
│ ├── login.html
|
||||
│ └── user_manager.html
|
||||
└── data/ # Data storage
|
||||
└── users.json # User accounts with hashed passwords
|
||||
app/
|
||||
├── app.py # Flask app entry point and blueprint registration
|
||||
├── config.py # Backend/config toggles
|
||||
├── data_access.py # Chooses JSON or SQLite backend
|
||||
├── data_access_json.py # JSON-backed data operations
|
||||
├── data_access_sqlite.py # SQLite-backed data operations
|
||||
├── models.py # SQLAlchemy models for SQLite mode
|
||||
├── blueprints/
|
||||
│ ├── auth.py # Login, logout, session, location selection
|
||||
│ ├── users.py # User CRUD and password management
|
||||
│ ├── products.py # Quiz pages, product APIs, admin pages
|
||||
│ └── canvas.py # Layered image fallback API
|
||||
├── static/
|
||||
│ ├── css/styles.css # Main application styles
|
||||
│ ├── js/script.js # Quiz flow, URL state, search, previews
|
||||
│ └── images/ # Product and layered image assets
|
||||
├── templates/ # Flask templates
|
||||
└── data/ # JSON data files used by the app
|
||||
```
|
||||
|
||||
## 🎯 Features Implemented
|
||||
## Implemented Features
|
||||
|
||||
### ✅ Authentication System (blueprints/auth.py)
|
||||
- Login with username/password
|
||||
- Session management
|
||||
- Password hashing (PBKDF2-SHA256, 1M iterations)
|
||||
- Login required decorator
|
||||
- Permission checking: `can_user('manage_users')`
|
||||
- User status checking (active/inactive)
|
||||
### Authentication and Session Flow
|
||||
- Username/password login
|
||||
- Session-backed authentication
|
||||
- Active/inactive user checks
|
||||
- Multi-location session support with location selection
|
||||
- Permission checks via `can_user(...)`
|
||||
|
||||
### ✅ User Management (blueprints/users.py)
|
||||
- View all users
|
||||
- Add new users
|
||||
### User Management
|
||||
- List users
|
||||
- Create users
|
||||
- Delete users
|
||||
- Toggle active/inactive status
|
||||
- Change passwords (requires admin verification)
|
||||
- Location-based access control
|
||||
- Permission system (manage_users, create_quotes, etc.)
|
||||
- Download users as JSON
|
||||
- Toggle active status
|
||||
- Change passwords
|
||||
- Download user data JSON
|
||||
|
||||
## 🔐 Default Login
|
||||
### Product Finder
|
||||
- Quiz flow served at `/quiz/`
|
||||
- URL-based state restoration with query params:
|
||||
- `b` for bitwise state
|
||||
- `q` for current question/results state
|
||||
- `p` for direct product links
|
||||
- Product results grid with clickable product cards
|
||||
- Product detail page with configuration controls
|
||||
- Share buttons for results and product-detail URLs
|
||||
- Browser back/forward support via `popstate`
|
||||
|
||||
- **Username:** `Master`
|
||||
- **Password:** `Master`
|
||||
- **Permissions:** Full access (manage_users)
|
||||
### Product Management and Search
|
||||
- Advanced search page
|
||||
- Product listing page with search and pagination API
|
||||
- Product manager page
|
||||
- Product CRUD API endpoints
|
||||
- Product attributes API endpoint
|
||||
|
||||
## 🚀 Local Testing
|
||||
### Image Systems
|
||||
- Flat-image preview support
|
||||
- Static layered-image support
|
||||
- Canvas API fallback system for hierarchical image lookup
|
||||
- Layer transforms for flipped/mirrored previews where configured
|
||||
|
||||
### Data Backends
|
||||
- JSON backend is the current default
|
||||
- SQLite backend is supported behind `USE_DATABASE = True` in `config.py`
|
||||
- Data-access calls are routed through `data_access.py`
|
||||
|
||||
## Important Routes
|
||||
|
||||
### App-Level Routes
|
||||
- `/` - Authenticated home page
|
||||
- `/test` - Basic app health page
|
||||
- `/init-db` - Create SQLite tables when database mode is enabled
|
||||
|
||||
### Auth Routes
|
||||
- `/login`
|
||||
- `/select-location`
|
||||
- `/api/login`
|
||||
- `/api/select-location`
|
||||
- `/api/session`
|
||||
- `/logout`
|
||||
|
||||
### Product Routes
|
||||
- `/quiz/`
|
||||
- `/quiz/index`
|
||||
- `/quiz/advanced-search`
|
||||
- `/quiz/list`
|
||||
- `/quiz/manage`
|
||||
- `/quiz/product/<product_code>`
|
||||
- `/quiz/api/products`
|
||||
- `/quiz/api/products/search`
|
||||
- `/quiz/api/products/<product_code>`
|
||||
- `/quiz/api/product-attributes`
|
||||
|
||||
### Canvas API Routes
|
||||
- `/api/canvas/<product_code>/<layer>`
|
||||
- `/api/canvas/<product_code>/info`
|
||||
- `/api/canvas/test`
|
||||
|
||||
## Local Development
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
cd product_app
|
||||
python app.py
|
||||
cd app
|
||||
../.venv/bin/python app.py
|
||||
```
|
||||
|
||||
Visit: http://localhost:8080/
|
||||
Open:
|
||||
- `http://127.0.0.1:8080/`
|
||||
- `http://127.0.0.1:8080/quiz/`
|
||||
|
||||
## Routes
|
||||
## Data Update Workflow
|
||||
|
||||
### Main Routes
|
||||
- `/` - Home (redirects to login if not authenticated)
|
||||
- `/test` - Test page to verify app is running
|
||||
If product source data changes, use the VS Code tasks from the repository root:
|
||||
|
||||
### Authentication Routes (auth_bp)
|
||||
- `/login` - Login page
|
||||
- `/logout` - Logout
|
||||
- `/api/login` - POST: Login API
|
||||
- `/api/session` - GET: Current session info
|
||||
- `Update Data Files from CSV`
|
||||
- `Generate Bitwise Data`
|
||||
- `Process All Data`
|
||||
|
||||
### User Management Routes (users_bp)
|
||||
- `/users/` - User management page
|
||||
- `/users/api` - GET: List all users, POST: Add user
|
||||
- `/users/api/<index>` - DELETE: Delete user
|
||||
- `/users/api/<index>/active` - PATCH: Toggle active status
|
||||
- `/users/api/<index>/change-password` - POST: Change password
|
||||
- `/users/api/download` - GET: Download users.json
|
||||
These regenerate the JSON files consumed by the quiz and filtering logic.
|
||||
|
||||
## 📦 Production Deployment
|
||||
## Current Limitations
|
||||
|
||||
### Upload to Server: `/home/bmdwtjuw/product-finder/`
|
||||
- Generic conditional-question evaluation is not fully implemented in the frontend.
|
||||
The current flow works with the generated navigation structure and a small amount
|
||||
of hardcoded branching, but it does not yet use a reusable `resolveNextQuestion`
|
||||
style engine for all conditional prompts.
|
||||
- SQLite support exists, but the project currently runs in JSON mode by default.
|
||||
- There is no automated test suite in this repository yet; validation is currently manual.
|
||||
|
||||
Files to upload:
|
||||
- `app.py`
|
||||
- `passenger_wsgi.py` (or just use existing)
|
||||
- `blueprints/` (entire folder)
|
||||
- `__init__.py`
|
||||
- `auth.py`
|
||||
- `users.py`
|
||||
- `templates/` (entire folder)
|
||||
- `login.html`
|
||||
- `user_manager.html`
|
||||
- `data/users.json`
|
||||
## Recommended Next Work
|
||||
|
||||
### Control Panel Settings
|
||||
|
||||
- **Application startup file:** `passenger_wsgi.py` (or `app.py`)
|
||||
- **Application Entry point:** `application`
|
||||
- **Python version:** 3.13.11
|
||||
|
||||
### Test After Deployment:
|
||||
1. https://columbiawindows.com/product-finder/test
|
||||
2. https://columbiawindows.com/product-finder/login
|
||||
3. Login with Master/Master
|
||||
4. Test user management
|
||||
|
||||
## 🔧 Adding New Features
|
||||
|
||||
### Create a New Blueprint
|
||||
|
||||
1. Create `blueprints/your_feature.py`:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, render_template
|
||||
from blueprints.auth import login_required, can_user
|
||||
|
||||
your_feature_bp = Blueprint('your_feature', __name__, url_prefix='/your-feature')
|
||||
|
||||
@your_feature_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('your_feature.html')
|
||||
```
|
||||
|
||||
2. Register in `app.py`:
|
||||
|
||||
```python
|
||||
from blueprints.your_feature import your_feature_bp
|
||||
app.register_blueprint(your_feature_bp)
|
||||
```
|
||||
|
||||
3. Create `templates/your_feature.html`
|
||||
|
||||
4. Test locally, then upload to server
|
||||
|
||||
## 📝 Benefits of Blueprint Structure
|
||||
|
||||
✅ **Separation of Concerns:** Each feature in its own file
|
||||
✅ **Easy to Maintain:** Find and edit specific features quickly
|
||||
✅ **Scalable:** Add new features without touching existing code
|
||||
✅ **Testable:** Each blueprint can be tested independently
|
||||
✅ **Reusable:** Share decorators (login_required, permission_required) across blueprints
|
||||
|
||||
## 🛠️ Next Steps
|
||||
|
||||
- Add more blueprints for other features (quotes, products, etc.)
|
||||
- Add more templates as needed
|
||||
- Extend permission system
|
||||
- Add location selection page
|
||||
- Add the main product finder quiz
|
||||
1. Finish generic conditional navigation so `conditional` metadata in `navigation.json` is evaluated uniformly.
|
||||
2. Add smoke tests for login, quiz state restoration, and product detail deep links.
|
||||
3. Decide whether SQLite should remain optional or become the default backend.
|
||||
|
||||
@@ -6,6 +6,10 @@ from flask import Blueprint, render_template, send_from_directory, session, json
|
||||
from blueprints.auth import login_required, get_current_user, can_user
|
||||
import data_access as da
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
import config
|
||||
|
||||
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
|
||||
|
||||
@@ -226,6 +230,106 @@ def get_product_attributes():
|
||||
'attributes': attributes
|
||||
})
|
||||
|
||||
@products_bp.route('/api/submit-quote', methods=['POST'])
|
||||
@login_required
|
||||
def submit_quote():
|
||||
"""Submit current order items as a handoff file for downstream processing"""
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not logged in'
|
||||
}), 401
|
||||
|
||||
# Allow explicit quote permission or super admin
|
||||
if not can_user('create_quotes') and not user.get('superAdmin', False):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Permission denied'
|
||||
}), 403
|
||||
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
items = payload.get('items', [])
|
||||
|
||||
if not isinstance(items, list) or len(items) == 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No order items to submit'
|
||||
}), 400
|
||||
|
||||
# Normalize each item to a safe subset for handoff
|
||||
normalized_items = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized_items.append({
|
||||
'productCode': str(item.get('productCode', '')).strip(),
|
||||
'description': str(item.get('description', '')).strip(),
|
||||
'material': str(item.get('material', '')).strip(),
|
||||
'color': str(item.get('color', '')).strip(),
|
||||
'size': str(item.get('size', '')).strip(),
|
||||
'hingeLocation': str(item.get('hingeLocation', '')).strip(),
|
||||
'quantity': int(item.get('quantity', 1) or 1)
|
||||
})
|
||||
|
||||
normalized_items = [i for i in normalized_items if i['productCode']]
|
||||
if not normalized_items:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Order items are invalid'
|
||||
}), 400
|
||||
|
||||
submission_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
quote_data = {
|
||||
'schemaVersion': 1,
|
||||
'submissionId': submission_id,
|
||||
'createdAt': created_at,
|
||||
'createdBy': {
|
||||
'username': session.get('username'),
|
||||
'location': session.get('currentLocation'),
|
||||
'accessibleLocations': session.get('accessibleLocations', [])
|
||||
},
|
||||
'source': {
|
||||
'app': 'product-finder',
|
||||
'route': payload.get('route', ''),
|
||||
'url': payload.get('url', '')
|
||||
},
|
||||
'context': {
|
||||
'answers': payload.get('answers', {}),
|
||||
'bitValue': payload.get('bitValue', 0)
|
||||
},
|
||||
'items': normalized_items
|
||||
}
|
||||
|
||||
outgoing_dir = config.QUOTE_OUTGOING_DIR
|
||||
os.makedirs(outgoing_dir, exist_ok=True)
|
||||
|
||||
final_filename = f"{submission_id}.json"
|
||||
final_path = os.path.join(outgoing_dir, final_filename)
|
||||
tmp_path = final_path + '.tmp'
|
||||
|
||||
# Atomic handoff write
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(quote_data, f, indent=2)
|
||||
os.replace(tmp_path, final_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Quote submitted successfully',
|
||||
'submissionId': submission_id,
|
||||
'fileName': final_filename,
|
||||
'filePath': final_path
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@products_bp.route('/css/<path:filename>')
|
||||
def serve_css(filename):
|
||||
"""Serve CSS files"""
|
||||
|
||||
@@ -15,6 +15,11 @@ DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'data', 'products.db')}"
|
||||
# JSON data directory
|
||||
DATA_DIR = os.path.join(basedir, 'data')
|
||||
|
||||
# Shared handoff directory used by quote submission flow
|
||||
_default_shared_dir = os.path.abspath(os.path.join(basedir, '..', 'shared'))
|
||||
SHARED_DIR = os.environ.get('SHARED_DIR', _default_shared_dir)
|
||||
QUOTE_OUTGOING_DIR = os.environ.get('QUOTE_OUTGOING_DIR', os.path.join(SHARED_DIR, 'outgoing'))
|
||||
|
||||
# Session configuration
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
@@ -10,6 +10,9 @@ let productData = [];
|
||||
let accessoryData = [];
|
||||
let bitwiseData = {};
|
||||
|
||||
// In-page order list for the current browser session
|
||||
let orderItems = [];
|
||||
|
||||
// Track accumulated bit value for URL state
|
||||
let accumulatedBitValue = 0;
|
||||
|
||||
@@ -625,6 +628,8 @@ function startOver() {
|
||||
}
|
||||
// Clear bit value
|
||||
accumulatedBitValue = 0;
|
||||
// Clear order list
|
||||
orderItems = [];
|
||||
// Clear URL
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
loadContent('start');
|
||||
@@ -916,6 +921,11 @@ function showProductDetail(product) {
|
||||
<div class="config-field" style="margin-top: 20px;">
|
||||
<button class="back-button" onclick="addToOrder('${prodCode}')" style="width: 100%; padding: 12px; font-size: 16px;">Add to Order</button>
|
||||
</div>
|
||||
<div class="config-field" style="margin-top: 12px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">Current Order</div>
|
||||
<div id="order-items-list" style="background: #f8f9fb; border: 1px solid #e3e5ea; border-radius: 8px; padding: 10px;"></div>
|
||||
<button class="back-button" onclick="submitQuoteOrder()" style="width: 100%; margin-top: 10px; padding: 12px; font-size: 16px;">Quote</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-details">
|
||||
@@ -964,6 +974,7 @@ function showProductDetail(product) {
|
||||
|
||||
contentDiv.innerHTML = html;
|
||||
updateBreadcrumb();
|
||||
renderOrderList();
|
||||
|
||||
// Initialize images based on type
|
||||
if (useCanvasAPI) {
|
||||
@@ -1328,6 +1339,143 @@ function handleSizeChange(productCode) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedSizeLabel() {
|
||||
const sizeSelect = document.getElementById('config-size');
|
||||
if (!sizeSelect) return '';
|
||||
|
||||
if (sizeSelect.value === 'custom') {
|
||||
const customWidth = document.getElementById('custom-width')?.value || '';
|
||||
const customHeight = document.getElementById('custom-height')?.value || '';
|
||||
if (customWidth && customHeight) {
|
||||
return `Custom ${customWidth}" x ${customHeight}"`;
|
||||
}
|
||||
return 'Custom Size';
|
||||
}
|
||||
|
||||
const selectedOption = sizeSelect.options[sizeSelect.selectedIndex];
|
||||
return selectedOption ? selectedOption.textContent.trim() : '';
|
||||
}
|
||||
|
||||
function buildOrderItemKey(item) {
|
||||
return [
|
||||
item.productCode,
|
||||
item.material,
|
||||
item.color,
|
||||
item.size,
|
||||
item.hingeLocation
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function addToOrder(productCode) {
|
||||
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||
if (!product) {
|
||||
showNotification('Unable to add item: product not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const material = document.getElementById('config-material')?.value || 'N/A';
|
||||
const color = document.getElementById('config-color')?.value || 'N/A';
|
||||
const hingeLocation = document.querySelector('[name="hinge-location"]:checked')?.value || 'left';
|
||||
const size = getSelectedSizeLabel() || 'N/A';
|
||||
|
||||
const item = {
|
||||
productCode,
|
||||
description: product.description || product.DESCRIPTION || product.title || productCode,
|
||||
material,
|
||||
color,
|
||||
size,
|
||||
hingeLocation,
|
||||
quantity: 1
|
||||
};
|
||||
|
||||
const itemKey = buildOrderItemKey(item);
|
||||
const existingIndex = orderItems.findIndex(existing => buildOrderItemKey(existing) === itemKey);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
orderItems[existingIndex].quantity += 1;
|
||||
} else {
|
||||
orderItems.push(item);
|
||||
}
|
||||
|
||||
renderOrderList();
|
||||
showNotification('Added to order.');
|
||||
}
|
||||
|
||||
async function submitQuoteOrder() {
|
||||
if (!Array.isArray(orderItems) || orderItems.length === 0) {
|
||||
showNotification('Add at least one item before submitting a quote.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/quiz/api/submit-quote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: orderItems,
|
||||
bitValue: accumulatedBitValue,
|
||||
answers: userAnswers,
|
||||
route: window.location.pathname,
|
||||
url: window.location.href
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.status !== 'success') {
|
||||
showNotification(data.message || 'Quote submission failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const submissionId = data.submissionId || 'unknown';
|
||||
orderItems = [];
|
||||
renderOrderList();
|
||||
showNotification(`Quote submitted (${submissionId}).`);
|
||||
} catch (error) {
|
||||
showNotification('Quote submission failed.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeOrderItem(index) {
|
||||
if (index < 0 || index >= orderItems.length) return;
|
||||
orderItems.splice(index, 1);
|
||||
renderOrderList();
|
||||
}
|
||||
|
||||
function renderOrderList() {
|
||||
const container = document.getElementById('order-items-list');
|
||||
if (!container) return;
|
||||
|
||||
if (orderItems.length === 0) {
|
||||
container.innerHTML = '<div style="color: #666; font-size: 0.95em;">No items added yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalQuantity = orderItems.reduce((total, item) => total + item.quantity, 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: grid; gap: 8px;">
|
||||
${orderItems.map((item, index) => `
|
||||
<div style="background: #fff; border: 1px solid #dfe3ea; border-radius: 6px; padding: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;">
|
||||
<div>
|
||||
<div style="font-weight: 600;">${item.productCode} (${item.quantity})</div>
|
||||
<div style="font-size: 0.9em; color: #444;">${item.description}</div>
|
||||
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
|
||||
Material: ${item.material} | Color: ${item.color}<br>
|
||||
Size: ${item.size} | Hinge: ${item.hingeLocation}
|
||||
</div>
|
||||
</div>
|
||||
<button class="back-button" onclick="removeOrderItem(${index})" style="padding: 6px 10px; font-size: 0.85em;">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="font-size: 0.9em; color: #333; font-weight: 600; padding-top: 4px;">
|
||||
Total Items: ${totalQuantity}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Go back to results from product detail
|
||||
function goBackToResults() {
|
||||
// Find results in history or load it directly
|
||||
|
||||
Reference in New Issue
Block a user