diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b8b55e --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Decision Tree Helpdesk Application + +This application provides a decision tree-based helpdesk system to guide users through troubleshooting steps. + +## Features + +- Interactive decision tree navigation +- Google OAuth authentication +- Admin interface for managing decision trees +- User-friendly interface + +## Prerequisites + +- Python 3.7+ +- pip (Python package manager) +- A Google Developer account (for OAuth) + +## Installation + +1. Clone the repository: + ``` + git clone https://github.com/yourusername/decision-tree-helpdesk.git + cd decision-tree-helpdesk + ``` + +2. Create and activate a virtual environment: + ``` + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +4. Set up environment variables: + Create a `.env` file in the root directory with the following variables: + ``` + GOOGLE_CLIENT_ID=your_client_id_here + GOOGLE_CLIENT_SECRET=your_client_secret_here + SECRET_KEY=your_secret_key_here + ``` + +5. Initialize the database: + ``` + flask db init + flask db migrate -m "Initial migration" + flask db upgrade + ``` + +## Running the Application + +1. Start the development server: + ``` + flask run + ``` + +2. Open your browser and navigate to: + ``` + http://localhost:5000 + ``` + +## Usage + +### User Guide + +1. Log in using your Google account +2. Navigate through the decision tree by answering questions +3. Follow the recommended solutions for your issue + +### Admin Guide + +1. Log in as an administrator +2. Access the admin panel to manage decision trees +3. Create, edit, or delete nodes in the decision tree +4. Define question and answer paths + +## Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature-name` +3. Commit your changes: `git commit -m 'Add some feature'` +4. Push to the branch: `git push origin feature-name` +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/app.py b/app.py index d8809d1..8647df3 100644 --- a/app.py +++ b/app.py @@ -1,33 +1,39 @@ -from flask import Flask +import os +from dotenv import load_dotenv +from flask import Flask, render_template 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 oauth import google_bp from commands import create_admin +# Load environment variables from .env file +load_dotenv() + app = Flask(__name__) +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-for-testing') 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 = LoginManager(app) login_manager.login_view = 'auth.login' @login_manager.user_loader -def load_user(id): - return User.query.get(int(id)) +def load_user(user_id): + return User.query.get(int(user_id)) # Register Blueprints app.register_blueprint(user_bp) app.register_blueprint(admin_bp, url_prefix='/admin') app.register_blueprint(auth_bp, url_prefix='/auth') +app.register_blueprint(google_bp, url_prefix='/login') # Create database tables if they don't exist with app.app_context(): @@ -36,5 +42,13 @@ with app.app_context(): # Add this after creating the app app.cli.add_command(create_admin) +@app.errorhandler(404) +def page_not_found(e): + return render_template('error.html'), 404 + +@app.errorhandler(500) +def internal_server_error(e): + return render_template('error.html'), 500 + if __name__ == '__main__': app.run(debug=True) diff --git a/auth.py b/auth.py index 697b11a..b40fc98 100644 --- a/auth.py +++ b/auth.py @@ -5,6 +5,7 @@ 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 +from oauth import google_bp auth_bp = Blueprint('auth', __name__) @@ -45,7 +46,10 @@ def login(): return redirect(next_page or url_for('user.home')) flash('Invalid username or password', 'danger') - return render_template('auth/login.html', form=form) + # Check if Google OAuth is configured + google_configured = google_bp.client_id is not None + + return render_template('auth/login.html', form=form, google_configured=google_configured) @auth_bp.route('/logout') @login_required diff --git a/migrations/add_oauth_support.py b/migrations/add_oauth_support.py new file mode 100644 index 0000000..62e312c --- /dev/null +++ b/migrations/add_oauth_support.py @@ -0,0 +1,42 @@ +from app import app, db +from models import User, OAuth + +def upgrade_database(): + """Add OAuth support to the database""" + with app.app_context(): + # Check if columns already exist + with db.engine.connect() as conn: + # Check if is_oauth_user column exists in User table + result = conn.execute(db.text("PRAGMA table_info(user)")) + columns = result.fetchall() + column_names = [col[1] for col in columns] + + if 'is_oauth_user' not in column_names: + conn.execute(db.text("ALTER TABLE user ADD COLUMN is_oauth_user BOOLEAN DEFAULT 0")) + print("Added is_oauth_user column to User table") + + # Check if OAuth table exists + result = conn.execute(db.text("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth'")) + table_exists = result.fetchone() is not None + + if not table_exists: + # Create the OAuth table + conn.execute(db.text(""" + CREATE TABLE oauth ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(50) NOT NULL, + provider_user_id VARCHAR(256) NOT NULL, + token TEXT NOT NULL, + user_id INTEGER, + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + UNIQUE (provider, provider_user_id) + ) + """)) + print("Created OAuth table") + + conn.commit() + + print("Database migration for OAuth support completed successfully") + +if __name__ == "__main__": + upgrade_database() \ No newline at end of file diff --git a/models.py b/models.py index ab1d794..6cb6070 100644 --- a/models.py +++ b/models.py @@ -1,22 +1,30 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash +from flask_dance.consumer.storage.sqla import OAuthConsumerMixin 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) + username = db.Column(db.String(64), index=True, unique=True) + email = db.Column(db.String(120), index=True, unique=True) password_hash = db.Column(db.String(128)) is_admin = db.Column(db.Boolean, default=False) + # New field to track if user was created via OAuth + is_oauth_user = 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 OAuth(OAuthConsumerMixin, db.Model): + user_id = db.Column(db.Integer, db.ForeignKey(User.id)) + user = db.relationship(User) + class NodeImage(db.Model): id = db.Column(db.Integer, primary_key=True) node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False) diff --git a/oauth.py b/oauth.py new file mode 100644 index 0000000..fb008a0 --- /dev/null +++ b/oauth.py @@ -0,0 +1,90 @@ +import os +from flask import flash, redirect, url_for +from flask_dance.consumer import oauth_authorized +from flask_dance.consumer.storage.sqla import SQLAlchemyStorage +from flask_dance.contrib.google import make_google_blueprint +from flask_login import current_user, login_user +from sqlalchemy.orm.exc import NoResultFound +from models import db, User, OAuth + +# Get Google OAuth credentials from environment variables +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET") + +# Create the Google OAuth blueprint +google_bp = make_google_blueprint( + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + scope=["profile", "email"], + storage=SQLAlchemyStorage(OAuth, db.session, user=current_user) +) + +# Register a callback for when the OAuth login is successful +@oauth_authorized.connect_via(google_bp) +def google_logged_in(blueprint, token): + if not token: + flash("Failed to log in with Google.", "danger") + return False + + # Get the user's Google account info + resp = blueprint.session.get("/oauth2/v1/userinfo") + if not resp.ok: + flash("Failed to fetch user info from Google.", "danger") + return False + + google_info = resp.json() + google_user_id = str(google_info["id"]) + + # Find this OAuth token in the database, or create it + query = OAuth.query.filter_by( + provider=blueprint.name, + provider_user_id=google_user_id + ) + try: + oauth = query.one() + except NoResultFound: + oauth = OAuth( + provider=blueprint.name, + provider_user_id=google_user_id, + token=token + ) + + # Now, check to see if we already have a user with this email + if oauth.user: + # If this OAuth token already has an associated user, log them in + login_user(oauth.user) + flash("Successfully signed in with Google.", "success") + else: + # Check if we have a user with this email already + user = User.query.filter_by(email=google_info["email"]).first() + if user: + # If we do, link the OAuth token to the existing user + oauth.user = user + db.session.add(oauth) + db.session.commit() + login_user(user) + flash("Successfully signed in with Google.", "success") + else: + # If we don't, create a new user + username = google_info["email"].split("@")[0] + # Make sure username is unique + base_username = username + count = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}{count}" + count += 1 + + user = User( + username=username, + email=google_info["email"], + is_oauth_user=True + ) + # No password is set for OAuth users + oauth.user = user + db.session.add_all([user, oauth]) + db.session.commit() + login_user(user) + flash("Successfully signed in with Google.", "success") + + # Prevent Flask-Dance from creating another token + return False \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 32006e3..3f70d69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ Flask-Login==0.6.2 Flask-WTF==1.2.1 Flask-Migrate==4.0.5 Werkzeug==2.3.7 +Flask-Dance[sqla]==6.2.0 # Database SQLAlchemy==2.0.23 @@ -16,6 +17,7 @@ email-validator==2.1.0 # Security python-dotenv==1.0.0 bcrypt==4.0.1 +blinker==1.6.2 # Image handling Pillow==10.1.0 @@ -39,4 +41,8 @@ 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 +# mysqlclient==2.2.0 # For MySQL + +# OAuth and SSO +Flask-Dance[sqla]==6.2.0 +blinker==1.6.2 \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html index 20c0418..2c6d563 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -33,6 +33,21 @@ {{ form.submit(class="btn btn-primary") }} + + {% if google_configured %} +