Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ Flask-WTF==1.2.2
# Testing (optional but recommended for contributors)
pytest==9.1.1
flake8==7.3.0
Authlib==1.3.1
Flask-SQLAlchemy==3.1.1
python-dotenv==1.0.1
requests==2.32.3
requests==2.32.3
52 changes: 41 additions & 11 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,48 @@
# Ensure the 'src' directory is in the python path for Vercel and root-level execution
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from flask import Flask
from flask import Flask, session
from flask_wtf.csrf import CSRFProtect
from routes.main_routes import main
from routes.github_routes import github_bp
from config import Config
from errors.handlers import register_error_handlers
from models import db
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY", "default-dev-secret-key-replace-in-production")

# Load config settings into Flask's internal config manager properly
app.config.from_object(Config)

# Initialize SQLAlchemy
db.init_app(app)

with app.app_context():
db.create_all()

# Initialize OAuth
oauth = OAuth(app)
github = oauth.register(
name='github',
client_id=app.config.get("GITHUB_CLIENT_ID"),
client_secret=app.config.get("GITHUB_CLIENT_SECRET"),
access_token_url='https://github.com/login/oauth/access_token',
access_token_params=None,
authorize_url='https://github.com/login/oauth/authorize',
authorize_params=None,
api_base_url='https://api.github.com/',
client_kwargs={'scope': 'read:user'},
)

# Register blueprints
from routes.auth_routes import auth_bp
from routes.admin_routes import admin_bp

app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(admin_bp, url_prefix='/admin')

# Enable CSRF protection for all state-changing requests
csrf = CSRFProtect(app)

Expand All @@ -46,6 +75,16 @@
# so Blueprint-level error handlers take precedence where defined.
register_error_handlers(app)

@app.context_processor
def inject_user():
"""Make current_user available to all templates."""
user_id = session.get('user_id')
current_user = None
if user_id:
from models import User
current_user = db.session.get(User, user_id)
return dict(current_user=current_user)


@app.after_request
def add_security_headers(response):
Expand All @@ -66,16 +105,7 @@ def add_security_headers(response):
"form-action 'self' https://formspree.io https://api.web3forms.com; "
"frame-ancestors 'none'"
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"form-action 'self' https://formspree.io https://api.web3forms.com; "
"frame-ancestors 'none'"
)

return response


Expand Down
11 changes: 11 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ class Config:
# Base URL for the application - used for OG tags and canonical URLs
# Can be overridden via environment variable for different deployments
BASE_URL = os.getenv("BASE_URL", "https://mydevpath-github.vercel.app")
# Security and Session
SECRET_KEY = os.getenv("SECRET_KEY", "dev_secret_key_change_in_production")

# GitHub OAuth Settings
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "dummy_client_id")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "dummy_client_secret")

# Database Settings
basedir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///" + os.path.join(basedir, "data", "devpath.db"))
SQLALCHEMY_TRACK_MODIFICATIONS = False

# Application metadata for OG tags
SITE_NAME = "DevPath"
Expand Down
73 changes: 73 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class Project(db.Model):
__tablename__ = 'projects'

id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
level = db.Column(db.String(50), nullable=False)
interest = db.Column(db.String(100), nullable=False)
time = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=False)

# Store arrays as JSON
skills = db.Column(db.JSON, nullable=False, default=list)
features = db.Column(db.JSON, nullable=False, default=list)
tech_stack = db.Column(db.JSON, nullable=False, default=list)
roadmap = db.Column(db.JSON, nullable=False, default=list)
resources = db.Column(db.JSON, nullable=True, default=list)
starter_code = db.Column(db.String(500), nullable=True)

def to_dict(self):
"""Convert the model to a dictionary matching projects.json format."""
return {
"id": self.id,
"title": self.title,
"level": self.level,
"interest": self.interest,
"time": self.time,
"description": self.description,
"skills": self.skills if self.skills else [],
"features": self.features if self.features else [],
"tech_stack": self.tech_stack if self.tech_stack else [],
"roadmap": self.roadmap if self.roadmap else [],
"resources": self.resources if self.resources else [],
"starter_code": self.starter_code
}

class User(db.Model):
__tablename__ = 'users'

id = db.Column(db.Integer, primary_key=True)
github_id = db.Column(db.String(120), unique=True, nullable=False)
username = db.Column(db.String(100), nullable=False)
avatar_url = db.Column(db.String(255), nullable=True)
is_admin = db.Column(db.Boolean, nullable=False, default=False)

# Store arrays of strings as JSON
saved_skills = db.Column(db.JSON, nullable=False, default=list)
bookmarked_projects = db.Column(db.JSON, nullable=False, default=list)

