Skip to content
Closed
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
20 changes: 15 additions & 5 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
# Business logic, recommendation scoring, and data loading all live in
# the utils/ and routes/ packages, not here.

from flask import Flask, render_template
from routes.main_routes import main
from flask import Flask, render_template, jsonify
from config import Config
from routes.main_routes import main, limiter

app = Flask(__name__)
app.config.from_object(Config)

# Initialize Limiter with the app
limiter.init_app(app)

# Register all routes defined in the main Blueprint
app.register_blueprint(main)
Expand All @@ -31,6 +36,11 @@ def add_security_headers(response):

# ---- Error handlers ----

@app.errorhandler(429)
def ratelimit_handler(e):
"""Return a structured JSON error for rate limit breaches."""
return jsonify({"error": "Rate limit exceeded. Please try again later."}), 429

@app.errorhandler(404)
def page_not_found(error):
"""Render a friendly 404 page instead of the raw Flask error."""
Expand All @@ -54,6 +64,6 @@ def forbidden(error):


if __name__ == "__main__":
import os
debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() in ("true", "1")
app.run(debug=debug_mode)
# debug=True is only for local development.
# Never run with debug=True in a production deployment.
app.run(debug=True)
20 changes: 20 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import os

class Config:
"""Base configuration class."""
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
DEBUG = os.environ.get("DEBUG", "True").lower() == "true"

# Metadata settings
SITE_NAME = os.environ.get("SITE_NAME", "DevPath")
SITE_DESCRIPTION = os.environ.get("SITE_DESCRIPTION", "Recommend real coding projects based on your skills.")
BASE_URL = os.environ.get("BASE_URL", "http://localhost:5000")
OG_IMAGE_PATH = os.environ.get("OG_IMAGE_PATH", "static/images/og-image.png")

@classmethod
def get_base_url(cls):
return cls.BASE_URL.rstrip('/')

@classmethod
def get_og_image_url(cls):
return f"{cls.get_base_url()}/{cls.OG_IMAGE_PATH.lstrip('/')}"
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
# To also install test dependencies:
# pip install -r requirements.txt pytest

# Web framework (only runtime dependency)
# Web framework (runtime dependencies)
Flask==3.0.3
Flask-Limiter==3.7.0

# Testing (optional but recommended for contributors)
pytest==8.2.2
9 changes: 9 additions & 0 deletions routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@
# and returns a response. No business logic lives here.

from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, make_response
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

from utils.recommender import get_recommendations, validate_recommendation_inputs
from utils.data_loader import find_project_by_id, load_all_projects, get_project_stats
from utils.file_server import read_starter_code, resolve_starter_file, get_starter_code_dir
import os

# Initialize Limiter
limiter = Limiter(
key_func=get_remote_address,
default_limits=[lambda: os.environ.get("RECOMMENDER_RATE_LIMIT", "60 per minute")],
storage_uri="memory://",
)

