Add quote bridge and quote handoff flow

This commit is contained in:
2026-06-29 18:41:45 -05:00
parent be1475dd8e
commit a329168765
17 changed files with 1707 additions and 509 deletions
+127
View File
@@ -0,0 +1,127 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Update Data Files from CSV",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/python",
"args": [
"parse_csv_to_json.py"
],
"options": {
"cwd": "${workspaceFolder}/app"
},
"presentation": {
"reveal": "always",
"panel": "shared",
"clear": true
},
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": false
},
"detail": "Generate JSON files from products.csv (Step 1/2)"
},
{
"label": "Generate Bitwise Data",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/python",
"args": [
"generate_bitwise_helper.py"
],
"options": {
"cwd": "${workspaceFolder}/app"
},
"presentation": {
"reveal": "always",
"panel": "shared",
"clear": false
},
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": false
},
"detail": "Generate bitwise filtering data (Step 2/2)"
},
{
"label": "Process All Data",
"dependsOn": [
"Update Data Files from CSV",
"Generate Bitwise Data"
],
"dependsOrder": "sequence",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"detail": "Run both scripts to update all JSON files from products.csv"
},
{
"label": "Start Flask Server",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/python",
"args": [
"app.py"
],
"options": {
"cwd": "${workspaceFolder}/app"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"problemMatcher": {
"pattern": {
"regexp": "^\\s*\\*\\s+Running on (https?://\\S+)",
"message": 1
},
"background": {
"activeOnStart": true,
"beginsPattern": "^\\s*\\*\\s+Serving Flask app",
"endsPattern": "^\\s*\\*\\s+Running on"
}
},
"group": "none",
"detail": "Start the Flask development server on port 8080"
},
{
"label": "Start Quote Bridge Listener",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/python",
"args": [
"quote-bridge/listener.py"
],
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
},
"problemMatcher": {
"pattern": {
"regexp": "^\\[.*\\] Quote bridge listener started$",
"message": 0
},
"background": {
"activeOnStart": true,
"beginsPattern": "^\\[.*\\] Quote bridge listener started$",
"endsPattern": "^\\[.*\\] Poll interval:"
}
},
"group": "none",
"detail": "Start the quote bridge helper app (shared file listener)"
}
]
}
+123 -124
View File
@@ -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/
├── app.py # Main application file ├── app.py # Flask app entry point and blueprint registration
├── passenger_wsgi.py # Passenger WSGI entry point for production ├── config.py # Backend/config toggles
├── requirements.txt # Python dependencies ├── data_access.py # Chooses JSON or SQLite backend
├── blueprints/ # Modular routes organized by feature ├── data_access_json.py # JSON-backed data operations
│ ├── __init__.py ├── data_access_sqlite.py # SQLite-backed data operations
│ ├── auth.py # Login/logout/session management ├── models.py # SQLAlchemy models for SQLite mode
│ └── users.py # User CRUD operations ├── blueprints/
├── templates/ # HTML templates │ ├── auth.py # Login, logout, session, location selection
│ ├── login.html │ ├── users.py # User CRUD and password management
── user_manager.html ── products.py # Quiz pages, product APIs, admin pages
└── data/ # Data storage │ └── canvas.py # Layered image fallback API
└── users.json # User accounts with hashed passwords ├── 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) ### Authentication and Session Flow
- Login with username/password - Username/password login
- Session management - Session-backed authentication
- Password hashing (PBKDF2-SHA256, 1M iterations) - Active/inactive user checks
- Login required decorator - Multi-location session support with location selection
- Permission checking: `can_user('manage_users')` - Permission checks via `can_user(...)`
- User status checking (active/inactive)
### User Management (blueprints/users.py) ### User Management
- View all users - List users
- Add new users - Create users
- Delete users - Delete users
- Toggle active/inactive status - Toggle active status
- Change passwords (requires admin verification) - Change passwords
- Location-based access control - Download user data JSON
- Permission system (manage_users, create_quotes, etc.)
- Download users as 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` ### Product Management and Search
- **Password:** `Master` - Advanced search page
- **Permissions:** Full access (manage_users) - 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 ```bash
cd product_app cd app
python app.py ../.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 If product source data changes, use the VS Code tasks from the repository root:
- `/` - Home (redirects to login if not authenticated)
- `/test` - Test page to verify app is running
### Authentication Routes (auth_bp) - `Update Data Files from CSV`
- `/login` - Login page - `Generate Bitwise Data`
- `/logout` - Logout - `Process All Data`
- `/api/login` - POST: Login API
- `/api/session` - GET: Current session info
### User Management Routes (users_bp) These regenerate the JSON files consumed by the quiz and filtering logic.
- `/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
## 📦 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: ## Recommended Next Work
- `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`
### Control Panel Settings 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.
- **Application startup file:** `passenger_wsgi.py` (or `app.py`) 3. Decide whether SQLite should remain optional or become the default backend.
- **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
+104
View File
@@ -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 from blueprints.auth import login_required, get_current_user, can_user
import data_access as da import data_access as da
import os import os
import json
import uuid
from datetime import datetime, timezone
import config
products_bp = Blueprint('products', __name__, url_prefix='/quiz') products_bp = Blueprint('products', __name__, url_prefix='/quiz')
@@ -226,6 +230,106 @@ def get_product_attributes():
'attributes': 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>') @products_bp.route('/css/<path:filename>')
def serve_css(filename): def serve_css(filename):
"""Serve CSS files""" """Serve CSS files"""
+5
View File
@@ -15,6 +15,11 @@ DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'data', 'products.db')}"
# JSON data directory # JSON data directory
DATA_DIR = os.path.join(basedir, 'data') 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 configuration
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SAMESITE = 'Lax'
+148
View File
@@ -10,6 +10,9 @@ let productData = [];
let accessoryData = []; let accessoryData = [];
let bitwiseData = {}; let bitwiseData = {};
// In-page order list for the current browser session
let orderItems = [];
// Track accumulated bit value for URL state // Track accumulated bit value for URL state
let accumulatedBitValue = 0; let accumulatedBitValue = 0;
@@ -625,6 +628,8 @@ function startOver() {
} }
// Clear bit value // Clear bit value
accumulatedBitValue = 0; accumulatedBitValue = 0;
// Clear order list
orderItems = [];
// Clear URL // Clear URL
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
loadContent('start'); loadContent('start');
@@ -916,6 +921,11 @@ function showProductDetail(product) {
<div class="config-field" style="margin-top: 20px;"> <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> <button class="back-button" onclick="addToOrder('${prodCode}')" style="width: 100%; padding: 12px; font-size: 16px;">Add to Order</button>
</div> </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> </div>
<div class="result-details"> <div class="result-details">
@@ -964,6 +974,7 @@ function showProductDetail(product) {
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
updateBreadcrumb(); updateBreadcrumb();
renderOrderList();
// Initialize images based on type // Initialize images based on type
if (useCanvasAPI) { 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 // Go back to results from product detail
function goBackToResults() { function goBackToResults() {
// Find results in history or load it directly // Find results in history or load it directly
+4 -24
View File
@@ -1,29 +1,9 @@
# Required Code Changes for Conditional Navigation # Conditional Navigation and Filtering Status
## Overview ## Purpose
To support conditional material/color questions and dynamic navigation, the following changes are needed in your application code.
--- This document records what was originally proposed for conditional question flow,
what is already implemented, and what still remains.
## 1. JavaScript Changes (`js/script.js`)
### Current State
- Loads `questions.json` statically
- Has hardcoded conditional for `q-material-conditional`
- No product data loading
### Required Changes
#### A. Load Multiple Data Files
```javascript
// At the top of script.js - modify loadQuestionData() to load both files
let questionData = {};
let productData = [];
let accessoryData = [];
let bitwiseData = {};
let accumulatedBitValue = 0;
function init() {
// Load all data files // Load all data files
Promise.all([ Promise.all([
fetch('data/navigation.json').then(r => r.json()), fetch('data/navigation.json').then(r => r.json()),
+88 -155
View File
@@ -1,184 +1,117 @@
# 🎉 Flask Web Application Successfully Created! # Product Finder Project
Your HTML/JavaScript quiz has been converted to a Python Flask web application. ## What This Project Is
## ✅ What Was Created This repository contains a Flask-based Product Finder application used to guide users
through product selection and provide product detail, accessory, search, and admin
workflows.
### Core Application Files The current application is no longer a simple Flask conversion of a static quiz.
- **app.py** - Main Flask application with routes It now includes:
- **wsgi.py** - Production WSGI entry point - modular blueprints
- **config.py** - Configuration management - login and session management
- **requirements.txt** - Python dependencies - location-aware access
- product management APIs
- shareable quiz URLs
- layered image preview support
- an optional SQLite backend
### Templates & Static Files ## Fastest Way To Run It
- **templates/** - HTML files (index.html, index2.html, 404.html)
- **css/** - Stylesheets (styles.css)
- **js/** - JavaScript files (script.js)
- **images/** - Product images folder
### Documentation & Utilities From the repository root:
- **README.md** - Complete documentation
- **QUICKSTART.md** - Quick start guide
- **run.bat** - Windows startup script
- **.gitignore** - Git ignore file
- **.env.example** - Environment template
## 🚀 Quick Start
### Option 1: Using Batch File (Windows)
Double-click `run.bat` to install dependencies and start the server.
### Option 2: Manual Start
```bash ```bash
# Install dependencies cd app
pip install -r requirements.txt ../.venv/bin/python app.py
# Run the application
python app.py
``` ```
### Access Your Application Then open:
Open in browser: **http://localhost:8080/quiz** - `http://127.0.0.1:8080/`
- `http://127.0.0.1:8080/quiz/`
## 🌐 Current Status ## Recommended VS Code Tasks
✅ Flask is currently running on: http://localhost:8080 Use the built-in tasks from the repository root:
✅ Quiz page: http://localhost:8080/quiz
✅ Debug mode: Enabled (auto-reloads on file changes)
## 📁 Project Structure - `Start Flask Server`
- `Process All Data`
- `Update Data Files from CSV`
- `Generate Bitwise Data`
``` `Process All Data` runs the two data-generation steps in sequence.
project/
├── app.py ← Main Flask app (START HERE)
├── wsgi.py ← For production deployment
├── config.py ← Settings & configuration
├── requirements.txt ← Python packages needed
├── run.bat ← Windows startup script
├── templates/ ← HTML files (Flask requires this folder)
│ ├── index.html ← Main landing page
│ ├── index2.html ← Quiz page
│ └── 404.html ← Error page
├── css/ ← Stylesheets
│ └── styles.css ← Main CSS file
├── js/ ← JavaScript files
│ └── script.js ← Quiz logic and data
└── images/ ← Product images (add your images here)
```
## 🎯 Key Features ## Main Application Areas
**Dynamic Routing** - Flask handles all page requests ### Authentication
**Static File Serving** - CSS, JS, and images properly served - login page
**Error Handling** - Custom 404 page - logout
**API Endpoints** - Ready for future backend features - session info endpoint
**Production Ready** - WSGI config included - location selection for multi-location users
**All Original Features** - Quiz, forms, conditional logic, memory storage
## 🔧 Customization ### Product Finder
- guided quiz flow at `/quiz/`
- advanced search page
- product list page
- product manager page
- direct product links at `/quiz/product/<product_code>`
### Change Port ### Images and Previews
Edit `app.py`, line with `app.run()`: - flat product images
```python - layered image previews
app.run(debug=True, host='0.0.0.0', port=YOUR_PORT) - Canvas API fallback routes for hierarchical image lookup
``` - layer transforms for mirrored door/hardware previews where configured
### Add New Routes ### Data Layer
In `app.py`: - JSON is the default runtime backend
```python - SQLite support exists behind a config flag
@app.route('/your-page') - frontend quiz data is driven by JSON files in `app/data/`
def your_page():
return render_template('your-page.html')
```
### Update Quiz Questions ## Important Files
Edit `js/script.js` - modify the `questionData` object
### Change Styling - `app/app.py` - application entry point
Edit `css/styles.css` - `app/blueprints/auth.py` - authentication/session logic
- `app/blueprints/users.py` - user management routes
- `app/blueprints/products.py` - quiz pages, product APIs, admin pages
- `app/blueprints/canvas.py` - Canvas API image routes
- `app/static/js/script.js` - quiz flow, URL state, search, previews
- `app/data/navigation.json` - quiz navigation structure
- `app/data/products.json` - product catalog data
- `app/data/accessories.json` - accessory data
- `app/data/product_bitwise.json` - bitwise lookup data
## 📦 Deployment Options ## Current Known Status
### 1. Web Panels (cPanel, Plesk) Implemented:
- Upload all files - modular Flask app structure
- Install requirements: `pip install -r requirements.txt` - login/session flow
- Point to `wsgi.py` - location-aware permissions
- user management
- product CRUD/search APIs
- quiz result filtering
- shareable URL state
- product deep links
- Canvas API image fallback system
### 2. Cloud Platforms Still incomplete:
- **Heroku**: Add `Procfile` and push to Git - fully generic conditional question skipping based on navigation metadata
- **PythonAnywhere**: Upload and configure WSGI - automated tests
- **AWS/Azure**: Use with Gunicorn - decision on whether SQLite should become the default backend
### 3. Docker ## If You Are Updating Product Data
See README.md for Dockerfile example
### 4. Production Server 1. Update the source CSV used by the data-generation scripts.
```bash 2. Run `Process All Data`.
pip install gunicorn 3. Restart the Flask server if needed.
gunicorn -w 4 -b 0.0.0.0:8080 wsgi:app 4. Verify quiz results and product detail views.
```
## 🐛 Troubleshooting ## If You Are Picking Up Development
### Port Already in Use Start with these files:
Change port in app.py or kill the process:
```bash
# Windows
netstat -ano | findstr :8080
taskkill /PID <PID> /F
```
### Templates Not Found 1. `app/app.py`
Make sure HTML files are in `templates/` folder 2. `app/blueprints/products.py`
3. `app/static/js/script.js`
4. `app/data/navigation.json`
5. `app/config.py`
### Static Files Not Loading Those files define the main routing, quiz behavior, and backend mode.
Check that `css/` and `js/` folders exist in root directory
### Module Not Found
```bash
pip install -r requirements.txt
```
## 📚 Next Steps
1. **Test the application** - Visit http://localhost:8080/quiz
2. **Add your product images** - Place images in `images/` folder
3. **Customize the quiz** - Edit `js/script.js`
4. **Update styling** - Modify `css/styles.css`
5. **Deploy** - Follow README.md deployment guide
## 🔒 Security Notes for Production
Before deploying to production:
- [ ] Set `DEBUG = False` in config.py
- [ ] Change `SECRET_KEY` to a strong random value
- [ ] Use environment variables for sensitive data
- [ ] Set up HTTPS/SSL
- [ ] Use a production WSGI server (Gunicorn, uWSGI)
- [ ] Configure proper logging
- [ ] Set up database backups (if using a database)
## 💡 Tips
- Flask auto-reloads when you edit files (in debug mode)
- Press `Ctrl+C` to stop the server
- Check terminal for error messages
- Use browser DevTools to debug JavaScript
- All original quiz functionality is preserved
## 📞 Need Help?
- Check **README.md** for detailed documentation
- Check **QUICKSTART.md** for simple instructions
- Review Flask logs in terminal for errors
- Test API endpoints using browser or Postman
---
**Your Flask app is ready to use! 🎊**
Visit: http://localhost:8080/quiz
+70 -206
View File
@@ -1,230 +1,94 @@
# Implementation Summary - URL State Management # URL State Management
## 🎯 What You Get ## Current Status
Your application will support **shareable URLs** that preserve: URL state management is implemented in the current frontend.
- ✅ User's navigation progress (bitwise value)
- ✅ Current question position
- ✅ Direct product links
- ✅ Browser refresh without data loss
- ✅ Browser back/forward buttons
- ✅ Copy/share functionality
## 🔗 URL Examples The quiz now supports:
- Shareable URLs for in-progress quiz state
- Direct links to product detail views
- Browser refresh without losing the current route context
- Browser back/forward support
- Share buttons on results and product-detail views
``` ## Current URL Parameters
# Initial state (no params)
https://yoursite.com/
# After selecting Door + Storm Door + Aluminum - `b` - accumulated bitwise state for the user selections
https://yoursite.com/?b=16401&q=q-dimensions - `q` - current question key or `results`
↑ ↑ - `p` - product code for direct product-detail views
| Current question
Accumulated bit value (Door + Aluminum + Storm Door)
# Viewing specific product Examples:
https://yoursite.com/?p=BGIST&b=16401
Product code
# Bit value 16401 decodes to: ```text
# Bit 0 (1) = Door /quiz/?b=16401&q=results
# Bit 4 (16) = Aluminum /quiz/?p=404&b=16401
# Bit 14 (16384) = Storm Door subtype /quiz/product/404
# Total: 1 + 16 + 16384 = 16401
``` ```
## 📋 Implementation Checklist ## What Is Implemented
### Phase 1: Core URL Functionality (Essential) ### Core State Handling
- [ ] Add bitwise data loading to `init()` function - `accumulatedBitValue` is tracked in the frontend
- [ ] Add `accumulatedBitValue` variable - `BIT_DEFINITIONS` constants are present in `script.js`
- [ ] Add `BIT_DEFINITIONS` constants - `product_bitwise.json` is loaded during startup
- [ ] Implement `updateURL()` function - `updateBitValue()` updates the accumulated bitmask from answer filters
- [ ] Implement `updateBitValue()` function - `updateURL()` writes `b` and `q` params without reloading the page
- [ ] Update `handleAnswer()` to call `updateBitValue()` - `initFromURL()` restores the app from query params or direct product routes
- [ ] Implement `initFromURL()` function - `restoreStateFromBitValue()` rebuilds the key quiz selections from the bitmask
- [ ] Implement `restoreStateFromBitValue()` function - `startOver()` clears the URL state
- [ ] Update `startOver()` to clear URL
### Phase 2: Product Deep Linking (Recommended) ### Product Deep Linking
- [ ] Implement `showProductByCode()` function - Product cards open detail views through `showProductByCode()`
- [ ] Update `showProductDetail()` to update URL - Product detail views push the selected product code into the URL
- [ ] Make product cards clickable - Direct query-string product links are supported with `?p=<code>`
- [ ] Add URL params when viewing products - Direct Flask routes are supported with `/quiz/product/<product_code>`
### Phase 3: Share Functionality (Nice to Have) ### Share and Navigation Support
- [ ] Implement `shareCurrentPage()` function - `shareCurrentPage()` copies the current URL to the clipboard when supported
- [ ] Add `showNotification()` helper - A notification helper is shown after successful copy
- [ ] Add "Share" buttons to UI - Share buttons exist on results and product detail screens
- [ ] Add CSS for notification animations - A `popstate` listener handles browser back/forward navigation
### Phase 4: Browser Navigation (Polish) ## Implementation Notes
- [ ] Add `popstate` event listener
- [ ] Test browser back button
- [ ] Test browser forward button
- [ ] Test refresh behavior
## 🚀 Quick Start The current implementation restores the primary quiz state used by filtering:
- base type
- subtype
- material
- color
### Step 1: Add Global Variables That is enough to reopen result sets and product-detail pages consistently.
Add to top of `js/script.js`:
```javascript
let accumulatedBitValue = 0;
let bitwiseData = {};
const BIT_DEFINITIONS = { Dimension data is still primarily driven by the current session flow rather than fully
'base_door': 0, 'base_window': 1, reconstructed from the URL alone.
'material_aluminum': 4, 'material_vinyl': 5,
'color_black': 6, 'color_white': 7, 'color_bronze': 8,
'color_tan': 9, 'color_mill': 10, 'color_sandstone': 11,
'subtype_patio_door': 12, 'subtype_primary_window': 13,
'subtype_storm_door': 14, 'subtype_storm_window': 15
};
```
### Step 2: Load Bitwise Data ## Validation Checklist
Update `init()` to load `product_bitwise.json`:
```javascript
Promise.all([
fetch('data/navigation.json').then(r => r.json()),
fetch('data/products.json').then(r => r.json()),
fetch('data/accessories.json').then(r => r.json()),
fetch('data/product_bitwise.json').then(r => r.json()) // ADD THIS
])
.then(([questions, products, accessories, bitwise]) => {
// ... existing code ...
// Index bitwise data - [x] Bitwise data loads at startup
bitwiseData = {}; - [x] URLs update as quiz answers are selected
bitwise.forEach(item => { - [x] Results pages can be shared with `b` and `q`
bitwiseData[item.PROD_CODE] = item.BIT_VALUE; - [x] Product pages can be shared with `p`
}); - [x] Browser refresh restores route context
- [x] Browser back button is handled in code
- [x] Browser forward button is handled in code
- [ ] Full manual regression testing across all quiz paths
// Check for URL state ## Known Limits
initFromURL();
});
```
### Step 3: Add URL Functions - URL restoration is designed around the current bitwise filter model, not a complete
Copy these three key functions from [URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md): replay of every form interaction.
1. `updateURL()` - Updates browser URL - Conditional question metadata exists in the navigation data, but the frontend does
2. `updateBitValue()` - Calculates bit value from selection not yet use a fully generic conditional-resolution engine.
3. `initFromURL()` - Restores state from URL on load
### Step 4: Update handleAnswer ## Related Files
Add one line to `handleAnswer()`:
```javascript
function handleAnswer(currentKey, answerValue, answerIndex) {
// ... existing code ...
updateBitValue(answerObject); // ADD THIS LINE - `app/static/js/script.js`
- `app/data/product_bitwise.json`
- `information/BITWISE_USAGE_GUIDE.md`
- `information/REQUIRED_CODE_CHANGES.md`
const nextKey = resolveNextQuestion(currentKey, answerValue, answerObject); ## Recommended Next Steps
history.push(nextKey);
updateURL(nextKey); // ADD THIS LINE 1. Manually test refresh and back/forward behavior on all major quiz branches.
2. Extend restoration if dimension-specific deep linking becomes a requirement.
loadContent(nextKey); 3. Finish generic conditional navigation so URL restoration and question skipping use the same rules.
}
```
### Step 5: Test
1. Run your app
2. Navigate through questions
3. Check URL updates after each selection
4. Copy URL and paste in new tab
5. Should restore to same state ✅
## 📖 Documentation Files
All details are in these files:
- **[URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md)** - Complete implementation guide
- **[REQUIRED_CODE_CHANGES.md](REQUIRED_CODE_CHANGES.md)** - Updated with URL features
- **[BITWISE_USAGE_GUIDE.md](BITWISE_USAGE_GUIDE.md)** - How bitwise system works
## 🔧 Key Functions Reference
| Function | Purpose | When Called |
|----------|---------|-------------|
| `initFromURL()` | Read URL params on page load | Once at startup |
| `updateURL()` | Write current state to URL | After each answer |
| `updateBitValue()` | Add selection to bit value | After each answer |
| `restoreStateFromBitValue()` | Decode bit value to selections | On page load from URL |
| `shareCurrentPage()` | Copy URL to clipboard | User clicks "Share" |
## 🎨 URL Format Design
### Why Bitwise?
- **Compact**: `?b=16401` vs `?door=true&aluminum=true&storm=true`
- **Fast**: Single integer comparison
- **Flexible**: Easy to add new attributes
- **Shareable**: Short URLs
- **Reversible**: Can decode back to selections
### Parameters Chosen
- `b` = bit value (short, recognizable)
- `q` = question (short, clear purpose)
- `p` = product (short, clear purpose)
### Alternative Considered
Could use hash fragments instead:
```
https://yoursite.com/#/door/storm-door/aluminum
```
But query params are better for:
- Server-side rendering
- Analytics tracking
- SEO (if products are indexed)
## ⚠️ Important Notes
1. **Bit Definitions Must Match**: The `BIT_DEFINITIONS` in JavaScript must match the Python script
2. **URL Length Limits**: URLs have practical limits (~2000 chars), but bit values are small
3. **No Sensitive Data**: Don't put sensitive info in URL (it's visible and loggable)
4. **Test Thoroughly**: Test all navigation paths and browser actions
## 🐛 Troubleshooting
### URL Not Updating
- Check `updateURL()` is called after `handleAnswer()`
- Check browser console for errors
- Verify `accumulatedBitValue` is being set
### State Not Restoring
- Check `initFromURL()` is called in `init()`
- Verify URL has `b` and `q` parameters
- Check `restoreStateFromBitValue()` logic
### Wrong Bit Values
- Verify `BIT_DEFINITIONS` matches `generate_bitwise_helper.py`
- Check `bitwise_legend.json` for correct bit positions
- Use browser console: `console.log(accumulatedBitValue)`
### Share Button Not Working
- Check clipboard API support: `navigator.clipboard`
- Fallback to `prompt()` for older browsers
- Test in HTTPS (clipboard API requires secure context)
## 📈 Benefits Summary
| Feature | User Benefit | Business Benefit |
|---------|-------------|------------------|
| Shareable URLs | Share configurations | Viral marketing |
| Bookmarks | Save favorites | Return visitors |
| Refresh-safe | No data loss | Better UX |
| Deep linking | Direct to product | SEO indexing |
| Browser nav | Back/forward works | Expected behavior |
| Short URLs | Easy to share | More sharing |
## 🎯 Next Steps
1. ✅ Implement Phase 1 (core URL functionality)
2. ✅ Test basic URL state restoration
3. ✅ Add Phase 2 (product deep linking)
4. ✅ Add Phase 3 (share buttons)
5. ✅ Add Phase 4 (browser navigation)
6. ✅ Test all scenarios thoroughly
7. ✅ Add analytics tracking (optional)
Good luck! 🚀
+379
View File
@@ -0,0 +1,379 @@
# Quote Bridge Automation Design
## Goal
Extend `quote-bridge` so it can do more than move quote files. The target design is a helper app that:
1. watches for quote JSON files,
2. selects the correct location profile,
3. launches the DOS app through DOSBox,
4. simulates user input,
5. enters quote data into the DOS application,
6. records success or failure.
## Recommended Design
Keep the automation separated into four layers:
1. `quote` JSON
- Business data coming from the Flask app.
2. `profile` JSON
- Machine and location configuration.
3. `instruction set` JSON
- The keystroke workflow for the DOS app.
4. `executor`
- Python code that reads the quote, profile, and instruction set and sends keys to the DOS app.
This separation keeps machine config, business data, and workflow logic from getting mixed together.
## Why Not Put Everything In The Profile
The location profile should define:
- where the remote share is mounted,
- which DOS app folder to use,
- which DOSBox binary/config to use,
- which instruction set to execute,
- which location-specific values apply.
It should not hold the full keystroke workflow. If the whole flow lives in the profile, each location file becomes hard to maintain.
## Recommended Folder Structure
```text
quote-bridge/
├── listener.py
├── dosbox_processor.py
├── profiles/
│ ├── default.json
│ ├── IOLA.json
│ ├── KC.json
│ └── LINDS.json
├── instruction-sets/
│ ├── create-quote-v1.json
│ └── create-quote-iola-v1.json
└── value-maps/
└── optional-future-files.json
```
## Profile Responsibilities
Profiles should define environment and per-location values.
Example:
```json
{
"profileName": "IOLA",
"dosboxBin": "dosbox-x",
"mountPath": "/mnt/iola-cgw",
"mountDrive": "C",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"instructionSet": "create-quote-v1",
"variables": {
"taxCode": "ABC",
"locationCode": "IOLA"
}
}
```
Use the profile for:
- DOSBox path/config
- remote mount path
- DOS working directory
- instruction set selection
- per-location values like tax code, warehouse, location code, salesperson, or other defaults
## Instruction Set Responsibilities
Instruction sets should define the interactive workflow.
Instead of using only freeform key strings, use a small action DSL.
Example:
```json
{
"name": "create-quote-v1",
"version": 1,
"steps": [
{
"action": "text",
"value": "${secrets.username}"
},
{
"action": "key",
"value": "ENTER"
},
{
"action": "text",
"value": "${secrets.password}"
},
{
"action": "key",
"value": "ENTER"
},
{
"action": "text",
"value": "${runtime.today}"
},
{
"action": "key",
"value": "ENTER"
},
{
"action": "loop",
"source": "items",
"steps": [
{
"action": "text",
"value": "${item.productCode}"
},
{
"action": "key",
"value": "ENTER"
},
{
"action": "text",
"value": "${item.quantity}"
},
{
"action": "key",
"value": "ENTER"
}
]
}
]
}
```
## Recommended Action Types
The executor should support a small set of explicit actions:
- `text`
- `key`
- `combo`
- `sleep`
- `wait`
- `loop`
- `conditional`
- `set-variable`
- optional future `assert`
This is better than a plain `keys: "abc"` design because real workflows need timing, branching, loops, and variable substitution.
## Special Keys And Combos
Use symbolic key names for special keys:
- `ENTER`
- `ESC`
- `UP`
- `DOWN`
- `LEFT`
- `RIGHT`
- `TAB`
- `BACKSPACE`
- `F1` through `F12`
For combos, prefer an array form.
Example:
```json
{
"action": "combo",
"keys": ["SHIFT", "~"]
}
```
That is less ambiguous than a single string.
## Handling Location-Specific Differences
There are two kinds of per-location differences.
### 1. Data Differences
Examples:
- tax value
- warehouse code
- location code
- default salesperson
These should stay in the profile:
```json
{
"variables": {
"taxCode": "IOLA-TAX",
"warehouseCode": "01"
}
}
```
### 2. Flow Differences
Examples:
- one location needs two extra keys
- one location lands on a different screen
- one location skips a field
If the difference is small, use the same instruction set and substitute different values.
If the difference is structural, create a separate instruction set.
Examples:
- `create-quote-v1`
- `create-quote-iola-v1`
That is cleaner than putting location branches on every single step.
## Suggested Runtime Context
When the executor runs, it should build a context containing:
- `quote`
- `items`
- `profile.variables`
- `runtime.today`
- `secrets.username`
- `secrets.password`
Then placeholders such as `${item.productCode}` or `${profile.variables.taxCode}` can be resolved during execution.
## How The Executor Should Work
At runtime:
1. Read quote file.
2. Read `createdBy.location` from the quote.
3. Load matching location profile.
4. Load the instruction set named by the profile.
5. Build runtime context.
6. Launch DOSBox.
7. Focus the DOSBox window if needed.
8. Execute steps sequentially.
9. Log each action.
10. On success, move file to `processed`.
11. On failure, move file to `failed`.
## How To Actually Send Keys
DOSBox runs the DOS app, but a separate automation backend is usually needed to send dynamic interactive keys.
On Ubuntu, the likely choices are:
- `xdotool` for X11
- `ydotool` for Wayland
That means the likely stack is:
- DOSBox-X runs the DOS app
- Python executor controls the DOSBox window
- `xdotool` or `ydotool` sends keystrokes
## Installation Requirements
Minimum:
- DOSBox or preferably DOSBox-X
- access to the DOS application files from Ubuntu
- Python
Useful additions:
- `xdotool` for X11-based key injection
- `ydotool` for Wayland-based key injection if needed
- `cifs-utils` for mounting Windows shares on Ubuntu
## Security Note
Do not store real usernames and passwords in committed profile JSON files.
Better options:
- environment variables
- a local untracked secrets JSON file
- a machine-local config file ignored by git
Example approach:
```json
{
"usernameEnv": "IOLA_DOS_USERNAME",
"passwordEnv": "IOLA_DOS_PASSWORD"
}
```
## Constraints And Risks
The hardest part is not the JSON format. The difficult part is making the execution deterministic enough that the DOS app is always on the expected screen.
Main risks:
- timing drift
- focus problems
- unexpected dialogs
- location-specific screen differences
- item mapping differences between web app and DOS app
Because of that, start small and keep logging detailed.
## Recommended First Implementation Scope
Start with a narrow slice:
1. one instruction set for login + single item entry
2. one location profile
3. one automation backend using `xdotool`
4. fixed waits only, no screen-reading yet
5. detailed action logging
After that works, extend to:
1. multi-item loops
2. per-location variables
3. per-location alternate flows
4. optional checkpoints/assertions
5. result files with external quote/order references
## Next Steps For quote-bridge
### Phase 1: Structure
1. Add `instruction-sets/` folder.
2. Add one starter instruction set file such as `create-quote-v1.json`.
3. Extend location profiles to include `instructionSet` and optional `variables`.
4. Add a local secrets mechanism for usernames/passwords.
### Phase 2: Execution Engine
1. Create an automation executor module.
2. Implement action handlers for:
- `text`
- `key`
- `combo`
- `sleep`
- `loop`
3. Add placeholder resolution for quote/profile/runtime values.
4. Add step-by-step logging.
### Phase 3: Input Backend
1. Decide whether Ubuntu is running X11 or Wayland.
2. If X11, install and integrate `xdotool`.
3. If Wayland, evaluate `ydotool`.
4. Add DOSBox window targeting/focus handling.
### Phase 4: DOS App Integration
1. Build a real `BRIDGE.BAT` startup contract.
2. Define the login sequence.
3. Define single-item quote entry.
4. Test with one known product and one location.
### Phase 5: Expansion
1. Add multi-item support.
2. Add value/code translation maps if needed.
3. Add location-specific instruction set variants only where necessary.
4. Add result file generation with success/failure metadata.
## Recommendation
Keep workflow logic in instruction sets, keep environment/location settings in profiles, and keep secrets out of tracked JSON files.
That will give `quote-bridge` the best chance of staying maintainable as the DOS automation grows.
+269
View File
@@ -0,0 +1,269 @@
# Quote Bridge (Listener App)
This is a separate Python app that runs independently from the Flask app.
It watches for quote files in `shared/outgoing` and can run in two modes:
- `move` (default): move incoming files to `shared/processed`
- `dosbox`: run DOSBox command/script per quote file, then move to
`shared/processed` on success or `shared/failed` on error
## Run
From repository root:
```bash
./.venv/bin/python quote-bridge/listener.py
```
Or from this folder:
```bash
../.venv/bin/python listener.py
```
## Install DOSBox On Ubuntu
You will need DOSBox installed on the Ubuntu machine if you want to use
`QUOTE_PROCESSOR_MODE=dosbox`.
### Option 1: Standard DOSBox
```bash
sudo apt update
sudo apt install dosbox
```
### Option 2: DOSBox-X
If available in your environment or package source, DOSBox-X is a better choice
for many protected-mode DOS applications.
Example:
```bash
sudo apt update
sudo apt install dosbox-x
```
If your distro does not provide `dosbox-x`, install it from your preferred
package source and then update `dosboxBin` in your profile.
## Environment Variables
- `QUOTE_OUTGOING_DIR`: Source directory to watch
- `QUOTE_PROCESSED_DIR`: Destination directory for moved files
- `QUOTE_FAILED_DIR`: Destination for failed quote files
- `QUOTE_PROCESSOR_MODE`: `move` or `dosbox` (default: `move`)
- `QUOTE_PROFILE_DIR`: Directory containing per-location DOSBox profiles
- `QUOTE_POLL_INTERVAL_SECONDS`: Poll interval (default: `2`)
### DOSBox Mode Variables
When `QUOTE_PROCESSOR_MODE=dosbox`, these variables are used:
- `DOSBOX_BIN`: DOSBox executable (default: `dosbox`)
- `DOSBOX_CONF`: Optional DOSBox config file path
- `DOSBOX_MOUNT_PATH`: Host path mounted into DOS (default: shared parent path)
- `DOSBOX_MOUNT_DRIVE`: DOS drive letter (default: `C`)
- `DOSBOX_BRIDGE_COMMAND`: DOS command/batch to run (default: `BRIDGE.BAT`)
- `DOSBOX_EXTRA_COMMANDS`: Extra DOS commands separated by `;`
- `DOSBOX_TIMEOUT_SECONDS`: Timeout for one DOSBox run (default: `120`)
- `DOSBOX_NOCONSOLE`: `true/false` to add `-noconsole` flag
## Profiles
When `QUOTE_PROCESSOR_MODE=dosbox`, the helper looks at the quote file's
`createdBy.location` field and tries to load a matching profile from:
- `quote-bridge/profiles/<LOCATION>.json`
- then `quote-bridge/profiles/default.json`
Included sample profiles:
- `quote-bridge/profiles/IOLA.json`
- `quote-bridge/profiles/KC.json`
- `quote-bridge/profiles/LINDS.json`
- `quote-bridge/profiles/default.json`
### Where To Enter The Remote Path
For each location profile, set:
- `mountPath`: the Linux path where that remote PC's shared drive/folder is mounted
Example:
```json
{
"mountPath": "/mnt/iola-cgw"
}
```
That is the main place to enter the mapped path.
### Where To Enter The DOS App Folder
For each location profile, set:
- `workingDirectory`: the DOS folder under the mounted share that contains the app
Example:
```json
{
"workingDirectory": "CGWAPP"
}
```
If the DOS app lives at:
```text
/mnt/iola-cgw/CGWAPP
```
then use:
- `mountPath = /mnt/iola-cgw`
- `workingDirectory = CGWAPP`
### Where To Enter The DOS Entry Script
For each location profile, set:
- `bridgeCommand`: the DOS-side batch/script/executable to run after mounting and changing directory
Example:
```json
{
"bridgeCommand": "BRIDGE.BAT"
}
```
## Suggested Setup For Multiple PCs
If each location runs from a different PC, the clean pattern is:
1. Mount each remote PC's application share on Ubuntu.
2. Put that mount path into the matching location profile.
3. Keep one profile per location.
4. Let the helper auto-select the profile based on the quote's current location.
Example mapping:
- `IOLA` -> `/mnt/iola-cgw`
- `KC` -> `/mnt/kc-cgw`
- `LINDS` -> `/mnt/linds-cgw`
## Mounting Remote PC Shares On Ubuntu
If the DOS app is stored on remote Windows PCs, Ubuntu needs those folders
mounted locally first.
Example mount points:
```bash
sudo mkdir -p /mnt/iola-cgw
sudo mkdir -p /mnt/kc-cgw
sudo mkdir -p /mnt/linds-cgw
```
Example CIFS mount command:
```bash
sudo mount -t cifs //REMOTE-PC/SharedFolder /mnt/iola-cgw \
-o username=YOUR_USER,password=YOUR_PASSWORD,uid=$(id -u),gid=$(id -g)
```
Replace:
- `REMOTE-PC` with the Windows machine name or IP
- `SharedFolder` with the shared folder name
- `YOUR_USER` and `YOUR_PASSWORD` with Windows credentials
After the mount is working, put that Linux mount path into the matching profile
as `mountPath`.
## Example .env-Style Setup
You can export variables in the shell before starting the listener.
Example:
```bash
export QUOTE_PROCESSOR_MODE=dosbox
export QUOTE_PROFILE_DIR=/home/jsalmon/Documents/git/quote-builder/quote-bridge/profiles
./.venv/bin/python quote-bridge/listener.py
```
If you prefer, create a small shell script such as `start-quote-bridge.sh` that
exports these values and starts the listener.
## Sample Profile Fields
Example profile:
```json
{
"profileName": "IOLA",
"dosboxBin": "dosbox-x",
"mountDrive": "C",
"mountPath": "/mnt/iola-cgw",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"extraCommands": [
"SET CLIPPER=F200"
],
"timeoutSeconds": 180,
"noConsole": false
}
```
## DOS-Side BRIDGE.BAT Contract
The helper currently assumes a DOS-side entry script like:
```bat
BRIDGE.BAT "C:\OUTGOING\20260629-153012-ab12cd34.json"
```
That means `BRIDGE.BAT` should accept the quote file path as `%1`.
Minimal example:
```bat
@echo off
rem %1 is the quote JSON file path inside DOSBox
echo Processing quote file: %1
rem Start your DOS app here and pass or import the file as needed
rem Example only:
rem MYAPP.EXE %1
```
If your DOS app requires a different startup sequence, change `bridgeCommand`
in the location profile.
## End-To-End Flow
1. User clicks `Quote` in the Flask app.
2. Flask writes a quote JSON file to `shared/outgoing`.
3. Listener sees the new file.
4. Listener selects profile based on `createdBy.location` in the quote.
5. In `dosbox` mode, listener launches DOSBox using that profile.
6. DOSBox runs the configured `bridgeCommand`.
7. File moves to:
- `shared/processed` on success
- `shared/failed` on failure
## Current Behavior
- Processes `.json` files only
- Ignores temporary files such as `*.tmp`
- Appends a timestamp suffix if a destination filename already exists
- In `move` mode, files are moved from outgoing to processed
- In `dosbox` mode, each file is passed to DOSBox first, then moved to:
- `processed` on success
- `failed` on failure
Use `Ctrl+C` to stop the listener.
+183
View File
@@ -0,0 +1,183 @@
"""DOSBox processing helpers for quote bridge.
This module provides a lightweight wrapper around DOSBox invocation so the
listener can process quote files using a DOS-side script.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Any, Callable, Dict, List
Logger = Callable[[str], None]
def _default_path(*parts: str) -> str:
base_dir = Path(__file__).resolve().parent
return str(base_dir.joinpath(*parts))
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _split_commands(raw: str) -> List[str]:
return [cmd.strip() for cmd in raw.split(";") if cmd.strip()]
def _load_quote_payload(quote_file: Path) -> Dict[str, Any]:
with open(quote_file, 'r', encoding='utf-8') as f:
return json.load(f)
def _profile_dir() -> Path:
return Path(os.environ.get("QUOTE_PROFILE_DIR", _default_path("profiles")))
def _profile_candidates(location: str) -> List[Path]:
profile_dir = _profile_dir()
candidates: List[Path] = []
if location:
candidates.append(profile_dir / f"{location.upper()}.json")
candidates.append(profile_dir / f"{location.lower()}.json")
candidates.append(profile_dir / "default.json")
return candidates
def _load_profile_for_quote(quote_file: Path, logger: Logger) -> Dict[str, Any]:
payload = _load_quote_payload(quote_file)
location = str(payload.get('createdBy', {}).get('location', '') or '').strip()
for candidate in _profile_candidates(location):
if candidate.exists():
with open(candidate, 'r', encoding='utf-8') as f:
profile = json.load(f)
profile['_profileFile'] = str(candidate)
profile['_quoteLocation'] = location
logger(f"Using DOSBox profile: {candidate}")
return profile
logger("No DOSBox profile file found; using environment defaults")
return {
'_profileFile': None,
'_quoteLocation': location,
}
def _profile_bool(profile: Dict[str, Any], key: str, env_name: str, default: bool = False) -> bool:
value = profile.get(key)
if value is None:
return _env_bool(env_name, default=default)
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _profile_string(profile: Dict[str, Any], key: str, env_name: str, default: str = "") -> str:
value = profile.get(key)
if value is None or value == "":
return os.environ.get(env_name, default)
return str(value)
def _profile_float(profile: Dict[str, Any], key: str, env_name: str, default: float) -> float:
value = profile.get(key)
if value is None or value == "":
return float(os.environ.get(env_name, str(default)))
return float(value)
def _profile_commands(profile: Dict[str, Any], key: str, env_name: str) -> List[str]:
value = profile.get(key)
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return _split_commands(value)
return _split_commands(os.environ.get(env_name, ""))
def _quote_path_for_dos(path: Path, mount_path: Path, drive: str) -> str:
drive_letter = (drive or "C").strip().upper()[:1]
try:
rel = path.resolve().relative_to(mount_path.resolve())
rel_str = str(rel).replace("/", "\\")
return f"{drive_letter}:\\{rel_str}"
except Exception:
# Fallback to absolute path transformed to backslashes.
return str(path.resolve()).replace("/", "\\")
def run_dosbox_for_quote(quote_file: Path, logger: Logger) -> None:
"""Run DOSBox and execute bridge command for one quote file.
Required environment values are provided with sensible defaults, but you'll
typically set these in deployment:
- DOSBOX_BIN
- DOSBOX_MOUNT_PATH
- DOSBOX_BRIDGE_COMMAND
"""
profile = _load_profile_for_quote(quote_file, logger)
dosbox_bin = _profile_string(profile, 'dosboxBin', 'DOSBOX_BIN', 'dosbox')
mount_path = Path(_profile_string(profile, 'mountPath', 'DOSBOX_MOUNT_PATH', str(quote_file.parent.parent)))
mount_drive = _profile_string(profile, 'mountDrive', 'DOSBOX_MOUNT_DRIVE', 'C')
working_directory = _profile_string(profile, 'workingDirectory', 'DOSBOX_WORKING_DIRECTORY', '')
bridge_command = _profile_string(profile, 'bridgeCommand', 'DOSBOX_BRIDGE_COMMAND', 'BRIDGE.BAT')
dosbox_conf = _profile_string(profile, 'dosboxConf', 'DOSBOX_CONF', '') or None
dosbox_timeout = _profile_float(profile, 'timeoutSeconds', 'DOSBOX_TIMEOUT_SECONDS', 120.0)
dosbox_no_console = _profile_bool(profile, 'noConsole', 'DOSBOX_NOCONSOLE', default=False)
dosbox_extra_commands = _profile_commands(profile, 'extraCommands', 'DOSBOX_EXTRA_COMMANDS')
quote_path_for_dos = _quote_path_for_dos(quote_file, mount_path, mount_drive)
# Build DOS command script.
dos_commands = [
f"mount {mount_drive} \"{mount_path}\"",
f"{mount_drive}",
]
if working_directory:
dos_commands.append(f"cd {working_directory}")
dos_commands.extend(dosbox_extra_commands)
dos_commands.append(f"{bridge_command} \"{quote_path_for_dos}\"")
dos_commands.append("exit")
cmd = [dosbox_bin]
if dosbox_conf:
cmd.extend(["-conf", dosbox_conf])
if dosbox_no_console:
cmd.append("-noconsole")
for line in dos_commands:
cmd.extend(["-c", line])
logger(f"DOSBox command: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
check=False,
text=True,
capture_output=True,
timeout=dosbox_timeout,
)
except FileNotFoundError as exc:
raise RuntimeError(f"DOSBox executable not found: {dosbox_bin}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"DOSBox timed out after {dosbox_timeout} seconds"
) from exc
if result.stdout:
logger(f"DOSBox stdout:\n{result.stdout.strip()}")
if result.stderr:
logger(f"DOSBox stderr:\n{result.stderr.strip()}")
if result.returncode != 0:
raise RuntimeError(f"DOSBox exited with code {result.returncode}")
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Quote bridge listener.
Watches a source directory for quote JSON files and processes them in one of
two modes:
1) move - move straight to processed folder (default)
2) dosbox - invoke DOSBox script, then move to processed/failed
"""
from __future__ import annotations
import os
import shutil
import signal
import time
from datetime import datetime
from pathlib import Path
from dosbox_processor import run_dosbox_for_quote
def _default_path(*parts: str) -> str:
base_dir = Path(__file__).resolve().parent.parent
return str(base_dir.joinpath(*parts))
QUOTE_OUTGOING_DIR = Path(
os.environ.get("QUOTE_OUTGOING_DIR", _default_path("shared", "outgoing"))
)
QUOTE_PROCESSED_DIR = Path(
os.environ.get("QUOTE_PROCESSED_DIR", _default_path("shared", "processed"))
)
QUOTE_FAILED_DIR = Path(
os.environ.get("QUOTE_FAILED_DIR", _default_path("shared", "failed"))
)
PROCESSOR_MODE = os.environ.get("QUOTE_PROCESSOR_MODE", "move").strip().lower()
POLL_INTERVAL_SECONDS = float(os.environ.get("QUOTE_POLL_INTERVAL_SECONDS", "2"))
RUNNING = True
def _timestamp() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(message: str) -> None:
print(f"[{_timestamp()}] {message}", flush=True)
def should_process(path: Path) -> bool:
if not path.is_file():
return False
if path.suffix.lower() != ".json":
return False
if path.name.endswith(".tmp"):
return False
return True
def move_quote_file(path: Path, destination_dir: Path) -> Path:
target = destination_dir / path.name
# Avoid filename collisions by appending epoch milliseconds.
if target.exists():
stem = path.stem
suffix = path.suffix
target = QUOTE_PROCESSED_DIR / f"{stem}-{int(time.time() * 1000)}{suffix}"
shutil.move(str(path), str(target))
return target
def process_quote_file(path: Path) -> None:
if PROCESSOR_MODE == "move":
target = move_quote_file(path, QUOTE_PROCESSED_DIR)
log(f"Moved {path.name} -> {target.name}")
return
if PROCESSOR_MODE == "dosbox":
try:
run_dosbox_for_quote(path, log)
target = move_quote_file(path, QUOTE_PROCESSED_DIR)
log(f"DOSBox processed {path.name} -> {target.name}")
except Exception as exc:
failed_target = move_quote_file(path, QUOTE_FAILED_DIR)
log(f"DOSBox failed for {path.name}: {exc}")
log(f"Moved to failed: {failed_target.name}")
return
# Unknown mode: fail fast so operator can fix configuration.
raise RuntimeError(
f"Unsupported QUOTE_PROCESSOR_MODE '{PROCESSOR_MODE}'. Use 'move' or 'dosbox'."
)
def process_once() -> int:
moved = 0
for entry in sorted(QUOTE_OUTGOING_DIR.iterdir()):
if not should_process(entry):
continue
process_quote_file(entry)
moved += 1
return moved
def handle_shutdown(signum: int, _frame) -> None:
global RUNNING
RUNNING = False
log(f"Received signal {signum}; shutting down...")
def main() -> int:
QUOTE_OUTGOING_DIR.mkdir(parents=True, exist_ok=True)
QUOTE_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
QUOTE_FAILED_DIR.mkdir(parents=True, exist_ok=True)
signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)
log("Quote bridge listener started")
log(f"Processor mode: {PROCESSOR_MODE}")
log(f"Watching: {QUOTE_OUTGOING_DIR}")
log(f"Processed dir: {QUOTE_PROCESSED_DIR}")
log(f"Failed dir: {QUOTE_FAILED_DIR}")
log(f"Poll interval: {POLL_INTERVAL_SECONDS}s")
while RUNNING:
try:
moved = process_once()
if moved == 0:
time.sleep(POLL_INTERVAL_SECONDS)
except FileNotFoundError:
# If a folder disappears unexpectedly, recreate it and continue.
QUOTE_OUTGOING_DIR.mkdir(parents=True, exist_ok=True)
QUOTE_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
QUOTE_FAILED_DIR.mkdir(parents=True, exist_ok=True)
time.sleep(POLL_INTERVAL_SECONDS)
except Exception as exc: # pragma: no cover - defensive runtime logging
log(f"Error: {exc}")
time.sleep(POLL_INTERVAL_SECONDS)
log("Quote bridge listener stopped")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+13
View File
@@ -0,0 +1,13 @@
{
"profileName": "IOLA",
"description": "Iola DOSBox profile",
"enabled": false,
"dosboxBin": "dosbox-x",
"mountDrive": "C",
"mountPath": "/mnt/iola-cgw",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"extraCommands": [],
"timeoutSeconds": 180,
"noConsole": false
}
+13
View File
@@ -0,0 +1,13 @@
{
"profileName": "KC",
"description": "Kansas City DOSBox profile",
"enabled": false,
"dosboxBin": "dosbox-x",
"mountDrive": "C",
"mountPath": "/mnt/kc-cgw",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"extraCommands": [],
"timeoutSeconds": 180,
"noConsole": false
}
+13
View File
@@ -0,0 +1,13 @@
{
"profileName": "LINDS",
"description": "Lindsborg DOSBox profile",
"enabled": false,
"dosboxBin": "dosbox-x",
"mountDrive": "C",
"mountPath": "/mnt/linds-cgw",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"extraCommands": [],
"timeoutSeconds": 180,
"noConsole": false
}
+20
View File
@@ -0,0 +1,20 @@
{
"profileName": "DEFAULT",
"description": "Fallback DOSBox profile. Copy and customize per location.",
"enabled": false,
"dosboxBin": "dosbox-x",
"mountDrive": "C",
"mountPath": "/mnt/remote-cgw-share",
"workingDirectory": "CGWAPP",
"bridgeCommand": "BRIDGE.BAT",
"extraCommands": [
"SET CLIPPER=F200"
],
"timeoutSeconds": 180,
"noConsole": false,
"notes": {
"mountPath": "Enter the Linux path where the remote PC share is mounted.",
"workingDirectory": "Enter the DOS folder under the mounted share that contains your Harbour/Clipper app and BRIDGE.BAT.",
"bridgeCommand": "Change this if your DOS-side entry point is not BRIDGE.BAT."
}
}
+1
View File
@@ -0,0 +1 @@
# Standard library only for now