commit 5a900e0ded6ed29103b474189a40c600deebc556 Author: Bobby Abellana Date: Fri Mar 21 10:42:01 2025 -0700 Initial commit with decision tree helpdesk application diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..708f465 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..d475fd1 --- /dev/null +++ b/admin.py @@ -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/', 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/') +@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/') +@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/', 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//', 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//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//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')) diff --git a/app.py b/app.py new file mode 100644 index 0000000..d8809d1 --- /dev/null +++ b/app.py @@ -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) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..697b11a --- /dev/null +++ b/auth.py @@ -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) \ No newline at end of file diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..74f4b54 --- /dev/null +++ b/commands.py @@ -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!') \ No newline at end of file diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..eb23440 --- /dev/null +++ b/forms.py @@ -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.') \ No newline at end of file diff --git a/migrations/add_media_columns.py b/migrations/add_media_columns.py new file mode 100644 index 0000000..ef994f3 --- /dev/null +++ b/migrations/add_media_columns.py @@ -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() \ No newline at end of file diff --git a/migrations/add_node_images_table.py b/migrations/add_node_images_table.py new file mode 100644 index 0000000..02d5bd0 --- /dev/null +++ b/migrations/add_node_images_table.py @@ -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() \ No newline at end of file diff --git a/migrations/add_site_config_table.py b/migrations/add_site_config_table.py new file mode 100644 index 0000000..efb709a --- /dev/null +++ b/migrations/add_site_config_table.py @@ -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() \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..ab1d794 --- /dev/null +++ b/models.py @@ -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 diff --git a/recreate_db.py b/recreate_db.py new file mode 100644 index 0000000..3a378ae --- /dev/null +++ b/recreate_db.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32006e3 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/static/js/tree-visualizer.js b/static/js/tree-visualizer.js new file mode 100644 index 0000000..c099d51 --- /dev/null +++ b/static/js/tree-visualizer.js @@ -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 = '
No tree data available
'; + 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 = '
Error parsing tree data
'; + 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('') + .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('') + .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('') + .on("click", function() { + d3.select("#tree-visualization svg") + .transition() + .duration(300) + .call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(0.7)); + }); +}); \ No newline at end of file diff --git a/static/uploads/node_7_Screenshot_2025-02-14_112309.png b/static/uploads/node_7_Screenshot_2025-02-14_112309.png new file mode 100644 index 0000000..a7e4c4e Binary files /dev/null and b/static/uploads/node_7_Screenshot_2025-02-14_112309.png differ diff --git a/static/uploads/node_7_Screenshot_2025-03-05_131850.png b/static/uploads/node_7_Screenshot_2025-03-05_131850.png new file mode 100644 index 0000000..e36072f Binary files /dev/null and b/static/uploads/node_7_Screenshot_2025-03-05_131850.png differ diff --git a/static/uploads/node_7_Screenshot_2025-03-05_134726.png b/static/uploads/node_7_Screenshot_2025-03-05_134726.png new file mode 100644 index 0000000..2cbbc4c Binary files /dev/null and b/static/uploads/node_7_Screenshot_2025-03-05_134726.png differ diff --git a/static/uploads/node_7_Screenshot_2025-03-12_094913.png b/static/uploads/node_7_Screenshot_2025-03-12_094913.png new file mode 100644 index 0000000..9181dbe Binary files /dev/null and b/static/uploads/node_7_Screenshot_2025-03-12_094913.png differ diff --git a/templates/admin/admin_dashboard.html b/templates/admin/admin_dashboard.html new file mode 100644 index 0000000..f19859c --- /dev/null +++ b/templates/admin/admin_dashboard.html @@ -0,0 +1,70 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ +
+
Manage Products
+
+ + + + + + + + + + + {% for product in products %} + + + + + + + {% else %} + + + + {% endfor %} + +
IDTitleDescriptionActions
{{ product.id }}{{ product.title }}{{ product.description|truncate(50) }} + +
No products found. Create one.
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/admin/create_product.html b/templates/admin/create_product.html new file mode 100644 index 0000000..044800e --- /dev/null +++ b/templates/admin/create_product.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

Create New Product

+
+
+
+
+ + +
Enter a descriptive name for your product or service.
+
+ +
+ + +
Enter a URL for the product image (optional).
+
+ +
+ + +
Briefly describe what this product or service is about.
+
+ +
+ +
+
+
+ + +
+ +
+ + +
+
+
+
You can edit the full decision tree after creating the product.
+
+ +
+ Cancel + +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/admin/edit_decision_tree.html b/templates/admin/edit_decision_tree.html new file mode 100644 index 0000000..08fbf96 --- /dev/null +++ b/templates/admin/edit_decision_tree.html @@ -0,0 +1,176 @@ +{% extends 'base.html' %} + +{% macro render_node_tree(node) %} + {% if node %} +
    + {% if node.yes_node %} +
  • +
    + Yes + + {{ node.yes_node.question }} + + {% if node.yes_node.is_terminal %} + Terminal + {% endif %} +
    + {{ render_node_tree(node.yes_node) }} +
  • + {% endif %} + + {% if node.no_node %} +
  • +
    + No + + {{ node.no_node.question }} + + {% if node.no_node.is_terminal %} + Terminal + {% endif %} +
    + {{ render_node_tree(node.no_node) }} +
  • + {% endif %} +