# Interest categories that currently have no project recommendations available
NO_PROJECT_INTERESTS = {
"machine learning/ai",
Expand Down
64 changes: 33 additions & 31 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ if (clearFiltersBtn) {
function isSkillSelected(skill) {
var normalizedSkill = normalizeSkill(skill);
return selectedSkills.some(function (selectedSkill) {
return normalizeSkill(selectedSkill) === normalizedSkill;
return normalizeSkill(selectedSkill.name) === normalizedSkill;
});
}

Expand Down Expand Up @@ -306,9 +306,7 @@ if (clearFiltersBtn) {
quickPickChips.forEach(function (chip) {
chip.addEventListener("click", function () {
var skill = chip.getAttribute("data-skill");
var isAlreadySelected = selectedSkills.some(function (s) {
return s.toLowerCase() === skill.toLowerCase();
});
var isAlreadySelected = isSkillSelected(skill);

if (isAlreadySelected) {
removeSkill(skill);
Expand Down Expand Up @@ -364,7 +362,10 @@ if (clearFiltersBtn) {
// Block duplicate entries (case-insensitive)
if (isSkillSelected(skill)) return;

selectedSkills.push(skill);
var proficiencySelect = document.getElementById("skill-proficiency");
var proficiency = proficiencySelect ? proficiencySelect.value : "Intermediate";

selectedSkills.push({ name: skill, level: proficiency });
renderSelectedChips();
syncSkillsHiddenInput();
updateQuickPickState();
Expand All @@ -376,7 +377,7 @@ if (clearFiltersBtn) {
function removeSkill(skill) {
// Rebuild the array without the skill that was just removed
selectedSkills = selectedSkills.filter(function (selectedSkill) {
return normalizeSkill(selectedSkill) !== normalizeSkill(skill);
return normalizeSkill(selectedSkill.name) !== normalizeSkill(skill);
});
renderSelectedChips();
syncSkillsHiddenInput();
Expand All @@ -388,11 +389,22 @@ if (clearFiltersBtn) {
function renderSelectedChips() {
// Wipe out old chips first so we don't end up with duplicates in the UI
chipsSelectedEl.innerHTML = "";
selectedSkills.forEach(function (skill) {
selectedSkills.forEach(function (skillObj) {
var skill = skillObj.name;
var level = skillObj.level;

// Create a new chip element for each selected skill
var chipEl = document.createElement("span");
chipEl.className = "skill-chip-selected";
chipEl.textContent = skill;

// Proficiency badge
var badge = document.createElement("span");
badge.className = "skill-proficiency-badge";
badge.textContent = level.substring(0, 3); // BEG, INT, ADV
chipEl.appendChild(badge);

var textNode = document.createTextNode(skill);
chipEl.appendChild(textNode);

// Remove button for each chip (create lil "x" button)
var removeBtn = document.createElement("button");
Expand All @@ -412,12 +424,11 @@ if (clearFiltersBtn) {
}

function syncSkillsHiddenInput() {
if (!skillsHidden){
var skillsHidden = document.getElementById("skills");
}
// Keep the hidden <input> in sync for form serialisation
// The API expects a comma-separated string, so join the array that way
skillsHidden.value = selectedSkills.join(", ");
// Serialize as JSON string for the backend
if (skillsHidden) {
skillsHidden.value = JSON.stringify(selectedSkills);
}
}

updateQuickPickState();
Expand Down Expand Up @@ -525,17 +536,15 @@ if (clearFiltersBtn) {

renderResults(data.projects || [], data.message);
})
.catch(function () {

.catch(function (err) {
setLoadingState(false);
//combine form values into an object to send to server/api
var payload = {
// Prefer the hidden input value; fall back to raw text box if hidden input is empty
skills: skillsHidden.value.trim() || skillsTextInput.value.trim(),
level: document.getElementById("level").value,
interest: document.getElementById("interest").value,
time: document.getElementById("time").value
};
var generalErr = document.getElementById("form-error-general");
if (generalErr) {
generalErr.textContent = "Something went wrong. Please try again.";
}
console.error("Recommendation error:", err);
});
});
});

// Manages the loading state of the form and results section(whats visible or not)
Expand All @@ -556,7 +565,6 @@ if (clearFiltersBtn) {
resultsSection.scrollIntoView({ behavior: "smooth" });
} else {
resultsLoadingEl.style.display = "none";
resultsGrid.style.display = "grid"; //switch back to gird layout
}
}

Expand All @@ -574,17 +582,11 @@ if (clearFiltersBtn) {
resultsGrid.innerHTML = "";

if (!projects || projects.length === 0) {
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";
if (message && emptyMessageEl) emptyMessageEl.textContent = message;
if (!projects || projects.length === 0) { //if no projects returned from api, show the "no results" message and hide the grid
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";

// Show a friendly custom message when the user selected an interest
var selectedInterest = document.getElementById("interest")?.value;
var selectedInterest = document.getElementById("interest") ? document.getElementById("interest").value : "";
if (selectedInterest) {
emptyMessageEl.textContent = "No projects are currently available for this interest. Please check back later or try a different area.";
} else if (message) {
Expand Down
35 changes: 35 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,41 @@ label {
padding: 3px 10px 3px 12px;
}

.skill-proficiency-badge {
font-size: 0.65rem;
text-transform: uppercase;
background: rgba(0, 0, 0, 0.08);
color: var(--indigo-800);
padding: 1px 6px;
border-radius: 4px;
margin-right: 2px;
font-weight: 700;
letter-spacing: 0.02em;
}

.skill-proficiency-select {
border: none;
background: var(--indigo-50);
color: var(--indigo-700);
font-size: 0.75rem;
font-weight: 700;
padding: 2px 24px 2px 8px;
border-radius: 4px;
cursor: pointer;
margin-right: 6px;
outline: none;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%233347e0' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
transition: background var(--t);
}

.skill-proficiency-select:hover {
background-color: var(--indigo-100);
}

.skill-chip-remove {
background: none;
border: none;
Expand Down
13 changes: 9 additions & 4 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,18 @@ <h2 class="section-title">Find Your Next Project</h2>
</div>
<div class="skill-input-wrap" id="skill-input-wrap">
<div class="skill-chips-selected" id="skill-chips-selected"></div>

<!-- Skill Proficiency Selector -->
<select id="skill-proficiency" class="skill-proficiency-select" aria-label="Skill Proficiency">
<option value="Beginner">Beginner</option>
<option value="Intermediate" selected>Intermediate</option>
<option value="Advanced">Advanced</option>
</select>

<input
type="text"
id="skills-input"
placeholder="Type a skill and press Enter..."
placeholder="Type a skill..."
autocomplete="off"
aria-haspopup="listbox"
aria-expanded="false"
Expand Down Expand Up @@ -524,9 +532,6 @@ <h2 class="section-title">Recommended Projects</h2>
<div id="results-empty" style="display:none;">
<div class="empty-state">
<div class="empty-icon">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><line
x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
Expand Down
41 changes: 41 additions & 0 deletions tests/test_rate_limiting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
import pytest
from app import app

@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client

def test_rate_limiting_api_recommend(client):
"""Fire 3 rapid requests; the 3rd should be rate limited (429)."""
# Set rate limit low for THIS test
os.environ["RECOMMENDER_RATE_LIMIT"] = "2 per minute"

payload = {
"skills": "Python",
"level": "Beginner",
"interest": "Data",
"time": "Low"
}

try:
# Request 1: Should be 200
resp1 = client.post("/api/recommend", json=payload)
assert resp1.status_code == 200

# Request 2: Should be 200
resp2 = client.post("/api/recommend", json=payload)
assert resp2.status_code == 200

# Request 3: Should be 429
resp3 = client.post("/api/recommend", json=payload)
assert resp3.status_code == 429

data = resp3.get_json()
assert "error" in data
assert "Rate limit exceeded" in data["error"]
finally:
# Reset rate limit so other tests are not affected
os.environ["RECOMMENDER_RATE_LIMIT"] = "60 per minute"
Loading