diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 80e23874..cdba1353 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -24,11 +24,12 @@ jobs:
- name: Install dependencies
run: |
pip install -r requirements-dev.txt
+ pip install pytest
# Run tests
- name: Run tests
run: |
- python -m unittest tests/test_basic.py
+ PYTHONPATH=src pytest
# Optional: Print success
- name: Success message
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index da21d874..8647362c 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -29,7 +29,8 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- pip install flake8
+ if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
+ pip install flake8 pytest
- name: Lint code (non-blocking)
run: |
@@ -37,4 +38,4 @@ jobs:
- name: Run tests
run: |
- python -m unittest tests/test_basic.py
+ PYTHONPATH=src pytest
diff --git a/data/devpath.db b/data/devpath.db
new file mode 100644
index 00000000..47760a9c
Binary files /dev/null and b/data/devpath.db differ
diff --git a/rewrite_explore.py b/rewrite_explore.py
new file mode 100644
index 00000000..4a03aa86
--- /dev/null
+++ b/rewrite_explore.py
@@ -0,0 +1,156 @@
+import re
+
+with open("src/templates/explore.html", "r") as f:
+ content = f.read()
+
+# Replace the hero section, stats section, etc. with a simple explore layout.
+# We will find the tag or and remove everything until
+"""
+ new_body = new_body.replace("{% end从业%}", "{% endfor %}")
+ content = content[:start_idx] + new_body + content[end_idx:]
+
+with open("src/templates/explore.html", "w") as f:
+ f.write(content)
+
+print("Done")
diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py
index 161f0f26..9f60e274 100644
--- a/src/routes/main_routes.py
+++ b/src/routes/main_routes.py
@@ -28,6 +28,7 @@
from flask import jsonify
from utils.portfolio_analyzer import analyze_portfolio
import os
+import math
from models import db, ProjectProgress
_skill_validator = SkillProgressionValidator()
@@ -68,6 +69,86 @@ def index():
return render_template("index.html", stats=stats, available_levels=available_levels, available_interests=available_interests, config=Config)
+@main.route("/explore")
+def explore():
+ """Render the explore page with server-side pagination, filtering, and sorting."""
+ page = request.args.get("page", 1, type=int)
+ per_page = request.args.get("per_page", 12, type=int)
+ search_query = request.args.get("search", "").strip().lower()
+ level_filter = request.args.get("level", "").strip().lower()
+ interest_filter = request.args.get("interest", "").strip().lower()
+ time_filter = request.args.get("time", "").strip().lower()
+ sort_by = request.args.get("sort", "id_asc")
+
+ all_projects = load_all_projects()
+ filtered_projects = []
+
+ for p in all_projects:
+ # Search text
+ if search_query:
+ searchable_text = (
+ p.get("title", "") + " " +
+ p.get("description", "") + " " +
+ " ".join(p.get("skills", []))
+ ).lower()
+ if search_query not in searchable_text:
+ continue
+
+ if level_filter and p.get("level", "").lower() != level_filter:
+ continue
+
+ if interest_filter and p.get("interest", "").lower() != interest_filter:
+ continue
+
+ if time_filter and p.get("time", "").lower() != time_filter:
+ continue
+
+ filtered_projects.append(p)
+
+ # Sorting
+ if sort_by == "title_asc":
+ filtered_projects.sort(key=lambda x: x.get("title", "").lower())
+ elif sort_by == "title_desc":
+ filtered_projects.sort(key=lambda x: x.get("title", "").lower(), reverse=True)
+ elif sort_by == "id_desc":
+ filtered_projects.sort(key=lambda x: x.get("id", 0), reverse=True)
+ else: # id_asc
+ filtered_projects.sort(key=lambda x: x.get("id", 0))
+
+ total_items = len(filtered_projects)
+ total_pages = math.ceil(total_items / per_page) if total_items > 0 else 1
+
+ # Bound page number
+ if page < 1:
+ page = 1
+ elif page > total_pages:
+ page = total_pages
+
+ start_idx = (page - 1) * per_page
+ end_idx = start_idx + per_page
+
+ paginated_projects = filtered_projects[start_idx:end_idx]
+
+ # Also pass filter dropdown options to UI
+ available_levels = get_available_levels()
+ stats = get_project_stats()
+
+ return render_template(
+ "explore.html",
+ projects=paginated_projects,
+ page=page,
+ total_pages=total_pages,
+ total_items=total_items,
+ search=search_query,
+ level=level_filter,
+ interest=interest_filter,
+ time=time_filter,
+ sort=sort_by,
+ available_levels=available_levels,
+ stats=stats,
+ config=Config
+ )
+
@main.route("/contact")
def contact():
return render_template("contact.html", config=Config)
diff --git a/src/templates/contact.html b/src/templates/contact.html
index 9eb28451..ec52900f 100644
--- a/src/templates/contact.html
+++ b/src/templates/contact.html
@@ -117,7 +117,7 @@
-
-
- Send Message
-
diff --git a/src/templates/explore.html b/src/templates/explore.html
new file mode 100644
index 00000000..8568f802
--- /dev/null
+++ b/src/templates/explore.html
@@ -0,0 +1,737 @@
+
+
+
+
+
+
+
+ DevPath — Find Projects Based On Your Skills
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include 'partials/theme_head.html' %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
JavaScript is required to use DevPath. Please enable JavaScript in your browser settings and reload the page.
+
+
+
+
+
+
+
+ Explore All Projects
+ Browse our entire catalog of projects, filter by difficulty or interest, and find your next coding challenge.
+
+
+
+
+
+
+
+
+
Showing {{ projects|length }} of {{ total_items }} projects
+
+
+ {% if projects %}
+
+ {% for project in projects %}
+
+
{{ project.title }}
+
{{ project.description | truncate(120) }}
+
+ {% for skill in project.skills %}
+ {{ skill }}
+ {% endfor %}
+ {{ project.level }}
+ Time: {{ project.time }}
+
+
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
No Projects Found
+
Try adjusting your filters or search term.
+
Clear Filters
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/index.html b/src/templates/index.html
index 3bdf348a..b82d08d6 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -455,6 +455,7 @@
How It Works
Features
Find Project
+ Explore All
Compare
Contact
{% if current_user %}
@@ -1268,6 +1269,7 @@
Home
How It Works
Features
+ Explore All
Find Project
Contact Us
diff --git a/test_recommender.py b/test_recommender.py
index 8546263d..4df5ae77 100644
--- a/test_recommender.py
+++ b/test_recommender.py
@@ -15,6 +15,12 @@
_get_related,
_load_clusters,
)
+try:
+ from app import app
+ ctx = app.app_context()
+ ctx.push()
+except ImportError:
+ pass
# ---------------------------------------------------------------------------
# Helpers
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 24d3c9a0..e2f2974e 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -14,6 +14,7 @@
score_single_project,
SCORING_WEIGHTS,
VALID_LEVELS,
+ VALID_INTERESTS,
VALID_TIME_AVAILABILITY,
)
from utils.roadmap_comparer import compare_roadmaps, load_all_career_roadmaps
@@ -311,6 +312,31 @@ def test_home_route():
assert response.status_code == 200
+def test_explore_route():
+ """Explore route should return 200 OK and handle pagination."""
+ client = get_client()
+ response = client.get("/explore?page=1&per_page=5")
+ assert response.status_code == 200
+ html = response.data.decode("utf-8")
+ assert "Explore All Projects" in html
+ # The max per_page is 5, so there should be pagination controls or fewer items.
+ assert 'class="project-card"' in html
+
+def test_contact_page_renders_send_message_form():
+ """Contact page should include the external form handler and required fields."""
+ client = get_client()
+ response = client.get("/contact")
+
+ assert response.status_code == 200
+ html = response.get_data(as_text=True)
+
+ assert 'class="contact-form"' in html
+ assert 'action="https://api.web3forms.com/submit"' in html
+ assert 'method="POST"' in html
+ assert 'name="name"' in html
+ assert 'name="email"' in html
+ assert 'name="message"' in html
+ assert "Send Message" in html
def test_security_headers_present():
"""Security headers should be included in all responses."""
client = get_client()