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
64 changes: 35 additions & 29 deletions app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
current_app,
request,
abort,
make_response,
)
from app.config import get_config
import re, json

main_bp: Blueprint = Blueprint("main", __name__)
config = get_config()

import re

CODE_RE = re.compile(r"^[A-Z]{3,4}\d{4}[A-Z]?")

Expand All @@ -37,50 +38,48 @@ def index():
"""
Returns home page
"""
completed = request.cookies.get("completed-courses", "[]")
try:
completed = set(json.loads(completed))
except Exception as e:
current_app.logger.exception(f"Error decoding JSON: {e}")
completed = set()

# Convert to a sorted list for stable JSON serialization in template (sets are not JSON serializable)
completed_list = sorted(completed)
Comment on lines +41 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add tests for cookie handling edge cases.

Cover: no cookie, invalid JSON (e.g., "5"), non-list JSON (e.g., "{}"), mixed/invalid items, lowercase and spaced codes → all should not crash and should normalize consistently.

Run this quick check to find all cookie reads that must tolerate None/invalid JSON:

Also applies to: 91-96


🏁 Script executed:

#!/bin/bash
# Verify all cookie reads of "completed-courses" guard against None/invalid JSON
rg -n -C2 'cookies\.get\(\s*"completed-courses"[^)]*\)' app/routes/main.py
rg -n -C2 'json\.loads\(' app/routes/main.py

Length of output: 928


Unify and Harden Cookie Handling; Add Edge-Case Tests

The two completed-courses cookie reads in app/routes/main.py are handled inconsistently and may still crash on missing or malformed input. We should:

  • Ensure both reads supply a safe default ("[]") to avoid Nonejson.loads(None) errors
  • Broaden the exception handler to catch both json.JSONDecodeError and TypeError (the latter arises when completed is None)
  • Add comprehensive tests covering:
    • No cookie present
    • Invalid JSON (e.g. "5")
    • Non-list JSON (e.g. "{}")
    • Mixed or invalid items within the array
    • Course codes in lowercase or with extra whitespace (should normalize to canonical form)

Locations needing updates:

  • Lines 41–49: already uses cookies.get("completed-courses", "[]"), but confirm exception handling covers all error types.
  • Lines 91–96: change to request.cookies.get("completed-courses", "[]") and extend except to catch TypeError as well.

Suggested shell check to find all occurrences:

#!/bin/bash
rg -n -C2 'cookies\.get\(\s*"completed-courses"' app/routes/main.py
rg -n -C2 'json\.loads\(' app/routes/main.py

Let me know if you’d like help writing those tests or hardening the exception logic.

🤖 Prompt for AI Agents
In app/routes/main.py around lines 41–49 and 91–96, unify and harden the
handling of the "completed-courses" cookie by always using
request.cookies.get("completed-courses", "[]") as the input to json.loads,
validate that the decoded value is a list (otherwise treat as empty), and
broaden the exception handling to catch json.JSONDecodeError and TypeError;
after loading, normalize items by trimming whitespace and uppercasing (and only
keep string items), convert to a set for deduplication and fall back to an empty
set on any error, then produce a stable sorted list for templates; also add
tests for no cookie, invalid JSON (e.g. "5"), non-list JSON (e.g. "{}"),
mixed/invalid array items, and inputs with lowercase/whitespace to assert
normalization to canonical course codes.


return render_template(
"index.html",
title="GraphUF",
semesters=current_app.config["SEMESTERS"],
default_semester=current_app.config["DEFAULT_SEMESTER"],
max_courses_taken=config.MAX_COURSES_TAKEN,
completed_courses=completed_list,
)


@main_bp.route("/unlocks", methods=["POST"])
def unlocks_redirect():
raw = request.form.get("tentative-code", "")
code = normalise(raw)
if not code:
return redirect(url_for("main.index"))

# extract completed courses
completed = []
for i in range(1, config.MAX_COURSES_TAKEN + 1):
raw = request.form.get(f"code{i}", "")
if not raw:
continue

base = normalise(raw)
if not base:
return redirect(url_for("main.index"))

completed.append(base)

code = request.form.get("tentative-code", "")
completed = request.form.get(f"completed-courses", "[]")
sem = request.form.get("semester", current_app.config["DEFAULT_SEMESTER"])

view = request.form.get("view_type", "")
if view != "tcm" and view != "graph":
if view not in ("tcm", "graph"):
abort(400, "Bad view type")
Comment on lines +63 to 69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unnecessary f-string and validate client-provided JSON before use.

  • F541: f-string without placeholders at Line 64.
  • Treat completed-courses as untrusted; parse, validate list type, normalise/dedupe, then re-serialize.

Apply this diff:

-    code = request.form.get("tentative-code", "")
-    completed = request.form.get(f"completed-courses", "[]")
+    code = request.form.get("tentative-code", "")
+    completed_raw = request.form.get("completed-courses", "[]")
+    try:
+        items = json.loads(completed_raw)
+        if not isinstance(items, list):
+            raise ValueError("completed-courses must be a list")
+        # Normalise, dedupe, and keep stable ordering
+        norm = {normalise(c) for c in items if isinstance(c, str) and normalise(c)}
+        completed = json.dumps(sorted(norm))
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.warning("Invalid completed-courses in form: %s", e)
+        completed = "[]"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
code = request.form.get("tentative-code", "")
completed = request.form.get(f"completed-courses", "[]")
sem = request.form.get("semester", current_app.config["DEFAULT_SEMESTER"])
view = request.form.get("view_type", "")
if view != "tcm" and view != "graph":
if view not in ("tcm", "graph"):
abort(400, "Bad view type")
code = request.form.get("tentative-code", "")
completed_raw = request.form.get("completed-courses", "[]")
try:
items = json.loads(completed_raw)
if not isinstance(items, list):
raise ValueError("completed-courses must be a list")
# Normalise, dedupe, and keep stable ordering
norm = {normalise(c) for c in items if isinstance(c, str) and normalise(c)}
completed = json.dumps(sorted(norm))
except (json.JSONDecodeError, TypeError, ValueError) as e:
current_app.logger.warning("Invalid completed-courses in form: %s", e)
completed = "[]"
sem = request.form.get("semester", current_app.config["DEFAULT_SEMESTER"])
view = request.form.get("view_type", "")
if view not in ("tcm", "graph"):
abort(400, "Bad view type")
🧰 Tools
🪛 Ruff (0.12.2)

64-64: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
In app/routes/main.py around lines 63 to 69, replace the unnecessary f-string on
line 64 with a plain string key and validate the client-provided
"completed-courses" JSON before use: read the raw value via
request.form.get("completed-courses", "[]"), parse it with json.loads inside a
try/except and abort(400) on JSONDecodeError, ensure the parsed value is a list
(abort 400 if not), normalize entries (e.g., coerce to str, strip whitespace),
remove duplicates while preserving order, and then either use the validated list
directly or re-serialize with json.dumps when a string is required downstream;
also ensure any subsequent code expects the sanitized list instead of the raw
untrusted string.


