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