Add quote bridge and quote handoff flow
This commit is contained in:
Vendored
+127
@@ -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
@@ -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
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
# Required Code Changes for Conditional Navigation
|
||||
# Conditional Navigation and Filtering Status
|
||||
|
||||
## Overview
|
||||
To support conditional material/color questions and dynamic navigation, the following changes are needed in your application code.
|
||||
## Purpose
|
||||
|
||||
---
|
||||
|
||||
## 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() {
|
||||
This document records what was originally proposed for conditional question flow,
|
||||
what is already implemented, and what still remains.
|
||||
// Load all data files
|
||||
Promise.all([
|
||||
fetch('data/navigation.json').then(r => r.json()),
|
||||
|
||||
+88
-155
@@ -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
|
||||
- **app.py** - Main Flask application with routes
|
||||
- **wsgi.py** - Production WSGI entry point
|
||||
- **config.py** - Configuration management
|
||||
- **requirements.txt** - Python dependencies
|
||||
The current application is no longer a simple Flask conversion of a static quiz.
|
||||
It now includes:
|
||||
- modular blueprints
|
||||
- login and session management
|
||||
- location-aware access
|
||||
- product management APIs
|
||||
- shareable quiz URLs
|
||||
- layered image preview support
|
||||
- an optional SQLite backend
|
||||
|
||||
### Templates & Static Files
|
||||
- **templates/** - HTML files (index.html, index2.html, 404.html)
|
||||
- **css/** - Stylesheets (styles.css)
|
||||
- **js/** - JavaScript files (script.js)
|
||||
- **images/** - Product images folder
|
||||
## Fastest Way To Run It
|
||||
|
||||
### Documentation & Utilities
|
||||
- **README.md** - Complete documentation
|
||||
- **QUICKSTART.md** - Quick start guide
|
||||
- **run.bat** - Windows startup script
|
||||
- **.gitignore** - Git ignore file
|
||||
- **.env.example** - Environment template
|
||||
From the repository root:
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Using Batch File (Windows)
|
||||
Double-click `run.bat` to install dependencies and start the server.
|
||||
|
||||
### Option 2: Manual Start
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run the application
|
||||
python app.py
|
||||
cd app
|
||||
../.venv/bin/python app.py
|
||||
```
|
||||
|
||||
### Access Your Application
|
||||
Open in browser: **http://localhost:8080/quiz**
|
||||
Then open:
|
||||
- `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
|
||||
✅ Quiz page: http://localhost:8080/quiz
|
||||
✅ Debug mode: Enabled (auto-reloads on file changes)
|
||||
Use the built-in tasks from the repository root:
|
||||
|
||||
## 📁 Project Structure
|
||||
- `Start Flask Server`
|
||||
- `Process All Data`
|
||||
- `Update Data Files from CSV`
|
||||
- `Generate Bitwise Data`
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
`Process All Data` runs the two data-generation steps in sequence.
|
||||
|
||||
## 🎯 Key Features
|
||||
## Main Application Areas
|
||||
|
||||
✅ **Dynamic Routing** - Flask handles all page requests
|
||||
✅ **Static File Serving** - CSS, JS, and images properly served
|
||||
✅ **Error Handling** - Custom 404 page
|
||||
✅ **API Endpoints** - Ready for future backend features
|
||||
✅ **Production Ready** - WSGI config included
|
||||
✅ **All Original Features** - Quiz, forms, conditional logic, memory storage
|
||||
### Authentication
|
||||
- login page
|
||||
- logout
|
||||
- session info endpoint
|
||||
- location selection for multi-location users
|
||||
|
||||
## 🔧 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
|
||||
Edit `app.py`, line with `app.run()`:
|
||||
```python
|
||||
app.run(debug=True, host='0.0.0.0', port=YOUR_PORT)
|
||||
```
|
||||
### Images and Previews
|
||||
- flat product images
|
||||
- layered image previews
|
||||
- Canvas API fallback routes for hierarchical image lookup
|
||||
- layer transforms for mirrored door/hardware previews where configured
|
||||
|
||||
### Add New Routes
|
||||
In `app.py`:
|
||||
```python
|
||||
@app.route('/your-page')
|
||||
def your_page():
|
||||
return render_template('your-page.html')
|
||||
```
|
||||
### Data Layer
|
||||
- JSON is the default runtime backend
|
||||
- SQLite support exists behind a config flag
|
||||
- frontend quiz data is driven by JSON files in `app/data/`
|
||||
|
||||
### Update Quiz Questions
|
||||
Edit `js/script.js` - modify the `questionData` object
|
||||
## Important Files
|
||||
|
||||
### Change Styling
|
||||
Edit `css/styles.css`
|
||||
- `app/app.py` - application entry point
|
||||
- `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)
|
||||
- Upload all files
|
||||
- Install requirements: `pip install -r requirements.txt`
|
||||
- Point to `wsgi.py`
|
||||
Implemented:
|
||||
- modular Flask app structure
|
||||
- login/session flow
|
||||
- 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
|
||||
- **Heroku**: Add `Procfile` and push to Git
|
||||
- **PythonAnywhere**: Upload and configure WSGI
|
||||
- **AWS/Azure**: Use with Gunicorn
|
||||
Still incomplete:
|
||||
- fully generic conditional question skipping based on navigation metadata
|
||||
- automated tests
|
||||
- decision on whether SQLite should become the default backend
|
||||
|
||||
### 3. Docker
|
||||
See README.md for Dockerfile example
|
||||
## If You Are Updating Product Data
|
||||
|
||||
### 4. Production Server
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn -w 4 -b 0.0.0.0:8080 wsgi:app
|
||||
```
|
||||
1. Update the source CSV used by the data-generation scripts.
|
||||
2. Run `Process All Data`.
|
||||
3. Restart the Flask server if needed.
|
||||
4. Verify quiz results and product detail views.
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
## If You Are Picking Up Development
|
||||
|
||||
### Port Already in Use
|
||||
Change port in app.py or kill the process:
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :8080
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
Start with these files:
|
||||
|
||||
### Templates Not Found
|
||||
Make sure HTML files are in `templates/` folder
|
||||
1. `app/app.py`
|
||||
2. `app/blueprints/products.py`
|
||||
3. `app/static/js/script.js`
|
||||
4. `app/data/navigation.json`
|
||||
5. `app/config.py`
|
||||
|
||||
### Static Files Not Loading
|
||||
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
|
||||
Those files define the main routing, quiz behavior, and backend mode.
|
||||
|
||||
@@ -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:
|
||||
- ✅ 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 state management is implemented in the current frontend.
|
||||
|
||||
## 🔗 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
|
||||
|
||||
```
|
||||
# Initial state (no params)
|
||||
https://yoursite.com/
|
||||
## Current URL Parameters
|
||||
|
||||
# After selecting Door + Storm Door + Aluminum
|
||||
https://yoursite.com/?b=16401&q=q-dimensions
|
||||
↑ ↑
|
||||
| Current question
|
||||
Accumulated bit value (Door + Aluminum + Storm Door)
|
||||
- `b` - accumulated bitwise state for the user selections
|
||||
- `q` - current question key or `results`
|
||||
- `p` - product code for direct product-detail views
|
||||
|
||||
# Viewing specific product
|
||||
https://yoursite.com/?p=BGIST&b=16401
|
||||
↑
|
||||
Product code
|
||||
Examples:
|
||||
|
||||
# Bit value 16401 decodes to:
|
||||
# Bit 0 (1) = Door
|
||||
# Bit 4 (16) = Aluminum
|
||||
# Bit 14 (16384) = Storm Door subtype
|
||||
# Total: 1 + 16 + 16384 = 16401
|
||||
```text
|
||||
/quiz/?b=16401&q=results
|
||||
/quiz/?p=404&b=16401
|
||||
/quiz/product/404
|
||||
```
|
||||
|
||||
## 📋 Implementation Checklist
|
||||
## What Is Implemented
|
||||
|
||||
### Phase 1: Core URL Functionality (Essential)
|
||||
- [ ] Add bitwise data loading to `init()` function
|
||||
- [ ] Add `accumulatedBitValue` variable
|
||||
- [ ] Add `BIT_DEFINITIONS` constants
|
||||
- [ ] Implement `updateURL()` function
|
||||
- [ ] Implement `updateBitValue()` function
|
||||
- [ ] Update `handleAnswer()` to call `updateBitValue()`
|
||||
- [ ] Implement `initFromURL()` function
|
||||
- [ ] Implement `restoreStateFromBitValue()` function
|
||||
- [ ] Update `startOver()` to clear URL
|
||||
### Core State Handling
|
||||
- `accumulatedBitValue` is tracked in the frontend
|
||||
- `BIT_DEFINITIONS` constants are present in `script.js`
|
||||
- `product_bitwise.json` is loaded during startup
|
||||
- `updateBitValue()` updates the accumulated bitmask from answer filters
|
||||
- `updateURL()` writes `b` and `q` params without reloading the page
|
||||
- `initFromURL()` restores the app from query params or direct product routes
|
||||
- `restoreStateFromBitValue()` rebuilds the key quiz selections from the bitmask
|
||||
- `startOver()` clears the URL state
|
||||
|
||||
### Phase 2: Product Deep Linking (Recommended)
|
||||
- [ ] Implement `showProductByCode()` function
|
||||
- [ ] Update `showProductDetail()` to update URL
|
||||
- [ ] Make product cards clickable
|
||||
- [ ] Add URL params when viewing products
|
||||
### Product Deep Linking
|
||||
- Product cards open detail views through `showProductByCode()`
|
||||
- Product detail views push the selected product code into the URL
|
||||
- Direct query-string product links are supported with `?p=<code>`
|
||||
- Direct Flask routes are supported with `/quiz/product/<product_code>`
|
||||
|
||||
### Phase 3: Share Functionality (Nice to Have)
|
||||
- [ ] Implement `shareCurrentPage()` function
|
||||
- [ ] Add `showNotification()` helper
|
||||
- [ ] Add "Share" buttons to UI
|
||||
- [ ] Add CSS for notification animations
|
||||
### Share and Navigation Support
|
||||
- `shareCurrentPage()` copies the current URL to the clipboard when supported
|
||||
- A notification helper is shown after successful copy
|
||||
- Share buttons exist on results and product detail screens
|
||||
- A `popstate` listener handles browser back/forward navigation
|
||||
|
||||
### Phase 4: Browser Navigation (Polish)
|
||||
- [ ] Add `popstate` event listener
|
||||
- [ ] Test browser back button
|
||||
- [ ] Test browser forward button
|
||||
- [ ] Test refresh behavior
|
||||
## Implementation Notes
|
||||
|
||||
## 🚀 Quick Start
|
||||
The current implementation restores the primary quiz state used by filtering:
|
||||
- base type
|
||||
- subtype
|
||||
- material
|
||||
- color
|
||||
|
||||
### Step 1: Add Global Variables
|
||||
Add to top of `js/script.js`:
|
||||
```javascript
|
||||
let accumulatedBitValue = 0;
|
||||
let bitwiseData = {};
|
||||
That is enough to reopen result sets and product-detail pages consistently.
|
||||
|
||||
const BIT_DEFINITIONS = {
|
||||
'base_door': 0, 'base_window': 1,
|
||||
'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
|
||||
};
|
||||
```
|
||||
Dimension data is still primarily driven by the current session flow rather than fully
|
||||
reconstructed from the URL alone.
|
||||
|
||||
### Step 2: Load Bitwise Data
|
||||
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
|
||||
bitwiseData = {};
|
||||
bitwise.forEach(item => {
|
||||
bitwiseData[item.PROD_CODE] = item.BIT_VALUE;
|
||||
});
|
||||
|
||||
// Check for URL state
|
||||
initFromURL();
|
||||
});
|
||||
```
|
||||
## Validation Checklist
|
||||
|
||||
### Step 3: Add URL Functions
|
||||
Copy these three key functions from [URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md):
|
||||
1. `updateURL()` - Updates browser URL
|
||||
2. `updateBitValue()` - Calculates bit value from selection
|
||||
3. `initFromURL()` - Restores state from URL on load
|
||||
- [x] Bitwise data loads at startup
|
||||
- [x] URLs update as quiz answers are selected
|
||||
- [x] Results pages can be shared with `b` and `q`
|
||||
- [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
|
||||
|
||||
### Step 4: Update handleAnswer
|
||||
Add one line to `handleAnswer()`:
|
||||
```javascript
|
||||
function handleAnswer(currentKey, answerValue, answerIndex) {
|
||||
// ... existing code ...
|
||||
|
||||
updateBitValue(answerObject); // ADD THIS LINE
|
||||
|
||||
const nextKey = resolveNextQuestion(currentKey, answerValue, answerObject);
|
||||
history.push(nextKey);
|
||||
|
||||
updateURL(nextKey); // ADD THIS LINE
|
||||
|
||||
loadContent(nextKey);
|
||||
}
|
||||
```
|
||||
## Known Limits
|
||||
|
||||
### 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 ✅
|
||||
- URL restoration is designed around the current bitwise filter model, not a complete
|
||||
replay of every form interaction.
|
||||
- Conditional question metadata exists in the navigation data, but the frontend does
|
||||
not yet use a fully generic conditional-resolution engine.
|
||||
|
||||
## 📖 Documentation Files
|
||||
## Related 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
|
||||
- `app/static/js/script.js`
|
||||
- `app/data/product_bitwise.json`
|
||||
- `information/BITWISE_USAGE_GUIDE.md`
|
||||
- `information/REQUIRED_CODE_CHANGES.md`
|
||||
|
||||
## 🔧 Key Functions Reference
|
||||
## Recommended Next Steps
|
||||
|
||||
| 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! 🚀
|
||||
1. Manually test refresh and back/forward behavior on all major quiz branches.
|
||||
2. Extend restoration if dimension-specific deep linking becomes a requirement.
|
||||
3. Finish generic conditional navigation so URL restoration and question skipping use the same rules.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}")
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Standard library only for now
|
||||
Reference in New Issue
Block a user