Initial commit with decision tree helpdesk application
This commit is contained in:
commit
5a900e0ded
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv/
|
||||||
|
.env/
|
||||||
|
.ENV/
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Flask stuff
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
420
admin.py
Normal file
420
admin.py
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from models import db, Product, Node, NodeImage, User, SiteConfig
|
||||||
|
from functools import wraps
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
# Add these configurations to your app
|
||||||
|
UPLOAD_FOLDER = 'static/uploads'
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or not current_user.is_admin:
|
||||||
|
flash('You need admin privileges to access this page.', 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
@admin_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_dashboard():
|
||||||
|
products = Product.query.all()
|
||||||
|
return render_template('admin/admin_dashboard.html', products=products)
|
||||||
|
|
||||||
|
@admin_bp.route('/create_product', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_product():
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title')
|
||||||
|
thumbnail = request.form.get('thumbnail')
|
||||||
|
description = request.form.get('description', '')
|
||||||
|
|
||||||
|
# Get the root question and content from the form
|
||||||
|
root_question = request.form.get('root_question', 'How can we help you?')
|
||||||
|
root_content = request.form.get('root_content', 'Welcome to the decision tree.')
|
||||||
|
|
||||||
|
# Create a default root node for the decision tree
|
||||||
|
root_node = Node(question=root_question, content=root_content)
|
||||||
|
db.session.add(root_node)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create the product with the root node
|
||||||
|
product = Product(
|
||||||
|
title=title,
|
||||||
|
thumbnail=thumbnail,
|
||||||
|
description=description,
|
||||||
|
root_node_id=root_node.id
|
||||||
|
)
|
||||||
|
db.session.add(product)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Product created successfully! Now you can build the decision tree.", "success")
|
||||||
|
return redirect(url_for('admin.edit_decision_tree', product_id=product.id))
|
||||||
|
|
||||||
|
return render_template('admin/create_product.html')
|
||||||
|
|
||||||
|
@admin_bp.route('/edit_product/<int:product_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit_product(product_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
product.title = request.form.get('title')
|
||||||
|
product.thumbnail = request.form.get('thumbnail')
|
||||||
|
product.description = request.form.get('description', '')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash("Product updated successfully!", "success")
|
||||||
|
return redirect(url_for('admin.admin_dashboard'))
|
||||||
|
|
||||||
|
return render_template('admin/edit_product.html', product=product)
|
||||||
|
|
||||||
|
@admin_bp.route('/delete_product/<int:product_id>')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_product(product_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
|
||||||
|
# Delete the root node and all associated nodes (this would need a more sophisticated
|
||||||
|
# implementation to delete the entire tree)
|
||||||
|
if product.root_node:
|
||||||
|
db.session.delete(product.root_node)
|
||||||
|
|
||||||
|
db.session.delete(product)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Product deleted successfully!", "success")
|
||||||
|
return redirect(url_for('admin.admin_dashboard'))
|
||||||
|
|
||||||
|
@admin_bp.route('/edit_decision_tree/<int:product_id>')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit_decision_tree(product_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
|
||||||
|
# Generate tree data for visualization
|
||||||
|
tree_data = generate_tree_data(product.root_node, current_node_id=None)
|
||||||
|
tree_json = json.dumps(tree_data)
|
||||||
|
|
||||||
|
return render_template('admin/edit_decision_tree.html', product=product, tree_json=tree_json)
|
||||||
|
|
||||||
|
@admin_bp.route('/node/<int:node_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit_node(node_id):
|
||||||
|
node = Node.query.get_or_404(node_id)
|
||||||
|
|
||||||
|
# Find which product this node belongs to
|
||||||
|
product = find_product_for_node(node)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
node.question = request.form.get('question')
|
||||||
|
node.content = request.form.get('content')
|
||||||
|
node.is_terminal = 'is_terminal' in request.form
|
||||||
|
|
||||||
|
# Handle YouTube URL
|
||||||
|
youtube_url = request.form.get('youtube_url')
|
||||||
|
if youtube_url:
|
||||||
|
node.youtube_url = youtube_url
|
||||||
|
elif node.youtube_url and not youtube_url:
|
||||||
|
# Clear the YouTube URL if the field is empty
|
||||||
|
node.youtube_url = None
|
||||||
|
|
||||||
|
# Handle image upload
|
||||||
|
if 'image_upload' in request.files:
|
||||||
|
file = request.files['image_upload']
|
||||||
|
if file and file.filename and allowed_file(file.filename):
|
||||||
|
# Create upload directory if it doesn't exist
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate a secure filename with node ID to avoid conflicts
|
||||||
|
filename = secure_filename(f"node_{node.id}_{file.filename}")
|
||||||
|
filepath = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
# Create a new NodeImage
|
||||||
|
caption = request.form.get('image_caption', '')
|
||||||
|
|
||||||
|
# Get the highest display order
|
||||||
|
max_order = db.session.query(db.func.max(NodeImage.display_order)).filter(NodeImage.node_id == node.id).scalar() or 0
|
||||||
|
|
||||||
|
# Create new image with next order
|
||||||
|
new_image = NodeImage(
|
||||||
|
node_id=node.id,
|
||||||
|
image_url=f"/{filepath}",
|
||||||
|
caption=caption,
|
||||||
|
display_order=max_order + 1
|
||||||
|
)
|
||||||
|
db.session.add(new_image)
|
||||||
|
|
||||||
|
# Handle image removal
|
||||||
|
for key in request.form:
|
||||||
|
if key.startswith('remove_image_'):
|
||||||
|
try:
|
||||||
|
image_id = int(key.split('_')[-1])
|
||||||
|
image = NodeImage.query.get(image_id)
|
||||||
|
if image and image.node_id == node.id:
|
||||||
|
# Delete the file if it exists
|
||||||
|
if image.image_url and os.path.exists(image.image_url.lstrip('/')):
|
||||||
|
os.remove(image.image_url.lstrip('/'))
|
||||||
|
|
||||||
|
# Delete the database record
|
||||||
|
db.session.delete(image)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Handle image reordering
|
||||||
|
for key in request.form:
|
||||||
|
if key.startswith('image_order_'):
|
||||||
|
try:
|
||||||
|
image_id = int(key.split('_')[-1])
|
||||||
|
new_order = int(request.form[key])
|
||||||
|
image = NodeImage.query.get(image_id)
|
||||||
|
if image and image.node_id == node.id:
|
||||||
|
image.display_order = new_order
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash("Node updated successfully!", "success")
|
||||||
|
|
||||||
|
return redirect(url_for('admin.edit_node', node_id=node.id))
|
||||||
|
|
||||||
|
# Generate tree data for visualization
|
||||||
|
if product:
|
||||||
|
tree_data = generate_tree_data(product.root_node, current_node_id=node.id)
|
||||||
|
tree_json = json.dumps(tree_data)
|
||||||
|
else:
|
||||||
|
tree_json = '{}'
|
||||||
|
|
||||||
|
return render_template('admin/edit_node.html', node=node, product=product, tree_json=tree_json)
|
||||||
|
|
||||||
|
# Helper function to find which product a node belongs to
|
||||||
|
def find_product_for_node(node):
|
||||||
|
# First check if this is a root node
|
||||||
|
product = Product.query.filter_by(root_node_id=node.id).first()
|
||||||
|
|
||||||
|
if not product:
|
||||||
|
# Try to find the product by traversing up the tree
|
||||||
|
for p in Product.query.all():
|
||||||
|
root_node = p.root_node
|
||||||
|
if root_node:
|
||||||
|
# Check if node is in the tree
|
||||||
|
if is_node_in_tree(root_node, node.id):
|
||||||
|
product = p
|
||||||
|
break
|
||||||
|
|
||||||
|
return product
|
||||||
|
|
||||||
|
# Helper function to check if a node is in a tree
|
||||||
|
def is_node_in_tree(root, node_id):
|
||||||
|
if not root:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if root.id == node_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check yes branch
|
||||||
|
if root.yes_node_id and is_node_in_tree(root.yes_node, node_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check no branch
|
||||||
|
if root.no_node_id and is_node_in_tree(root.no_node, node_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Helper function to generate tree data for D3.js visualization
|
||||||
|
def generate_tree_data(node, current_node_id=None):
|
||||||
|
if not node:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create the node data
|
||||||
|
node_data = {
|
||||||
|
'name': node.question[:30] + ('...' if len(node.question) > 30 else ''),
|
||||||
|
'id': node.id,
|
||||||
|
'current': node.id == current_node_id,
|
||||||
|
'type': 'root', # Default type
|
||||||
|
'is_terminal': node.is_terminal, # Make sure this property is included
|
||||||
|
'children': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add yes branch
|
||||||
|
if node.yes_node:
|
||||||
|
yes_data = generate_tree_data(node.yes_node, current_node_id)
|
||||||
|
yes_data['type'] = 'yes'
|
||||||
|
node_data['children'].append(yes_data)
|
||||||
|
|
||||||
|
# Add no branch
|
||||||
|
if node.no_node:
|
||||||
|
no_data = generate_tree_data(node.no_node, current_node_id)
|
||||||
|
no_data['type'] = 'no'
|
||||||
|
node_data['children'].append(no_data)
|
||||||
|
|
||||||
|
return node_data
|
||||||
|
|
||||||
|
@admin_bp.route('/add_child_node/<int:parent_id>/<string:direction>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def add_child_node(parent_id, direction):
|
||||||
|
parent = Node.query.get_or_404(parent_id)
|
||||||
|
|
||||||
|
# Create a new node
|
||||||
|
new_node = Node(
|
||||||
|
question=request.form.get('question', 'New Question'),
|
||||||
|
content=request.form.get('content', 'New Content')
|
||||||
|
)
|
||||||
|
db.session.add(new_node)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Link the new node to the parent
|
||||||
|
if direction == 'yes':
|
||||||
|
parent.yes_node_id = new_node.id
|
||||||
|
else:
|
||||||
|
parent.no_node_id = new_node.id
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Find which product this node belongs to
|
||||||
|
product = None
|
||||||
|
# First check if this is a root node
|
||||||
|
product = Product.query.filter_by(root_node_id=parent.id).first()
|
||||||
|
|
||||||
|
if not product:
|
||||||
|
# Try to find the product by traversing up the tree
|
||||||
|
# This is a simplified approach and might not work for complex trees
|
||||||
|
for p in Product.query.all():
|
||||||
|
root_node = p.root_node
|
||||||
|
if root_node:
|
||||||
|
# Check if parent is in the yes branch
|
||||||
|
current = root_node
|
||||||
|
while current and current.yes_node_id:
|
||||||
|
if current.yes_node_id == parent.id:
|
||||||
|
product = p
|
||||||
|
break
|
||||||
|
current = current.yes_node
|
||||||
|
|
||||||
|
# Check if parent is in the no branch
|
||||||
|
current = root_node
|
||||||
|
while current and current.no_node_id:
|
||||||
|
if current.no_node_id == parent.id:
|
||||||
|
product = p
|
||||||
|
break
|
||||||
|
current = current.no_node
|
||||||
|
|
||||||
|
flash("Node added successfully!", "success")
|
||||||
|
|
||||||
|
# Redirect to edit the new node instead of going back to the dashboard
|
||||||
|
return redirect(url_for('admin.edit_node', node_id=new_node.id))
|
||||||
|
|
||||||
|
# Helper function to check if file extension is allowed
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and \
|
||||||
|
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
@admin_bp.route('/run_migrations')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def run_migrations():
|
||||||
|
"""Run database migrations"""
|
||||||
|
try:
|
||||||
|
# Check if columns already exist using the new SQLAlchemy 2.0 API
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
result = conn.execute(db.text("PRAGMA table_info(node)"))
|
||||||
|
columns = result.fetchall()
|
||||||
|
column_names = [col[1] for col in columns]
|
||||||
|
|
||||||
|
# Add image_url column if it doesn't exist
|
||||||
|
if 'image_url' not in column_names:
|
||||||
|
conn.execute(db.text("ALTER TABLE node ADD COLUMN image_url VARCHAR(500)"))
|
||||||
|
flash("Added image_url column to Node table", "success")
|
||||||
|
|
||||||
|
# Add youtube_url column if it doesn't exist
|
||||||
|
if 'youtube_url' not in column_names:
|
||||||
|
conn.execute(db.text("ALTER TABLE node ADD COLUMN youtube_url VARCHAR(500)"))
|
||||||
|
flash("Added youtube_url column to Node table", "success")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash("Database migration completed successfully", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error during migration: {str(e)}", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for('admin.admin_dashboard'))
|
||||||
|
|
||||||
|
@admin_bp.route('/users')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def manage_users():
|
||||||
|
users = User.query.all()
|
||||||
|
registration_enabled = SiteConfig.get_setting('registration_enabled', 'true') == 'true'
|
||||||
|
return render_template('admin/manage_users.html', users=users, registration_enabled=registration_enabled)
|
||||||
|
|
||||||
|
@admin_bp.route('/users/toggle_registration')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def toggle_registration():
|
||||||
|
current_setting = SiteConfig.get_setting('registration_enabled', 'true')
|
||||||
|
new_setting = 'false' if current_setting == 'true' else 'true'
|
||||||
|
SiteConfig.set_setting('registration_enabled', new_setting, 'Controls whether user registration is enabled')
|
||||||
|
|
||||||
|
status = "enabled" if new_setting == 'true' else "disabled"
|
||||||
|
flash(f"User registration has been {status}", "success")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
@admin_bp.route('/users/<int:user_id>/toggle_admin')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def toggle_admin(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
# Prevent removing admin status from the last admin
|
||||||
|
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
|
||||||
|
flash("Cannot remove admin status from the last admin user", "danger")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
# Don't allow changing your own admin status
|
||||||
|
if user.id == current_user.id:
|
||||||
|
flash("You cannot change your own admin status", "danger")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
user.is_admin = not user.is_admin
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
status = "granted" if user.is_admin else "revoked"
|
||||||
|
flash(f"Admin privileges {status} for {user.username}", "success")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
# Don't allow deleting yourself
|
||||||
|
if user.id == current_user.id:
|
||||||
|
flash("You cannot delete your own account", "danger")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
# Don't allow deleting the last admin
|
||||||
|
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
|
||||||
|
flash("Cannot delete the last admin user", "danger")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"User '{username}' has been deleted", "success")
|
||||||
|
return redirect(url_for('admin.manage_users'))
|
||||||
40
app.py
Normal file
40
app.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from models import db, User # Our database instance
|
||||||
|
from user import user_bp
|
||||||
|
from admin import admin_bp
|
||||||
|
from auth import auth_bp
|
||||||
|
from commands import create_admin
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///helpdesk.db'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
app.secret_key = 'your_secret_key' # Replace with a secure key
|
||||||
|
|
||||||
|
# Initialize the database with the Flask app
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# Initialize Flask-Login
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(id):
|
||||||
|
return User.query.get(int(id))
|
||||||
|
|
||||||
|
# Register Blueprints
|
||||||
|
app.register_blueprint(user_bp)
|
||||||
|
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
|
|
||||||
|
# Create database tables if they don't exist
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Add this after creating the app
|
||||||
|
app.cli.add_command(create_admin)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
||||||
80
auth.py
Normal file
80
auth.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from werkzeug.urls import url_parse
|
||||||
|
from models import db, User, SiteConfig
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired()])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
|
remember_me = BooleanField('Remember Me')
|
||||||
|
submit = SubmitField('Sign In')
|
||||||
|
|
||||||
|
class RegistrationForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
|
||||||
|
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
|
||||||
|
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
|
||||||
|
submit = SubmitField('Register')
|
||||||
|
|
||||||
|
def validate_username(self, username):
|
||||||
|
user = User.query.filter_by(username=username.data).first()
|
||||||
|
if user:
|
||||||
|
raise ValidationError('Username already taken. Please choose a different one.')
|
||||||
|
|
||||||
|
def validate_email(self, email):
|
||||||
|
user = User.query.filter_by(email=email.data).first()
|
||||||
|
if user:
|
||||||
|
raise ValidationError('Email already registered. Please use a different one.')
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('user.home'))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(username=form.username.data).first()
|
||||||
|
if user and user.check_password(form.password.data):
|
||||||
|
login_user(user, remember=form.remember_me.data)
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
return redirect(next_page or url_for('user.home'))
|
||||||
|
flash('Invalid username or password', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
|
@auth_bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
flash('You have been logged out.', 'info')
|
||||||
|
return redirect(url_for('user.home'))
|
||||||
|
|
||||||
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('user.home'))
|
||||||
|
|
||||||
|
# Check if registration is enabled
|
||||||
|
registration_enabled = SiteConfig.get_setting('registration_enabled', 'true') == 'true'
|
||||||
|
if not registration_enabled:
|
||||||
|
flash("User registration is currently disabled", "warning")
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
form = RegistrationForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User(
|
||||||
|
username=form.username.data,
|
||||||
|
email=form.email.data
|
||||||
|
)
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Registration successful! You can now log in.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('auth/register.html', form=form)
|
||||||
16
commands.py
Normal file
16
commands.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
from models import db, User
|
||||||
|
|
||||||
|
@click.command('create-admin')
|
||||||
|
@click.argument('username')
|
||||||
|
@click.argument('email')
|
||||||
|
@click.argument('password')
|
||||||
|
@with_appcontext
|
||||||
|
def create_admin(username, email, password):
|
||||||
|
"""Create an admin user."""
|
||||||
|
user = User(username=username, email=email, is_admin=True)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f'Admin user {username} created successfully!')
|
||||||
28
forms.py
Normal file
28
forms.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||||
|
from wtforms.validators import DataRequired, Email, EqualTo, ValidationError
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired()])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
|
remember_me = BooleanField('Remember Me')
|
||||||
|
submit = SubmitField('Sign In')
|
||||||
|
|
||||||
|
class RegistrationForm(FlaskForm):
|
||||||
|
username = StringField('Username', validators=[DataRequired()])
|
||||||
|
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||||
|
password = PasswordField('Password', validators=[DataRequired()])
|
||||||
|
password2 = PasswordField(
|
||||||
|
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
|
||||||
|
submit = SubmitField('Register')
|
||||||
|
|
||||||
|
def validate_username(self, username):
|
||||||
|
user = User.query.filter_by(username=username.data).first()
|
||||||
|
if user is not None:
|
||||||
|
raise ValidationError('Please use a different username.')
|
||||||
|
|
||||||
|
def validate_email(self, email):
|
||||||
|
user = User.query.filter_by(email=email.data).first()
|
||||||
|
if user is not None:
|
||||||
|
raise ValidationError('Please use a different email address.')
|
||||||
27
migrations/add_media_columns.py
Normal file
27
migrations/add_media_columns.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from app import app, db
|
||||||
|
|
||||||
|
def upgrade_database():
|
||||||
|
"""Add media columns to Node table"""
|
||||||
|
with app.app_context():
|
||||||
|
# Check if columns already exist using the new SQLAlchemy 2.0 API
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
result = conn.execute(db.text("PRAGMA table_info(node)"))
|
||||||
|
columns = result.fetchall()
|
||||||
|
column_names = [col[1] for col in columns]
|
||||||
|
|
||||||
|
# Add image_url column if it doesn't exist
|
||||||
|
if 'image_url' not in column_names:
|
||||||
|
conn.execute(db.text("ALTER TABLE node ADD COLUMN image_url VARCHAR(500)"))
|
||||||
|
print("Added image_url column to Node table")
|
||||||
|
|
||||||
|
# Add youtube_url column if it doesn't exist
|
||||||
|
if 'youtube_url' not in column_names:
|
||||||
|
conn.execute(db.text("ALTER TABLE node ADD COLUMN youtube_url VARCHAR(500)"))
|
||||||
|
print("Added youtube_url column to Node table")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("Database migration completed successfully")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
upgrade_database()
|
||||||
39
migrations/add_node_images_table.py
Normal file
39
migrations/add_node_images_table.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from app import app, db
|
||||||
|
from models import NodeImage
|
||||||
|
|
||||||
|
def upgrade_database():
|
||||||
|
"""Add NodeImage table for multiple images per node"""
|
||||||
|
with app.app_context():
|
||||||
|
# Check if table already exists
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
result = conn.execute(db.text("SELECT name FROM sqlite_master WHERE type='table' AND name='node_image'"))
|
||||||
|
table_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
# Create the node_image table
|
||||||
|
conn.execute(db.text("""
|
||||||
|
CREATE TABLE node_image (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
image_url VARCHAR(500) NOT NULL,
|
||||||
|
caption VARCHAR(200),
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES node (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
print("Created node_image table")
|
||||||
|
|
||||||
|
# Migrate existing single images to the new table
|
||||||
|
conn.execute(db.text("""
|
||||||
|
INSERT INTO node_image (node_id, image_url, display_order)
|
||||||
|
SELECT id, image_url, 0 FROM node
|
||||||
|
WHERE image_url IS NOT NULL AND image_url != ''
|
||||||
|
"""))
|
||||||
|
print("Migrated existing images to node_image table")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("Database migration for multiple images completed successfully")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
upgrade_database()
|
||||||
36
migrations/add_site_config_table.py
Normal file
36
migrations/add_site_config_table.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from app import app, db
|
||||||
|
from models import SiteConfig
|
||||||
|
|
||||||
|
def upgrade_database():
|
||||||
|
"""Add SiteConfig table for site settings"""
|
||||||
|
with app.app_context():
|
||||||
|
# Check if table already exists
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
result = conn.execute(db.text("SELECT name FROM sqlite_master WHERE type='table' AND name='site_config'"))
|
||||||
|
table_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
# Create the site_config table
|
||||||
|
conn.execute(db.text("""
|
||||||
|
CREATE TABLE site_config (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
value VARCHAR(255),
|
||||||
|
description VARCHAR(255)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
print("Created site_config table")
|
||||||
|
|
||||||
|
# Add default settings
|
||||||
|
conn.execute(db.text("""
|
||||||
|
INSERT INTO site_config (key, value, description)
|
||||||
|
VALUES ('registration_enabled', 'true', 'Controls whether user registration is enabled')
|
||||||
|
"""))
|
||||||
|
print("Added default site settings")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("Database migration for site configuration completed successfully")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
upgrade_database()
|
||||||
105
models.py
Normal file
105
models.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
class User(db.Model, UserMixin):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||||
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(128))
|
||||||
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
class NodeImage(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False)
|
||||||
|
image_url = db.Column(db.String(500), nullable=False)
|
||||||
|
caption = db.Column(db.String(200), nullable=True)
|
||||||
|
display_order = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
|
# Relationship back to the node
|
||||||
|
node = db.relationship('Node', backref=db.backref('images', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
class Node(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
question = db.Column(db.String(500), nullable=False)
|
||||||
|
content = db.Column(db.Text, nullable=True)
|
||||||
|
is_terminal = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# Keep single image_url for backward compatibility
|
||||||
|
image_url = db.Column(db.String(500), nullable=True)
|
||||||
|
youtube_url = db.Column(db.String(500), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
yes_node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=True)
|
||||||
|
no_node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=True)
|
||||||
|
|
||||||
|
yes_node = db.relationship('Node', foreign_keys=[yes_node_id], remote_side=[id], backref='yes_parent', uselist=False)
|
||||||
|
no_node = db.relationship('Node', foreign_keys=[no_node_id], remote_side=[id], backref='no_parent', uselist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def youtube_embed_url(self):
|
||||||
|
"""Convert YouTube URL to embed URL"""
|
||||||
|
if not self.youtube_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
# Extract video ID from various YouTube URL formats
|
||||||
|
video_id = None
|
||||||
|
|
||||||
|
# youtube.com/watch?v=VIDEO_ID format
|
||||||
|
if 'youtube.com/watch' in self.youtube_url:
|
||||||
|
parsed_url = urlparse(self.youtube_url)
|
||||||
|
video_id = parse_qs(parsed_url.query).get('v', [None])[0]
|
||||||
|
|
||||||
|
# youtu.be/VIDEO_ID format
|
||||||
|
elif 'youtu.be/' in self.youtube_url:
|
||||||
|
video_id = self.youtube_url.split('youtu.be/')[1].split('?')[0]
|
||||||
|
|
||||||
|
if video_id:
|
||||||
|
return f"https://www.youtube.com/embed/{video_id}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Product(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(100), nullable=False)
|
||||||
|
thumbnail = db.Column(db.String(200)) # URL or file path to the thumbnail image
|
||||||
|
description = db.Column(db.Text) # Add this line for product description
|
||||||
|
root_node_id = db.Column(db.Integer, db.ForeignKey('node.id'))
|
||||||
|
root_node = db.relationship("Node", foreign_keys=[root_node_id])
|
||||||
|
|
||||||
|
class SiteConfig(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(64), unique=True, nullable=False)
|
||||||
|
value = db.Column(db.String(255), nullable=True)
|
||||||
|
description = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_setting(cls, key, default=None):
|
||||||
|
setting = cls.query.filter_by(key=key).first()
|
||||||
|
if setting:
|
||||||
|
return setting.value
|
||||||
|
return default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_setting(cls, key, value, description=None):
|
||||||
|
setting = cls.query.filter_by(key=key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
if description:
|
||||||
|
setting.description = description
|
||||||
|
else:
|
||||||
|
setting = cls(key=key, value=value, description=description)
|
||||||
|
db.session.add(setting)
|
||||||
|
db.session.commit()
|
||||||
|
return setting
|
||||||
8
recreate_db.py
Normal file
8
recreate_db.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from app import app, db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Drop all tables
|
||||||
|
db.drop_all()
|
||||||
|
# Create all tables with the updated schema
|
||||||
|
db.create_all()
|
||||||
|
print("Database recreated with updated schema")
|
||||||
42
requirements.txt
Normal file
42
requirements.txt
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Flask and extensions
|
||||||
|
Flask==2.3.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Login==0.6.2
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
Flask-Migrate==4.0.5
|
||||||
|
Werkzeug==2.3.7
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLAlchemy==2.0.23
|
||||||
|
|
||||||
|
# Forms and validation
|
||||||
|
WTForms==3.1.1
|
||||||
|
email-validator==2.1.0
|
||||||
|
|
||||||
|
# Security
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
bcrypt==4.0.1
|
||||||
|
|
||||||
|
# Image handling
|
||||||
|
Pillow==10.1.0
|
||||||
|
|
||||||
|
# Markdown support (for rich text content)
|
||||||
|
Markdown==3.5
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-flask==1.3.0
|
||||||
|
coverage==7.3.2
|
||||||
|
|
||||||
|
# Production server
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
Jinja2==3.1.2
|
||||||
|
MarkupSafe==2.1.3
|
||||||
|
click==8.1.7
|
||||||
|
|
||||||
|
# If you plan to use a different database than SQLite
|
||||||
|
# psycopg2-binary==2.9.9 # For PostgreSQL
|
||||||
|
# mysqlclient==2.2.0 # For MySQL
|
||||||
468
static/js/tree-visualizer.js
Normal file
468
static/js/tree-visualizer.js
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Check if tree visualization container exists
|
||||||
|
const treeContainer = document.getElementById('tree-visualization');
|
||||||
|
if (!treeContainer) return;
|
||||||
|
|
||||||
|
// Get the tree data from the hidden input
|
||||||
|
let treeData;
|
||||||
|
try {
|
||||||
|
const treeDataInput = document.getElementById('tree-data');
|
||||||
|
if (!treeDataInput || !treeDataInput.value) {
|
||||||
|
console.error('Tree data not found');
|
||||||
|
treeContainer.innerHTML = '<div class="alert alert-warning">No tree data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
treeData = JSON.parse(treeDataInput.value);
|
||||||
|
|
||||||
|
// Ensure is_terminal is properly set as a boolean
|
||||||
|
function ensureTerminalFlag(node) {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// Convert is_terminal to boolean if it's a string
|
||||||
|
if (typeof node.is_terminal === 'string') {
|
||||||
|
node.is_terminal = (node.is_terminal.toLowerCase() === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If is_terminal is undefined, set it to false (not terminal by default)
|
||||||
|
if (node.is_terminal === undefined) {
|
||||||
|
node.is_terminal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process children recursively
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach(ensureTerminalFlag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTerminalFlag(treeData);
|
||||||
|
|
||||||
|
// Add this after ensureTerminalFlag is called
|
||||||
|
console.log("Tree data after processing:", JSON.stringify(treeData, null, 2));
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tree data:', e);
|
||||||
|
treeContainer.innerHTML = '<div class="alert alert-danger">Error parsing tree data</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dimensions and margins
|
||||||
|
const margin = {top: 20, right: 120, bottom: 20, left: 120},
|
||||||
|
width = 1200 - margin.left - margin.right,
|
||||||
|
height = 700 - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Clear any existing content
|
||||||
|
treeContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Append SVG
|
||||||
|
const svg = d3.select("#tree-visualization").append("svg")
|
||||||
|
.attr("width", "100%")
|
||||||
|
.attr("height", height + margin.top + margin.bottom)
|
||||||
|
.append("g")
|
||||||
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||||
|
|
||||||
|
// Preprocess the tree data to ensure consistent spacing
|
||||||
|
// Add dummy nodes for missing yes/no branches
|
||||||
|
function addDummyNodes(node) {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// Initialize children array if it doesn't exist
|
||||||
|
if (!node.children) {
|
||||||
|
node.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have both yes and no children
|
||||||
|
let hasYes = false;
|
||||||
|
let hasNo = false;
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === 'yes') hasYes = true;
|
||||||
|
if (child.type === 'no') hasNo = true;
|
||||||
|
|
||||||
|
// Recursively process this child
|
||||||
|
addDummyNodes(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add dummy nodes if this is not a terminal node
|
||||||
|
// Use explicit check for is_terminal === false
|
||||||
|
if (node.is_terminal === false) {
|
||||||
|
// Add dummy nodes if missing
|
||||||
|
if (!hasYes) {
|
||||||
|
node.children.push({
|
||||||
|
name: "(No 'Yes' branch)",
|
||||||
|
id: `dummy-yes-${node.id}`,
|
||||||
|
type: 'dummy-yes',
|
||||||
|
children: [],
|
||||||
|
isDummy: true,
|
||||||
|
parentId: node.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNo) {
|
||||||
|
node.children.push({
|
||||||
|
name: "(No 'No' branch)",
|
||||||
|
id: `dummy-no-${node.id}`,
|
||||||
|
type: 'dummy-no',
|
||||||
|
children: [],
|
||||||
|
isDummy: true,
|
||||||
|
parentId: node.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the tree data
|
||||||
|
addDummyNodes(treeData);
|
||||||
|
|
||||||
|
// Create a tree layout with more horizontal spacing
|
||||||
|
const tree = d3.tree()
|
||||||
|
.size([height, width - 300])
|
||||||
|
.separation(function(a, b) {
|
||||||
|
// Increase separation between nodes
|
||||||
|
return (a.parent == b.parent ? 1.5 : 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the root node
|
||||||
|
const root = d3.hierarchy(treeData);
|
||||||
|
|
||||||
|
// Now that root is defined, we can log terminal nodes
|
||||||
|
console.log("Terminal nodes:", root.descendants().filter(d => d.data.is_terminal).map(d => d.data.id));
|
||||||
|
|
||||||
|
// Add this after the root is created
|
||||||
|
console.log("All nodes:", root.descendants().map(d => ({
|
||||||
|
id: d.data.id,
|
||||||
|
name: d.data.name,
|
||||||
|
is_terminal: d.data.is_terminal,
|
||||||
|
type: d.data.type
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Compute the tree layout
|
||||||
|
tree(root);
|
||||||
|
|
||||||
|
// Add dotted lines for potential connections to dummy nodes
|
||||||
|
svg.selectAll(".potential-link")
|
||||||
|
.data(root.links().filter(d => d.target.data.isDummy))
|
||||||
|
.enter().append("path")
|
||||||
|
.attr("class", "potential-link")
|
||||||
|
.attr("d", d3.linkHorizontal()
|
||||||
|
.x(d => d.y)
|
||||||
|
.y(d => d.x))
|
||||||
|
.style("fill", "none")
|
||||||
|
.style("stroke", function(d) {
|
||||||
|
if (d.target.data.type === 'dummy-yes') return "#1cc88a";
|
||||||
|
if (d.target.data.type === 'dummy-no') return "#e74a3b";
|
||||||
|
return "#ccc";
|
||||||
|
})
|
||||||
|
.style("stroke-width", "1.5px")
|
||||||
|
.style("stroke-dasharray", "5,5") // This creates the dotted line
|
||||||
|
.style("opacity", 0.6);
|
||||||
|
|
||||||
|
// Add links between nodes (but not for dummy nodes)
|
||||||
|
svg.selectAll(".link")
|
||||||
|
.data(root.links().filter(d => !d.target.data.isDummy))
|
||||||
|
.enter().append("path")
|
||||||
|
.attr("class", "link")
|
||||||
|
.attr("d", d3.linkHorizontal()
|
||||||
|
.x(d => d.y)
|
||||||
|
.y(d => d.x))
|
||||||
|
.style("fill", "none")
|
||||||
|
.style("stroke", function(d) {
|
||||||
|
if (d.target.data.type === 'yes') return "#1cc88a";
|
||||||
|
if (d.target.data.type === 'no') return "#e74a3b";
|
||||||
|
return "#ccc";
|
||||||
|
})
|
||||||
|
.style("stroke-width", "1.5px");
|
||||||
|
|
||||||
|
// Add placeholder nodes for missing branches
|
||||||
|
svg.selectAll(".placeholder-node")
|
||||||
|
.data(root.descendants().filter(d => d.data.isDummy))
|
||||||
|
.enter().append("g")
|
||||||
|
.attr("class", "placeholder-node")
|
||||||
|
.attr("transform", d => `translate(${d.y},${d.x})`)
|
||||||
|
.each(function(d) {
|
||||||
|
const g = d3.select(this);
|
||||||
|
|
||||||
|
// Add a placeholder circle
|
||||||
|
g.append("circle")
|
||||||
|
.attr("r", 6)
|
||||||
|
.style("fill", "white")
|
||||||
|
.style("stroke", function() {
|
||||||
|
if (d.data.type === 'dummy-yes') return "#1cc88a";
|
||||||
|
if (d.data.type === 'dummy-no') return "#e74a3b";
|
||||||
|
return "#ccc";
|
||||||
|
})
|
||||||
|
.style("stroke-width", "1.5px")
|
||||||
|
.style("stroke-dasharray", "2,2")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.parentId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a label
|
||||||
|
g.append("text")
|
||||||
|
.attr("dy", "0.31em")
|
||||||
|
.attr("x", 15)
|
||||||
|
.style("text-anchor", "start")
|
||||||
|
.text(function() {
|
||||||
|
if (d.data.type === 'dummy-yes') return "Missing 'Yes' branch";
|
||||||
|
if (d.data.type === 'dummy-no') return "Missing 'No' branch";
|
||||||
|
return "Missing branch";
|
||||||
|
})
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("font-style", "italic")
|
||||||
|
.style("fill", function() {
|
||||||
|
if (d.data.type === 'dummy-yes') return "#1cc88a";
|
||||||
|
if (d.data.type === 'dummy-no') return "#e74a3b";
|
||||||
|
return "#666";
|
||||||
|
})
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.parentId}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add nodes (but not dummy nodes)
|
||||||
|
const node = svg.selectAll(".node")
|
||||||
|
.data(root.descendants().filter(d => !d.data.isDummy))
|
||||||
|
.enter().append("g")
|
||||||
|
.attr("class", "node")
|
||||||
|
.attr("transform", d => `translate(${d.y},${d.x})`);
|
||||||
|
|
||||||
|
// Add background rectangles for text
|
||||||
|
node.append("rect")
|
||||||
|
.attr("y", -10)
|
||||||
|
.attr("x", d => d.children ? -150 : 10)
|
||||||
|
.attr("width", 140)
|
||||||
|
.attr("height", 20)
|
||||||
|
.attr("rx", 4)
|
||||||
|
.attr("ry", 4)
|
||||||
|
.style("fill", "white")
|
||||||
|
.style("fill-opacity", 0.8)
|
||||||
|
.style("stroke", function(d) {
|
||||||
|
if (d.data.current) return "#f6c23e";
|
||||||
|
if (d.data.type === 'root') return "#4e73df";
|
||||||
|
if (d.data.type === 'yes') return "#1cc88a";
|
||||||
|
if (d.data.type === 'no') return "#e74a3b";
|
||||||
|
return "#ccc";
|
||||||
|
})
|
||||||
|
.style("stroke-width", "1px");
|
||||||
|
|
||||||
|
// Add circles to nodes
|
||||||
|
node.append("circle")
|
||||||
|
.attr("r", 8)
|
||||||
|
.style("fill", function(d) {
|
||||||
|
if (d.data.current) return "#f6c23e"; // Current node
|
||||||
|
if (d.data.type === 'root') return "#4e73df"; // Root node
|
||||||
|
if (d.data.type === 'yes') return "#1cc88a"; // Yes node
|
||||||
|
if (d.data.type === 'no') return "#e74a3b"; // No node
|
||||||
|
return "#fff";
|
||||||
|
})
|
||||||
|
.style("stroke", "#666")
|
||||||
|
.style("stroke-width", "1.5px");
|
||||||
|
|
||||||
|
// Add labels to nodes with truncated text
|
||||||
|
node.append("text")
|
||||||
|
.attr("dy", "0.31em")
|
||||||
|
.attr("x", d => d.children ? -15 : 15)
|
||||||
|
.style("text-anchor", d => d.children ? "end" : "start")
|
||||||
|
.text(d => {
|
||||||
|
// Truncate text to prevent overlap
|
||||||
|
const maxLength = 20;
|
||||||
|
if (d.data.name.length > maxLength) {
|
||||||
|
return d.data.name.substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
return d.data.name;
|
||||||
|
})
|
||||||
|
.style("font-size", "12px")
|
||||||
|
.style("font-family", "Arial, sans-serif")
|
||||||
|
.append("title") // Add tooltip with full text
|
||||||
|
.text(d => d.data.name);
|
||||||
|
|
||||||
|
// Add type labels (Yes/No/Root)
|
||||||
|
node.append("text")
|
||||||
|
.attr("dy", "-1.2em")
|
||||||
|
.attr("x", 0)
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("font-weight", "bold")
|
||||||
|
.style("fill", function(d) {
|
||||||
|
if (d.data.type === 'root') return "#4e73df";
|
||||||
|
if (d.data.type === 'yes') return "#1cc88a";
|
||||||
|
if (d.data.type === 'no') return "#e74a3b";
|
||||||
|
return "#666";
|
||||||
|
})
|
||||||
|
.text(function(d) {
|
||||||
|
if (d.data.type === 'root') return "ROOT";
|
||||||
|
if (d.data.type === 'yes') return "YES";
|
||||||
|
if (d.data.type === 'no') return "NO";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add terminal node indicators
|
||||||
|
node.filter(d => {
|
||||||
|
console.log(`Node ${d.data.id} is_terminal:`, d.data.is_terminal);
|
||||||
|
return d.data.is_terminal === true;
|
||||||
|
})
|
||||||
|
.append("text")
|
||||||
|
.attr("dy", "2.5em")
|
||||||
|
.attr("x", 0)
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("font-weight", "bold")
|
||||||
|
.style("fill", "#6c757d")
|
||||||
|
.text("TERMINAL");
|
||||||
|
|
||||||
|
// Add "Add" buttons for non-terminal nodes only
|
||||||
|
svg.selectAll(".add-button")
|
||||||
|
.data(root.descendants().filter(d => d.data.is_terminal === false && !d.data.isDummy))
|
||||||
|
.enter().append("g")
|
||||||
|
.attr("class", "add-button")
|
||||||
|
.attr("transform", d => {
|
||||||
|
// Position at the node itself
|
||||||
|
return `translate(${d.y},${d.x})`;
|
||||||
|
})
|
||||||
|
.each(function(d) {
|
||||||
|
const g = d3.select(this);
|
||||||
|
const hasYes = d.children && d.children.some(child => child.data.type === 'yes' && !child.data.isDummy);
|
||||||
|
const hasNo = d.children && d.children.some(child => child.data.type === 'no' && !child.data.isDummy);
|
||||||
|
|
||||||
|
// Create a container for the buttons
|
||||||
|
const buttonContainer = g.append("g")
|
||||||
|
.attr("transform", "translate(0, 20)"); // Position below the node
|
||||||
|
|
||||||
|
let yOffset = 0;
|
||||||
|
|
||||||
|
if (!hasYes) {
|
||||||
|
// Add Yes button
|
||||||
|
buttonContainer.append("rect")
|
||||||
|
.attr("y", yOffset)
|
||||||
|
.attr("x", -60)
|
||||||
|
.attr("width", 120)
|
||||||
|
.attr("height", 20)
|
||||||
|
.attr("rx", 4)
|
||||||
|
.attr("ry", 4)
|
||||||
|
.style("fill", "#d4edda")
|
||||||
|
.style("stroke", "#1cc88a")
|
||||||
|
.style("stroke-width", "1px")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonContainer.append("text")
|
||||||
|
.attr("dy", yOffset + 14)
|
||||||
|
.attr("x", 0)
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.text("Add 'Yes' branch")
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("fill", "#155724")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
yOffset += 25; // Increment for next button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNo) {
|
||||||
|
// Add No button
|
||||||
|
buttonContainer.append("rect")
|
||||||
|
.attr("y", yOffset)
|
||||||
|
.attr("x", -60)
|
||||||
|
.attr("width", 120)
|
||||||
|
.attr("height", 20)
|
||||||
|
.attr("rx", 4)
|
||||||
|
.attr("ry", 4)
|
||||||
|
.style("fill", "#f8d7da")
|
||||||
|
.style("stroke", "#e74a3b")
|
||||||
|
.style("stroke-width", "1px")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonContainer.append("text")
|
||||||
|
.attr("dy", yOffset + 14)
|
||||||
|
.attr("x", 0)
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.text("Add 'No' branch")
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("fill", "#721c24")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", function() {
|
||||||
|
window.location.href = `/admin/node/${d.data.id}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add indicator for non-terminal leaf nodes
|
||||||
|
node.filter(d => {
|
||||||
|
console.log(`Node ${d.data.id} expandable check:`,
|
||||||
|
d.data.is_terminal === false,
|
||||||
|
(!d.children || d.children.every(child => child.data.isDummy)));
|
||||||
|
return d.data.is_terminal === false &&
|
||||||
|
(!d.children || d.children.every(child => child.data.isDummy));
|
||||||
|
})
|
||||||
|
.append("text")
|
||||||
|
.attr("dy", "2.5em")
|
||||||
|
.attr("x", 0)
|
||||||
|
.style("text-anchor", "middle")
|
||||||
|
.style("font-size", "10px")
|
||||||
|
.style("font-style", "italic")
|
||||||
|
.style("fill", "#6c757d")
|
||||||
|
.text("EXPANDABLE");
|
||||||
|
|
||||||
|
// Add zoom functionality
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.3, 2])
|
||||||
|
.on("zoom", (event) => {
|
||||||
|
svg.attr("transform", event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
d3.select("#tree-visualization svg")
|
||||||
|
.call(zoom)
|
||||||
|
.call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7));
|
||||||
|
|
||||||
|
// Add click handlers to navigate to node edit page
|
||||||
|
node.style("cursor", "pointer")
|
||||||
|
.on("click", function(event, d) {
|
||||||
|
window.location.href = `/admin/node/${d.data.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add zoom controls
|
||||||
|
const zoomControls = d3.select("#tree-visualization")
|
||||||
|
.append("div")
|
||||||
|
.attr("class", "zoom-controls")
|
||||||
|
.style("position", "absolute")
|
||||||
|
.style("top", "10px")
|
||||||
|
.style("right", "10px");
|
||||||
|
|
||||||
|
zoomControls.append("button")
|
||||||
|
.attr("class", "btn btn-sm btn-outline-secondary me-1")
|
||||||
|
.html('<i class="fas fa-search-plus"></i>')
|
||||||
|
.on("click", function() {
|
||||||
|
d3.select("#tree-visualization svg")
|
||||||
|
.transition()
|
||||||
|
.duration(300)
|
||||||
|
.call(zoom.scaleBy, 1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
zoomControls.append("button")
|
||||||
|
.attr("class", "btn btn-sm btn-outline-secondary me-1")
|
||||||
|
.html('<i class="fas fa-search-minus"></i>')
|
||||||
|
.on("click", function() {
|
||||||
|
d3.select("#tree-visualization svg")
|
||||||
|
.transition()
|
||||||
|
.duration(300)
|
||||||
|
.call(zoom.scaleBy, 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
zoomControls.append("button")
|
||||||
|
.attr("class", "btn btn-sm btn-outline-secondary")
|
||||||
|
.html('<i class="fas fa-home"></i>')
|
||||||
|
.on("click", function() {
|
||||||
|
d3.select("#tree-visualization svg")
|
||||||
|
.transition()
|
||||||
|
.duration(300)
|
||||||
|
.call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7));
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
static/uploads/node_7_Screenshot_2025-02-14_112309.png
Normal file
BIN
static/uploads/node_7_Screenshot_2025-02-14_112309.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
static/uploads/node_7_Screenshot_2025-03-05_131850.png
Normal file
BIN
static/uploads/node_7_Screenshot_2025-03-05_131850.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
BIN
static/uploads/node_7_Screenshot_2025-03-05_134726.png
Normal file
BIN
static/uploads/node_7_Screenshot_2025-03-05_134726.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
static/uploads/node_7_Screenshot_2025-03-12_094913.png
Normal file
BIN
static/uploads/node_7_Screenshot_2025-03-12_094913.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
70
templates/admin/admin_dashboard.html
Normal file
70
templates/admin/admin_dashboard.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Admin Dashboard</h6>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.create_product') }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-plus"></i> Create New Product
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.manage_users') }}" class="btn btn-sm btn-info">
|
||||||
|
<i class="fas fa-users"></i> Manage Users
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.run_migrations') }}" class="btn btn-sm btn-secondary">
|
||||||
|
<i class="fas fa-database"></i> Run Migrations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Manage Products</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ product.id }}</td>
|
||||||
|
<td>{{ product.title }}</td>
|
||||||
|
<td>{{ product.description|truncate(50) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{{ url_for('admin.edit_product', product_id=product.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.edit_decision_tree', product_id=product.id) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="fas fa-sitemap"></i> Decision Tree
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('user.product_detail', product_id=product.id) }}" class="btn btn-sm btn-outline-info" target="_blank">
|
||||||
|
<i class="fas fa-eye"></i> View
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.delete_product', product_id=product.id) }}" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to delete this product?');">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center">No products found. <a href="{{ url_for('admin.create_product') }}">Create one</a>.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
60
templates/admin/create_product.html
Normal file
60
templates/admin/create_product.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h3 class="mb-0">Create New Product</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('admin.create_product') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">Product Title</label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" required>
|
||||||
|
<div class="form-text">Enter a descriptive name for your product or service.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="thumbnail" class="form-label">Thumbnail URL</label>
|
||||||
|
<input type="url" class="form-control" id="thumbnail" name="thumbnail">
|
||||||
|
<div class="form-text">Enter a URL for the product image (optional).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
|
||||||
|
<div class="form-text">Briefly describe what this product or service is about.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Initial Decision Tree Setup</label>
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="root_question" class="form-label">Root Question</label>
|
||||||
|
<input type="text" class="form-control" id="root_question" name="root_question"
|
||||||
|
value="How can we help you with this product?" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="root_content" class="form-label">Root Content</label>
|
||||||
|
<textarea class="form-control" id="root_content" name="root_content" rows="2">Welcome to the decision tree for this product. Please answer the following questions to help us assist you.</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">You can edit the full decision tree after creating the product.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Product</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
176
templates/admin/edit_decision_tree.html
Normal file
176
templates/admin/edit_decision_tree.html
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% macro render_node_tree(node) %}
|
||||||
|
{% if node %}
|
||||||
|
<ul class="list-unstyled ms-4">
|
||||||
|
{% if node.yes_node %}
|
||||||
|
<li>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-success me-2">Yes</span>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.yes_node.id) }}" class="text-decoration-none">
|
||||||
|
{{ node.yes_node.question }}
|
||||||
|
</a>
|
||||||
|
{% if node.yes_node.is_terminal %}
|
||||||
|
<span class="badge bg-secondary ms-2">Terminal</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ render_node_tree(node.yes_node) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if node.no_node %}
|
||||||
|
<li>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-danger me-2">No</span>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.no_node.id) }}" class="text-decoration-none">
|
||||||
|
{{ node.no_node.question }}
|
||||||
|
</a>
|
||||||
|
{% if node.no_node.is_terminal %}
|
||||||
|
<span class="badge bg-secondary ms-2">Terminal</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ render_node_tree(node.no_node) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 mb-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Edit Decision Tree: {{ product.title }}</h6>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
<!-- Update this link to use the correct route -->
|
||||||
|
<a href="{{ url_for('user.product_detail', product_id=product.id) }}" class="btn btn-sm btn-outline-success" target="_blank">
|
||||||
|
View Product
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Root Node</h5>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>{{ product.root_node.question }}</h6>
|
||||||
|
<p class="text-muted small">{{ product.root_node.content|truncate(100) }}</p>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=product.root_node.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
Edit Root Node
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5>Tree Structure</h5>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tree-container">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-primary me-2">Root</span>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=product.root_node.id) }}" class="text-decoration-none">
|
||||||
|
{{ product.root_node.question }}
|
||||||
|
</a>
|
||||||
|
{% if product.root_node.is_terminal %}
|
||||||
|
<span class="badge bg-secondary ms-2">Terminal</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-success">Yes Branch</h6>
|
||||||
|
{% if product.root_node.yes_node %}
|
||||||
|
{{ render_node_tree(product.root_node.yes_node) }}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No 'Yes' branch defined yet.</p>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=product.root_node.id) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
Add 'Yes' Branch
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-danger">No Branch</h6>
|
||||||
|
{% if product.root_node.no_node %}
|
||||||
|
{{ render_node_tree(product.root_node.no_node) }}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No 'No' branch defined yet.</p>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=product.root_node.id) }}" class="btn btn-sm btn-outline-danger">
|
||||||
|
Add 'No' Branch
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Tree Visualization</h5>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="tree-visualization" class="overflow-auto" style="height: 500px;"></div>
|
||||||
|
<input type="hidden" id="tree-data" value="{{ tree_json }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug data section -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#debugData">
|
||||||
|
Debug Tree Data
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="debugData">
|
||||||
|
<div class="card card-body">
|
||||||
|
<pre id="debug-output" style="max-height: 200px; overflow: auto;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Preview Decision Tree</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p>Test your decision tree from the user's perspective:</p>
|
||||||
|
<!-- Update this link to use the correct route -->
|
||||||
|
<a href="{{ url_for('user.start_decision_tree', product_id=product.id) }}" class="btn btn-success" target="_blank">
|
||||||
|
Test Decision Tree
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include D3.js for tree visualization -->
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/tree-visualizer.js') }}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const debugOutput = document.getElementById('debug-output');
|
||||||
|
const treeData = document.getElementById('tree-data');
|
||||||
|
if (debugOutput && treeData) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(treeData.value);
|
||||||
|
debugOutput.textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
debugOutput.textContent = "Error parsing tree data: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
324
templates/admin/edit_node.html
Normal file
324
templates/admin/edit_node.html
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% macro render_node_tree(node) %}
|
||||||
|
{% if node %}
|
||||||
|
<ul class="list-unstyled ms-4">
|
||||||
|
{% if node.yes_node %}
|
||||||
|
<li>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-success me-2">Yes</span>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.yes_node.id) }}" class="text-decoration-none">
|
||||||
|
{{ node.yes_node.question }}
|
||||||
|
</a>
|
||||||
|
{% if node.yes_node.is_terminal %}
|
||||||
|
<span class="badge bg-secondary ms-2">Terminal</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ render_node_tree(node.yes_node) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if node.no_node %}
|
||||||
|
<li>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-danger me-2">No</span>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.no_node.id) }}" class="text-decoration-none">
|
||||||
|
{{ node.no_node.question }}
|
||||||
|
</a>
|
||||||
|
{% if node.no_node.is_terminal %}
|
||||||
|
<span class="badge bg-secondary ms-2">Terminal</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{{ render_node_tree(node.no_node) }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left column: Tree visualization -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Decision Tree Visualization</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="tree-visualization" class="overflow-auto" style="height: 500px;"></div>
|
||||||
|
<input type="hidden" id="tree-data" value="{{ tree_json }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug data section -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#debugData">
|
||||||
|
Debug Tree Data
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="debugData">
|
||||||
|
<div class="card card-body">
|
||||||
|
<pre id="debug-output" style="max-height: 200px; overflow: auto;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Node edit form -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Edit Node</h6>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.edit_decision_tree', product_id=product.id) if product else '#' }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
Back to Tree
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="question" class="form-label">Question</label>
|
||||||
|
<input type="text" class="form-control" id="question" name="question" value="{{ node.question }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="content" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="content" name="content" rows="5">{{ node.content }}</textarea>
|
||||||
|
<small class="text-muted">This content will be shown to the user when they reach this node.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media content section -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" id="mediaTab" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="images-tab" data-bs-toggle="tab" data-bs-target="#images-content" type="button" role="tab">Images</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="youtube-tab" data-bs-toggle="tab" data-bs-target="#youtube-content" type="button" role="tab">YouTube Video</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content" id="mediaTabContent">
|
||||||
|
<!-- Images Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="images-content" role="tabpanel" aria-labelledby="images-tab">
|
||||||
|
<!-- Current Images -->
|
||||||
|
{% if node.images %}
|
||||||
|
<h6>Current Images</h6>
|
||||||
|
<div class="row mb-3">
|
||||||
|
{% for image in node.images|sort(attribute='display_order') %}
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<img src="{{ image.image_url }}" class="card-img-top" alt="Node image">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if image.caption %}
|
||||||
|
<p class="card-text small">{{ image.caption }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Order</span>
|
||||||
|
<input type="number" class="form-control" name="image_order_{{ image.id }}" value="{{ image.display_order }}" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="remove_image_{{ image.id }}" id="remove_image_{{ image.id }}">
|
||||||
|
<label class="form-check-label" for="remove_image_{{ image.id }}">Remove</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Upload New Image -->
|
||||||
|
<h6>Upload New Image</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="image_upload" class="form-label">Select Image</label>
|
||||||
|
<input type="file" class="form-control" id="image_upload" name="image_upload" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="image_caption" class="form-label">Caption (optional)</label>
|
||||||
|
<input type="text" class="form-control" id="image_caption" name="image_caption" placeholder="Enter a caption for the image">
|
||||||
|
</div>
|
||||||
|
<div id="image-preview" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YouTube Tab -->
|
||||||
|
<div class="tab-pane fade" id="youtube-content" role="tabpanel" aria-labelledby="youtube-tab">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="youtube_url" class="form-label">YouTube URL</label>
|
||||||
|
<input type="url" class="form-control" id="youtube_url" name="youtube_url" value="{{ node.youtube_url or '' }}" placeholder="https://www.youtube.com/watch?v=...">
|
||||||
|
<div class="form-text">Enter a YouTube video URL to embed in this node.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if node.youtube_embed_url %}
|
||||||
|
<div class="ratio ratio-16x9 mt-3">
|
||||||
|
<iframe src="{{ node.youtube_embed_url }}" title="YouTube video" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="youtube-preview"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_terminal" name="is_terminal" {% if node.is_terminal %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="is_terminal">Terminal Node</label>
|
||||||
|
<small class="d-block text-muted">Check this if this node is an endpoint (no further questions).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add child nodes section -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow mb-4 add-branch-form">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-success">Add 'Yes' Branch</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if node.yes_node %}
|
||||||
|
<p>Already has a 'Yes' branch:</p>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.yes_node.id) }}" class="btn btn-outline-success btn-sm">
|
||||||
|
Edit 'Yes' Node
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.add_child_node', parent_id=node.id, direction='yes') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="yes_question" class="form-label">Question</label>
|
||||||
|
<input type="text" class="form-control" id="yes_question" name="question" placeholder="Enter question for 'Yes' branch">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="yes_content" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="yes_content" name="content" rows="3" placeholder="Enter content for 'Yes' branch"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">Add 'Yes' Node</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow mb-4 add-branch-form">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-danger">Add 'No' Branch</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if node.no_node %}
|
||||||
|
<p>Already has a 'No' branch:</p>
|
||||||
|
<a href="{{ url_for('admin.edit_node', node_id=node.no_node.id) }}" class="btn btn-outline-danger btn-sm">
|
||||||
|
Edit 'No' Node
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.add_child_node', parent_id=node.id, direction='no') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="no_question" class="form-label">Question</label>
|
||||||
|
<input type="text" class="form-control" id="no_question" name="question" placeholder="Enter question for 'No' branch">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="no_content" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="no_content" name="content" rows="3" placeholder="Enter content for 'No' branch"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Add 'No' Node</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Add any additional styles needed for the tree visualization */
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Include D3.js for tree visualization -->
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/tree-visualizer.js') }}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const debugOutput = document.getElementById('debug-output');
|
||||||
|
const treeData = document.getElementById('tree-data');
|
||||||
|
if (debugOutput && treeData) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(treeData.value);
|
||||||
|
debugOutput.textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
debugOutput.textContent = "Error parsing tree data: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for terminal checkbox
|
||||||
|
const terminalCheckbox = document.getElementById('is_terminal');
|
||||||
|
if (terminalCheckbox) {
|
||||||
|
terminalCheckbox.addEventListener('change', function() {
|
||||||
|
const addBranchForms = document.querySelectorAll('.add-branch-form');
|
||||||
|
addBranchForms.forEach(form => {
|
||||||
|
form.style.display = this.checked ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger the change event to set initial state
|
||||||
|
if (terminalCheckbox.checked) {
|
||||||
|
const addBranchForms = document.querySelectorAll('.add-branch-form');
|
||||||
|
addBranchForms.forEach(form => {
|
||||||
|
form.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube URL preview
|
||||||
|
const youtubeUrlInput = document.getElementById('youtube_url');
|
||||||
|
if (youtubeUrlInput) {
|
||||||
|
youtubeUrlInput.addEventListener('input', function() {
|
||||||
|
const previewContainer = document.getElementById('youtube-preview');
|
||||||
|
if (previewContainer) {
|
||||||
|
const url = youtubeUrlInput.value;
|
||||||
|
if (url && isValidYouTubeUrl(url)) {
|
||||||
|
const embedUrl = getYouTubeEmbedUrl(url);
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="ratio ratio-16x9 mt-3">
|
||||||
|
<iframe src="${embedUrl}" title="YouTube video" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
previewContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate YouTube URL
|
||||||
|
function isValidYouTubeUrl(url) {
|
||||||
|
const regex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||||
|
return regex.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert YouTube URL to embed URL
|
||||||
|
function getYouTubeEmbedUrl(url) {
|
||||||
|
let videoId = '';
|
||||||
|
|
||||||
|
// Extract video ID from various YouTube URL formats
|
||||||
|
if (url.includes('youtube.com/watch')) {
|
||||||
|
const urlParams = new URLSearchParams(new URL(url).search);
|
||||||
|
videoId = urlParams.get('v');
|
||||||
|
} else if (url.includes('youtu.be/')) {
|
||||||
|
videoId = url.split('youtu.be/')[1].split('?')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
39
templates/admin/edit_product.html
Normal file
39
templates/admin/edit_product.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h3 class="mb-0">Edit Product</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('admin.edit_product', product_id=product.id) }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">Product Title</label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" value="{{ product.title }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="thumbnail" class="form-label">Thumbnail URL</label>
|
||||||
|
<input type="url" class="form-control" id="thumbnail" name="thumbnail" value="{{ product.thumbnail }}">
|
||||||
|
<div class="form-text">Enter a URL for the product image.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="3">{{ product.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
106
templates/admin/manage_users.html
Normal file
106
templates/admin/manage_users.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">User Management</h6>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
{% if registration_enabled %}
|
||||||
|
<a href="{{ url_for('admin.toggle_registration') }}" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-user-slash"></i> Disable Registration
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin.toggle_registration') }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="fas fa-user-plus"></i> Enable Registration
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<span class="badge bg-success">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% if user.id != current_user.id %}
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin.toggle_admin', user_id=user.id) }}" class="btn btn-sm btn-outline-warning">
|
||||||
|
<i class="fas fa-user-minus"></i> Remove Admin
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin.toggle_admin', user_id=user.id) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="fas fa-user-shield"></i> Make Admin
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteUserModal{{ user.id }}">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete User Modal -->
|
||||||
|
<div class="modal fade" id="deleteUserModal{{ user.id }}" tabindex="-1" aria-labelledby="deleteUserModalLabel{{ user.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteUserModalLabel{{ user.id }}">Confirm Delete</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
Are you sure you want to delete user <strong>{{ user.username }}</strong>? This action cannot be undone.
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST">
|
||||||
|
<button type="submit" class="btn btn-danger">Delete User</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Current User</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">No users found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/auth/login.html
Normal file
44
templates/auth/login.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-center">Login</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.username.label(class="form-label") }}
|
||||||
|
{{ form.username(class="form-control") }}
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.password.label(class="form-label") }}
|
||||||
|
{{ form.password(class="form-control") }}
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
{{ form.remember_me(class="form-check-input") }}
|
||||||
|
{{ form.remember_me.label(class="form-check-label") }}
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<p>New User? <a href="{{ url_for('auth.register') }}">Register here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
54
templates/auth/register.html
Normal file
54
templates/auth/register.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-center">Register</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.username.label(class="form-label") }}
|
||||||
|
{{ form.username(class="form-control") }}
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.email.label(class="form-label") }}
|
||||||
|
{{ form.email(class="form-control") }}
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.password.label(class="form-label") }}
|
||||||
|
{{ form.password(class="form-control") }}
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.password2.label(class="form-label") }}
|
||||||
|
{{ form.password2(class="form-control") }}
|
||||||
|
{% for error in form.password2.errors %}
|
||||||
|
<span class="text-danger">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
82
templates/base.html
Normal file
82
templates/base.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Decision Tree Helpdesk</title>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<style>
|
||||||
|
.tree-container {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
.node-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.node-children {
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('user.home') }}">Decision Tree Helpdesk</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('user.home') }}">Home</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="container mt-3">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-light py-4 mt-5">
|
||||||
|
<div class="container text-center">
|
||||||
|
<p class="text-muted mb-0">© 2025 Decision Tree Helpdesk. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS Bundle with Popper -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
109
templates/decision_tree.html
Normal file
109
templates/decision_tree.html
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">{{ product.title }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ current_node.question }}</h5>
|
||||||
|
|
||||||
|
{% if current_node.content %}
|
||||||
|
<div class="card-text mb-4">
|
||||||
|
{{ current_node.content|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Display images if available -->
|
||||||
|
{% if current_node.images %}
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if current_node.images|length == 1 %}
|
||||||
|
<!-- Single image display -->
|
||||||
|
{% set image = current_node.images|first %}
|
||||||
|
<figure class="figure">
|
||||||
|
<img src="{{ image.image_url }}" class="img-fluid rounded" alt="Node image">
|
||||||
|
{% if image.caption %}
|
||||||
|
<figcaption class="figure-caption text-center">{{ image.caption }}</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
|
{% else %}
|
||||||
|
<!-- Multiple images carousel -->
|
||||||
|
<div id="nodeImagesCarousel" class="carousel slide" data-bs-ride="carousel">
|
||||||
|
<div class="carousel-indicators">
|
||||||
|
{% for image in current_node.images|sort(attribute='display_order') %}
|
||||||
|
<button type="button" data-bs-target="#nodeImagesCarousel" data-bs-slide-to="{{ loop.index0 }}"
|
||||||
|
{% if loop.first %}class="active" aria-current="true"{% endif %}
|
||||||
|
aria-label="Slide {{ loop.index }}"></button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-inner">
|
||||||
|
{% for image in current_node.images|sort(attribute='display_order') %}
|
||||||
|
<div class="carousel-item {% if loop.first %}active{% endif %}">
|
||||||
|
<img src="{{ image.image_url }}" class="d-block w-100 rounded" alt="Node image">
|
||||||
|
{% if image.caption %}
|
||||||
|
<div class="carousel-caption d-none d-md-block bg-dark bg-opacity-50 rounded">
|
||||||
|
<p>{{ image.caption }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#nodeImagesCarousel" data-bs-slide="prev">
|
||||||
|
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Previous</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#nodeImagesCarousel" data-bs-slide="next">
|
||||||
|
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Next</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- For backward compatibility, show the single image if no images relationship -->
|
||||||
|
{% if current_node.image_url and not current_node.images %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="{{ current_node.image_url }}" class="img-fluid rounded" alt="Node image">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Display YouTube video if available -->
|
||||||
|
{% if current_node.youtube_embed_url %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="ratio ratio-16x9">
|
||||||
|
<iframe src="{{ current_node.youtube_embed_url }}" title="YouTube video" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_node.is_terminal %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
This is the end of this path.
|
||||||
|
<a href="{{ url_for('user.product_detail', product_id=product.id) }}" class="alert-link">Start over</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex justify-content-center mt-4">
|
||||||
|
{% if current_node.yes_node %}
|
||||||
|
<a href="{{ url_for('user.decision_node', product_id=product.id, node_id=current_node.yes_node.id) }}" class="btn btn-success mx-2">Yes</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_node.no_node %}
|
||||||
|
<a href="{{ url_for('user.decision_node', product_id=product.id, node_id=current_node.no_node.id) }}" class="btn btn-danger mx-2">No</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted">
|
||||||
|
<a href="{{ url_for('user.product_detail', product_id=product.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Start
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
69
templates/end.html
Normal file
69
templates/end.html
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('user.home') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Solution</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h3 class="mb-0">Solution Found</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4>{{ node.question }}</h4>
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ node.content|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
You've reached the end of this decision path. We hope this information was helpful!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
|
||||||
|
<a href="{{ url_for('user.home') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home me-2"></i> Return to Home
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="window.print()">
|
||||||
|
<i class="fas fa-print me-2"></i> Print Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Was this helpful?</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Please let us know if this solution solved your problem:</p>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-success">
|
||||||
|
<i class="fas fa-thumbs-up me-2"></i> Yes, it helped
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger">
|
||||||
|
<i class="fas fa-thumbs-down me-2"></i> No, I need more help
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5>Need additional assistance?</h5>
|
||||||
|
<p>Contact our support team:</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fas fa-envelope me-2"></i> support@example.com</li>
|
||||||
|
<li><i class="fas fa-phone me-2"></i> +1 (555) 123-4567</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
11
templates/error.html
Normal file
11
templates/error.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h4 class="alert-heading">An error occurred!</h4>
|
||||||
|
<p>Sorry, something went wrong. Please try again later or contact the administrator.</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('user.home') }}" class="btn btn-primary">Return to Home</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
47
templates/home.html
Normal file
47
templates/home.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 mb-4">
|
||||||
|
<div class="jumbotron bg-light p-5 rounded">
|
||||||
|
<h1 class="display-4">Welcome to the Decision Tree Helpdesk</h1>
|
||||||
|
<p class="lead">Find solutions to your problems by answering simple yes/no questions.</p>
|
||||||
|
<hr class="my-4">
|
||||||
|
<p>Choose a product below to get started with the decision tree.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{% for product in products %}
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card h-100 shadow">
|
||||||
|
{% if product.thumbnail %}
|
||||||
|
<img src="{{ product.thumbnail }}" class="card-img-top" alt="{{ product.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="card-img-top bg-light text-center py-5">
|
||||||
|
<i class="fas fa-question-circle fa-4x text-muted"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ product.title }}</h5>
|
||||||
|
<p class="card-text">{{ product.description|truncate(100) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white border-top-0">
|
||||||
|
<a href="{{ url_for('user.product_detail', product_id=product.id) }}" class="btn btn-primary">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No products available yet. Check back later!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
58
templates/node.html
Normal file
58
templates/node.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('user.home') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Decision Tree</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h3 class="mb-0">{{ node.question }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p>{{ node.content }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not node.is_terminal %}
|
||||||
|
<form method="POST" action="{{ url_for('user.decision_node', node_id=node.id) }}">
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<button type="submit" name="answer" value="yes" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-check-circle me-2"></i> Yes
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="answer" value="no" class="btn btn-danger btn-lg">
|
||||||
|
<i class="fas fa-times-circle me-2"></i> No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
This is the end of this decision path. If you need further assistance, please contact our support team.
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('user.home') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home me-2"></i> Return to Home
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Need more help?</h5>
|
||||||
|
<p>If you couldn't find a solution through our decision tree, please contact our support team:</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fas fa-envelope me-2"></i> support@example.com</li>
|
||||||
|
<li><i class="fas fa-phone me-2"></i> +1 (555) 123-4567</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
31
templates/product_detail.html
Normal file
31
templates/product_detail.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">{{ product.title }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if product.thumbnail %}
|
||||||
|
<img src="{{ product.thumbnail }}" class="img-fluid rounded mb-3" alt="{{ product.title }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="card-text">{{ product.description }}</p>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p>No decision tree is available for this product yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a href="{{ url_for('user.home') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Products
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
87
user.py
Normal file
87
user.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
|
from models import Product, Node
|
||||||
|
|
||||||
|
user_bp = Blueprint('user', __name__)
|
||||||
|
|
||||||
|
@user_bp.route('/')
|
||||||
|
def home():
|
||||||
|
products = Product.query.all()
|
||||||
|
return render_template('home.html', products=products)
|
||||||
|
|
||||||
|
@user_bp.route('/product/<int:product_id>')
|
||||||
|
def product_detail(product_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
|
||||||
|
# If the product has a root node, redirect directly to the decision tree
|
||||||
|
if product.root_node:
|
||||||
|
return redirect(url_for('user.decision_node', product_id=product.id, node_id=product.root_node_id))
|
||||||
|
|
||||||
|
# Otherwise, show the product detail page with a message
|
||||||
|
flash("This product doesn't have a decision tree yet.", "warning")
|
||||||
|
return render_template('product_detail.html', product=product)
|
||||||
|
|
||||||
|
# New route for starting the decision tree
|
||||||
|
@user_bp.route('/product/<int:product_id>/start')
|
||||||
|
def start_decision_tree(product_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
if not product.root_node:
|
||||||
|
flash("This product doesn't have a decision tree yet.", "warning")
|
||||||
|
return redirect(url_for('user.product_detail', product_id=product_id))
|
||||||
|
|
||||||
|
return redirect(url_for('user.decision_node', product_id=product_id, node_id=product.root_node_id))
|
||||||
|
|
||||||
|
@user_bp.route('/product/<int:product_id>/node/<int:node_id>')
|
||||||
|
def decision_node(product_id, node_id):
|
||||||
|
product = Product.query.get_or_404(product_id)
|
||||||
|
node = Node.query.get_or_404(node_id)
|
||||||
|
|
||||||
|
# Verify that this node belongs to the product's decision tree
|
||||||
|
if not is_node_in_product_tree(product, node):
|
||||||
|
flash("This node does not belong to the selected product.", "danger")
|
||||||
|
return redirect(url_for('user.product_detail', product_id=product_id))
|
||||||
|
|
||||||
|
return render_template('decision_tree.html', product=product, current_node=node)
|
||||||
|
|
||||||
|
# Backward compatibility route - redirects old URLs to new format
|
||||||
|
@user_bp.route('/node/<int:node_id>')
|
||||||
|
def legacy_node_redirect(node_id):
|
||||||
|
node = Node.query.get_or_404(node_id)
|
||||||
|
|
||||||
|
# Find which product this node belongs to
|
||||||
|
for product in Product.query.all():
|
||||||
|
if is_node_in_product_tree(product, node):
|
||||||
|
return redirect(url_for('user.decision_node', product_id=product.id, node_id=node.id))
|
||||||
|
|
||||||
|
# If we can't find the product, redirect to home
|
||||||
|
flash("Could not determine which product this node belongs to.", "warning")
|
||||||
|
return redirect(url_for('user.home'))
|
||||||
|
|
||||||
|
# Helper function to check if a node belongs to a product's decision tree
|
||||||
|
def is_node_in_product_tree(product, node):
|
||||||
|
if not product.root_node:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this is the root node
|
||||||
|
if product.root_node_id == node.id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Otherwise, traverse the tree
|
||||||
|
return is_node_in_tree(product.root_node, node.id)
|
||||||
|
|
||||||
|
# Helper function to check if a node is in a tree
|
||||||
|
def is_node_in_tree(root, node_id):
|
||||||
|
if not root:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if root.id == node_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check yes branch
|
||||||
|
if root.yes_node_id and is_node_in_tree(root.yes_node, node_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check no branch
|
||||||
|
if root.no_node_id and is_node_in_tree(root.no_node, node_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
Loading…
Reference in New Issue
Block a user