def to_dict(self):
return {
"id": self.id,
"github_id": self.github_id,
"username": self.username,
"avatar_url": self.avatar_url,
"is_admin": self.is_admin,
"saved_skills": self.saved_skills if self.saved_skills else [],
"bookmarked_projects": self.bookmarked_projects if self.bookmarked_projects else []
}

class ProjectProgress(db.Model):
__tablename__ = 'project_progress'

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False)
completed_steps = db.Column(db.JSON, nullable=False, default=list)

user = db.relationship('User', backref=db.backref('progress', lazy=True, cascade="all, delete-orphan"))
project = db.relationship('Project')
82 changes: 82 additions & 0 deletions src/routes/admin_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, session
from models import db, Project, User

admin_bp = Blueprint('admin', __name__)

@admin_bp.before_request
def check_admin():
user_id = session.get('user_id')
if not user_id:
return redirect(url_for('auth.login'))

user = db.session.get(User, user_id)
if not user or not user.is_admin:
abort(403)

@admin_bp.route('/')
def dashboard():
page = request.args.get('page', 1, type=int)
per_page = 20
pagination = Project.query.order_by(Project.id.desc()).paginate(page=page, per_page=per_page, error_out=False)
return render_template('admin/dashboard.html', pagination=pagination)

def _get_list_from_form(field_name):
# The form might send comma-separated strings or multiple values
val = request.form.get(field_name, '')
if not val:
return []
# Split by comma and strip whitespace
return [item.strip() for item in val.split(',') if item.strip()]

@admin_bp.route('/projects/new', methods=['GET', 'POST'])
def new_project():
if request.method == 'POST':
project = Project(
title=request.form.get('title', ''),
level=request.form.get('level', ''),
interest=request.form.get('interest', ''),
time=request.form.get('time', ''),
description=request.form.get('description', ''),
skills=_get_list_from_form('skills'),
features=_get_list_from_form('features'),
tech_stack=_get_list_from_form('tech_stack'),
roadmap=_get_list_from_form('roadmap'),
resources=_get_list_from_form('resources'),
starter_code=request.form.get('starter_code', '')
)
db.session.add(project)
db.session.commit()
return redirect(url_for('admin.dashboard'))
return render_template('admin/form.html', project=None)

@admin_bp.route('/projects/<int:id>/edit', methods=['GET', 'POST'])
def edit_project(id):
project = db.session.get(Project, id)
if not project:
abort(404)

if request.method == 'POST':
project.title = request.form.get('title', '')
project.level = request.form.get('level', '')
project.interest = request.form.get('interest', '')
project.time = request.form.get('time', '')
project.description = request.form.get('description', '')
project.skills = _get_list_from_form('skills')
project.features = _get_list_from_form('features')
project.tech_stack = _get_list_from_form('tech_stack')
project.roadmap = _get_list_from_form('roadmap')
project.resources = _get_list_from_form('resources')
project.starter_code = request.form.get('starter_code', '')

db.session.commit()
return redirect(url_for('admin.dashboard'))

return render_template('admin/form.html', project=project)

@admin_bp.route('/projects/<int:id>/delete', methods=['POST'])
def delete_project(id):
project = db.session.get(Project, id)
if project:
db.session.delete(project)
db.session.commit()
return redirect(url_for('admin.dashboard'))
56 changes: 56 additions & 0 deletions src/routes/auth_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from flask import Blueprint, session, redirect, url_for, current_app
from models import db, User

# We import github from app, but since it's instantiated there, it might cause circular import.
# Instead, we can import it locally or place the blueprint accordingly.
# A better way is to pass github from app, or just import it.
# To avoid circular imports, let's delay import or import github from the current_app context or from a dedicated module.
# For simplicity, we can do from app import github

auth_bp = Blueprint('auth', __name__)

@auth_bp.route('/login')
def login():
from app import github
# generate a redirect uri
redirect_uri = url_for('auth.authorize', _external=True)
return github.authorize_redirect(redirect_uri)

@auth_bp.route('/authorize')
def authorize():
from app import github
token = github.authorize_access_token()
if not token:
return redirect(url_for('main.index'))

resp = github.get('user', token=token)
profile = resp.json()

github_id = str(profile.get('id'))
username = profile.get('login')
avatar_url = profile.get('avatar_url')

if not github_id:
return redirect(url_for('main.index'))

user = User.query.filter_by(github_id=github_id).first()
if not user:
user = User(
github_id=github_id,
username=username,
avatar_url=avatar_url
)
db.session.add(user)
else:
user.username = username
user.avatar_url = avatar_url

db.session.commit()

session['user_id'] = user.id
return redirect(url_for('main.profile'))

@auth_bp.route('/logout')
def logout():
session.pop('user_id', None)
return redirect(url_for('main.index'))
Loading