Initial commit with decision tree helpdesk application

This commit is contained in:
Bobby Abellana 2025-03-21 10:42:01 -07:00
commit 5a900e0ded
33 changed files with 2734 additions and 0 deletions

58
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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.')

View 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()

View 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()

View 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
View 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
View 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
View 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

View 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));
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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 %}

View 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
View 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>

View 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
View 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
View 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
View 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
View 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 %}

View 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
View 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