+ {% endif %} +{% endmacro %} + +{% block content %} +
+
+
+
+
+
Edit Decision Tree: {{ product.title }}
+ +
+
+
+
+
Root Node
+
+
+
{{ product.root_node.question }}
+

{{ product.root_node.content|truncate(100) }}

+ + Edit Root Node + +
+
+ +
Tree Structure
+
+
+
+
+ Root + + {{ product.root_node.question }} + + {% if product.root_node.is_terminal %} + Terminal + {% endif %} +
+ +
+
+
Yes Branch
+ {% if product.root_node.yes_node %} + {{ render_node_tree(product.root_node.yes_node) }} + {% else %} +

No 'Yes' branch defined yet.

+ + Add 'Yes' Branch + + {% endif %} +
+
+
No Branch
+ {% if product.root_node.no_node %} + {{ render_node_tree(product.root_node.no_node) }} + {% else %} +

No 'No' branch defined yet.

+ + Add 'No' Branch + + {% endif %} +
+
+
+
+
+
+ +
+
Tree Visualization
+
+
+
+ +
+
+ + +
+ +
+
+

+                                    
+
+
+
+
+
+
+
+
+ +
+
+
+
+
Preview Decision Tree
+
+
+

Test your decision tree from the user's perspective:

+ + + Test Decision Tree + +
+
+
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/edit_node.html b/templates/admin/edit_node.html new file mode 100644 index 0000000..c02497f --- /dev/null +++ b/templates/admin/edit_node.html @@ -0,0 +1,324 @@ +{% extends 'base.html' %} + +{% macro render_node_tree(node) %} + {% if node %} +
    + {% if node.yes_node %} +
  • +
    + Yes + + {{ node.yes_node.question }} + + {% if node.yes_node.is_terminal %} + Terminal + {% endif %} +
    + {{ render_node_tree(node.yes_node) }} +
  • + {% endif %} + + {% if node.no_node %} +
  • +
    + No + + {{ node.no_node.question }} + + {% if node.no_node.is_terminal %} + Terminal + {% endif %} +
    + {{ render_node_tree(node.no_node) }} +
  • + {% endif %} +
+ {% endif %} +{% endmacro %} + +{% block content %} +
+
+ +
+
+
+
Decision Tree Visualization
+
+
+
+ +
+
+ + +
+ +
+
+

+                    
+
+
+
+ + +
+
+
+
Edit Node
+ +
+
+
+
+ + +
+ +
+ + + This content will be shown to the user when they reach this node. +
+ + +
+
+ +
+
+
+ +
+ + {% if node.images %} +
Current Images
+
+ {% for image in node.images|sort(attribute='display_order') %} +
+
+ Node image +
+ {% if image.caption %} +

{{ image.caption }}

+ {% endif %} +
+
+ Order + +
+
+ + +
+
+
+
+
+ {% endfor %} +
+ {% endif %} + + +
Upload New Image
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
Enter a YouTube video URL to embed in this node.
+
+ + {% if node.youtube_embed_url %} +
+ +
+ {% else %} +
+ {% endif %} +
+
+
+
+ +
+ + + Check this if this node is an endpoint (no further questions). +
+ + +
+
+
+ + +
+
+
+
+
Add 'Yes' Branch
+
+
+ {% if node.yes_node %} +

Already has a 'Yes' branch:

+ + Edit 'Yes' Node + + {% else %} +
+
+ + +
+
+ + +
+ +
+ {% endif %} +
+
+
+
+
+
+
Add 'No' Branch
+
+
+ {% if node.no_node %} +

Already has a 'No' branch:

+ + Edit 'No' Node + + {% else %} +
+
+ + +
+
+ + +
+ +
+ {% endif %} +
+
+
+
+
+
+
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/edit_product.html b/templates/admin/edit_product.html new file mode 100644 index 0000000..8270666 --- /dev/null +++ b/templates/admin/edit_product.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

Edit Product