return redirect(
url_for(
"main.unlocks_page",
code=code,
completed=_to_CSV(completed),
semester=sem,
view_type=view,
resp = make_response(
redirect(
url_for(
"main.unlocks_page",
code=code,
semester=sem,
view_type=view,
)
)
)
resp.set_cookie("completed-courses", completed)
return resp


@main_bp.route("/unlocks/<code>")
Expand All @@ -89,8 +88,12 @@ def unlocks_page(code: str):
if not base:
abort(400, f"Bad course code: {code}")

completed_raw = request.args.get("completed", "")
completed = set(completed_raw.split(",")) if completed_raw else set()
completed = request.cookies.get("completed-courses", "[]")
try:
completed = set(json.loads(completed))
except Exception as e:
current_app.logger.exception(f"Error decoding JSON: {e}")
completed = set()

sem = request.args.get("semester", current_app.config["DEFAULT_SEMESTER"])
view = request.args.get("view_type", "")
Expand All @@ -114,6 +117,9 @@ def unlocks_page(code: str):
# all courses in unlocks for which you do not meet any other prereqs, excluding tentative course
not_meet_prereqs = set()

"""
this readibility sucsk
"""
for c in unlocked:
"""
Get prerequisites for course c using the same logic as the url_for(api_bp.prereqs()) API route
Expand Down
90 changes: 84 additions & 6 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ <h4><i class="fas fa-info-circle"></i>Tip</h4>
<p>You can also enter a lower-level course you've already taken as a target to open show a wider range of course unlocks!</p>
</div>

<form action="{{ url_for('main.unlocks_redirect') }}" method="post">
<form id="temp-form-name" action="{{ url_for('main.unlocks_redirect') }}" method="post">
<div class="form-section">
<h3><i class="fas fa-target"></i>Target Course</h3>
<div class="form-group">
Expand Down Expand Up @@ -87,6 +87,8 @@ <h3><i class="fas"></i>Semester & View Options</h3>
<div id="config" data-max-courses="{{ max_courses_taken }}" hidden></div>

<script>
// Server-provided previously completed courses (from cookie)
const PRECOMPLETED = JSON.parse('{{ completed_courses | tojson | safe }}');
const configEl = document.getElementById('config');
const maxCourses = parseInt(configEl.dataset.maxCourses, 10);
const minCourses = 5;
Expand Down Expand Up @@ -132,20 +134,96 @@ <h3><i class="fas"></i>Semester & View Options</h3>
addBtn.disabled = currentCount >= maxCourses;
}

// Initial state
// Helper to ensure at least n inputs exist (adding in step-sized batches without exceeding max)
function ensureInputCount(n) {
let currentCount = courseInputsDiv.querySelectorAll('input').length;
while (currentCount < n) {
for (let i = 0; i < 5; i++) {
courseInputsDiv.appendChild(createCourseInput(currentCount + 1));
currentCount++;
}
}
}
Comment on lines +137 to +146
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

ensureInputCount ignores step/max and can over-append inputs.

Respect step and clamp to maxCourses to prevent runaway DOM growth when PRECOMPLETED is large.

Apply this diff:

-    // Helper to ensure at least n inputs exist (adding in step-sized batches without exceeding max)
-    function ensureInputCount(n) {
-      let currentCount = courseInputsDiv.querySelectorAll('input').length;
-      while (currentCount < n) {
-        for (let i = 0; i < 5; i++) {
-          courseInputsDiv.appendChild(createCourseInput(currentCount + 1));
-          currentCount++;
-        }
-      }
-    }
+    // Helper to ensure at least n inputs exist (respecting step and max)
+    function ensureInputCount(n) {
+      const target = Math.min(n, maxCourses);
+      let currentCount = courseInputsDiv.querySelectorAll('input').length;
+      while (currentCount < target) {
+        const toAdd = Math.min(step, target - currentCount);
+        for (let i = 0; i < toAdd; i++) {
+          courseInputsDiv.appendChild(createCourseInput(currentCount + 1));
+          currentCount++;
+        }
+      }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Helper to ensure at least n inputs exist (adding in step-sized batches without exceeding max)
function ensureInputCount(n) {
let currentCount = courseInputsDiv.querySelectorAll('input').length;
while (currentCount < n) {
for (let i = 0; i < 5; i++) {
courseInputsDiv.appendChild(createCourseInput(currentCount + 1));
currentCount++;
}
}
}
// Helper to ensure at least n inputs exist (respecting step and max)
function ensureInputCount(n) {
const target = Math.min(n, maxCourses);
let currentCount = courseInputsDiv.querySelectorAll('input').length;
while (currentCount < target) {
const toAdd = Math.min(step, target - currentCount);
for (let i = 0; i < toAdd; i++) {
courseInputsDiv.appendChild(createCourseInput(currentCount + 1));
currentCount++;
}
}
}
🤖 Prompt for AI Agents
In app/templates/index.html around lines 137 to 146, ensureInputCount currently
ignores the configured step and maxCourses and can append too many inputs;
change the loop to respect step and clamp to maxCourses by computing a batchSize
= Math.min(step, n - currentCount, maxCourses - currentCount) on each iteration,
append only batchSize inputs (incrementing currentCount accordingly), and break
the loop when batchSize <= 0 to avoid runaway DOM growth when PRECOMPLETED or n
is large.


// Populate any pre-completed courses from cookie
if (Array.isArray(PRECOMPLETED) && PRECOMPLETED.length) {
ensureInputCount(Math.max(minCourses, PRECOMPLETED.length));
const inputs = courseInputsDiv.querySelectorAll('input');
PRECOMPLETED.forEach((code, idx) => {
if (idx < inputs.length) {
inputs[idx].value = code;
}
});
}

// Initial state after potential population
updateButtonStates();

// Basic form validation
document.querySelector('form').addEventListener('submit', function(e) {
const CODE_RE = /^[A-Z]{3,4}\d{4}[A-Z]?/;

document.getElementById('temp-form-name').addEventListener('submit', function(e) {
e.preventDefault();

const tentativeCode = document.getElementById('tentative-code').value.trim();
if (!tentativeCode) {
e.preventDefault();
if (!CODE_RE.test(tentativeCode)) {
const el = document.getElementById('tentative-code');
el.focus();
el.style.borderColor = '#e74c3c';
setTimeout(() => { el.style.borderColor = '#e1e5e9'; }, 3000);
alert("Invalid course code(s)")
console.log('Invalid tentative code input:', tentativeCode);
return;
}

const validCourses = new Set();
const courseInputs = document.querySelectorAll('#course-inputs input');

for (const input of courseInputs) {
const value = input.value.trim();
if (value) { // Only process non-empty values
if (!CODE_RE.test(value)) {
input.focus();
input.style.borderColor = '#e74c3c';
alert("Invalid course code(s)");
console.log('Invalid course input:', value);
return;
}
validCourses.add(value.toUpperCase());
}
}

const formData = new FormData();
formData.append('tentative-code', tentativeCode.toUpperCase());
formData.append('completed-courses', JSON.stringify(Array.from(validCourses)));
formData.append('semester', document.getElementById('semester').value);
formData.append('view_type', document.getElementById('view_type').value);

fetch(this.action, {
method: 'POST',
body: formData
}).then(response => {
if (response.ok) {
window.location.href = response.url;
} else {
alert('Submission failed');
}
}).catch(error => {
console.error('Error:', error);
alert('Submission failed');
});
});

// Reset input styling when user interacts with any input
document.addEventListener('input', function(e) {
if (e.target.tagName === 'INPUT') {
e.target.style.borderColor = '';
}
});
document.addEventListener('input', function(e) {
if (e.target.tagName === 'INPUT') {
e.target.style.borderColor = '';
}
}, true);
</script>
</body>
</html>