+
+
+
+
+ + +
+ +
+ + +
Enter a URL for the product image.
+
+ +
+ + +
+ +
+ Cancel + +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/manage_users.html b/templates/admin/manage_users.html new file mode 100644 index 0000000..1e5979d --- /dev/null +++ b/templates/admin/manage_users.html @@ -0,0 +1,106 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+
User Management
+
+ + Back to Dashboard + + {% if registration_enabled %} + + Disable Registration + + {% else %} + + Enable Registration + + {% endif %} +
+
+
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + + + {% endfor %} + +
IDUsernameEmailAdminActions
{{ user.id }}{{ user.username }}{{ user.email }} + {% if user.is_admin %} + Yes + {% else %} + No + {% endif %} + +
+ {% if user.id != current_user.id %} + {% if user.is_admin %} + + Remove Admin + + {% else %} + + Make Admin + + {% endif %} + + + + + + {% else %} + Current User + {% endif %} +
+
No users found.
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..20c0418 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

Login

+
+
+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.remember_me(class="form-check-input") }} + {{ form.remember_me.label(class="form-check-label") }} +
+
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..23bb93d --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

Register

+
+
+
+ {{ form.hidden_tag() }} +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password2.label(class="form-label") }} + {{ form.password2(class="form-control") }} + {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5426b11 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,82 @@ + + + + + + Decision Tree Helpdesk + + + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+

© 2025 Decision Tree Helpdesk. All rights reserved.

+
+
+ + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/decision_tree.html b/templates/decision_tree.html new file mode 100644 index 0000000..2584e69 --- /dev/null +++ b/templates/decision_tree.html @@ -0,0 +1,109 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

{{ product.title }}

+
+
+
{{ current_node.question }}
+ + {% if current_node.content %} +
+ {{ current_node.content|safe }} +
+ {% endif %} + + + {% if current_node.images %} +
+ {% if current_node.images|length == 1 %} + + {% set image = current_node.images|first %} +
+ Node image + {% if image.caption %} +
{{ image.caption }}
+ {% endif %} +
+ {% else %} + + + {% endif %} +
+ {% endif %} + + + {% if current_node.image_url and not current_node.images %} +
+ Node image +
+ {% endif %} + + + {% if current_node.youtube_embed_url %} +
+
+ +
+
+ {% endif %} + + {% if current_node.is_terminal %} +
+ This is the end of this path. + Start over +
+ {% else %} +
+ {% if current_node.yes_node %} + Yes + {% endif %} + + {% if current_node.no_node %} + No + {% endif %} +
+ {% endif %} +
+ +
+
+
+
+{% endblock %} diff --git a/templates/end.html b/templates/end.html new file mode 100644 index 0000000..47cb8bc --- /dev/null +++ b/templates/end.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ + +
+
+

Solution Found

+
+
+
+

{{ node.question }}

+
+ {{ node.content|safe }} +
+
+ +
+ + You've reached the end of this decision path. We hope this information was helpful! +
+ +
+ + Return to Home + + +
+
+
+ +
+
+
Was this helpful?
+
+
+

Please let us know if this solution solved your problem:

+
+ + +
+ +
+ +
Need additional assistance?
+

Contact our support team:

+
    +
  • support@example.com
  • +
  • +1 (555) 123-4567
  • +
+
+
+
+
+
+{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..c3ae6b6 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

An error occurred!

+

Sorry, something went wrong. Please try again later or contact the administrator.

+
+ Return to Home +
+{% endblock %} \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..570af0a --- /dev/null +++ b/templates/home.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Welcome to the Decision Tree Helpdesk

+

Find solutions to your problems by answering simple yes/no questions.

+
+

Choose a product below to get started with the decision tree.

+
+
+
+ +
+ {% for product in products %} +
+
+ {% if product.thumbnail %} + {{ product.title }} + {% else %} +
+ +
+ {% endif %} +
+
{{ product.title }}
+

{{ product.description|truncate(100) }}

+
+ +
+
+ {% else %} +
+
+ No products available yet. Check back later! +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/node.html b/templates/node.html new file mode 100644 index 0000000..c38cc4c --- /dev/null +++ b/templates/node.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ + +
+
+

{{ node.question }}

+
+
+
+

{{ node.content }}

+
+ + {% if not node.is_terminal %} +
+
+ + +
+
+ {% else %} +
+ + This is the end of this decision path. If you need further assistance, please contact our support team. +
+ + Return to Home + + {% endif %} +
+
+ +
+
+
Need more help?
+

If you couldn't find a solution through our decision tree, please contact our support team:

+
    +
  • support@example.com
  • +
  • +1 (555) 123-4567
  • +
+
+
+
+
+
+{% endblock %} diff --git a/templates/product_detail.html b/templates/product_detail.html new file mode 100644 index 0000000..3d67993 --- /dev/null +++ b/templates/product_detail.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+

{{ product.title }}

+
+
+ {% if product.thumbnail %} + {{ product.title }} + {% endif %} + +

{{ product.description }}

+ +
+

No decision tree is available for this product yet.

+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/user.py b/user.py new file mode 100644 index 0000000..3b20845 --- /dev/null +++ b/user.py @@ -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/') +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//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//node/') +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/') +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