Skip to content

Comments

Chore/improve completed courses ux#41

Merged
aleguy02 merged 6 commits intomainfrom
chore/improve-completed-courses-ux
Aug 24, 2025
Merged

Chore/improve completed courses ux#41
aleguy02 merged 6 commits intomainfrom
chore/improve-completed-courses-ux

Conversation

@aleguy02
Copy link
Owner

@aleguy02 aleguy02 commented Aug 24, 2025

Completed courses are now stored in a cookie so the user doesn't have to input them over and over again.

Summary by CodeRabbit

  • New Features

    • Completed courses now persist via a browser cookie and are retained across pages.
    • Course input fields are prepopulated from saved data and grow automatically to accommodate saved or entered courses.
    • Form submission uses AJAX for smoother navigation.
  • Improvements

    • Stronger client-side validation for course codes and tentative code with clearer feedback (focus, highlighting) that resets on typing.
    • Server-side cookie handling with error logging; completed data no longer exposed in URLs.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 24, 2025

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Caution

Review failed

The pull request is closed.

Walkthrough

Completed-course state is moved to a JSON cookie (completed-courses) persisted by the server. Backend reads/writes that cookie and adjusts index/unlocks routes. Frontend index page now uses AJAX submission, CODE_RE validation, dynamic input creation, and prepopulates inputs from server-provided completed_courses.

Changes

Cohort / File(s) Summary of changes
Backend: cookie-based state and route updates
app/routes/main.py
Added json import and make_response. Index reads completed-courses cookie, decodes JSON with error handling, converts to a sorted list for the template. POST /unlocks reads a single completed-courses JSON field from the form, validates view_type, builds a redirect response via make_response, and sets the completed-courses cookie on the response. Unlocks page reads cookie for prereq computation. Left an unused helper (_to_CSV) and inserted extra docstring text (non-functional).
Frontend: AJAX submission, validation, and prepopulation
app/templates/index.html
Replaced default form submit with AJAX fetch POST; added client-side CODE_RE validation for tentative and course inputs with UI feedback; introduced PRECOMPLETED (derived from server completed_courses) to prepopulate inputs; added ensureInputCount(n) to create inputs in batches, input-style reset handlers, and preserved existing button state logic. On successful POST, client navigates to server redirect URL.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Browser
  participant ClientJS as Index (client JS)
  participant Server as App Server
  participant Unlocks as /unlocks handler

  User->>Browser: GET /
  Browser->>Server: GET /
  Server-->>Browser: HTML (includes completed_courses)
  Browser->>ClientJS: render + prepopulate from PRECOMPLETED

  User->>ClientJS: submits form (AJAX)
  ClientJS->>Server: POST /unlocks (body includes completed-courses JSON, view_type)
  Server->>Unlocks: validate view_type, compute redirect URL
  Unlocks-->>Server: create response via make_response, set `completed-courses` cookie, set Location header (redirect)
  Server-->>ClientJS: 302 redirect response
  ClientJS->>Browser: set window.location to redirect URL
  Browser->>Server: GET redirected page
  Server-->>Browser: Unlocks page (reads `completed-courses` cookie to compute prereqs)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I nibbled bytes and hid a JSON treat,
Tucked codes in cookie-pouches, tidy and neat.
Regex whiskers twitch, inputs rise in rows,
Fetch carries the hop where the redirect goes.
Carrots unlocked — a joyful, bouncy prose. 🥕🐇

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 97e5abc and 637118a.

📒 Files selected for processing (1)
  • app/routes/main.py (4 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/improve-completed-courses-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (9)
app/routes/main.py (3)

71-82: Harden the cookie (expiry, SameSite, Secure, HttpOnly).

Set explicit attributes to reduce CSRF/snooping risk and ensure reasonable retention.

Apply this diff:

-    resp.set_cookie("completed-courses", completed)
+    resp.set_cookie(
+        "completed-courses",
+        completed,
+        max_age=60 * 60 * 24 * 180,  # 180 days
+        samesite="Lax",
+        secure=request.is_secure,
+        httponly=True,
+        path="/",
+    )

120-122: Remove stray triple-quoted string with typos.

This is an inert string literal with spelling errors. Replace with a concise comment.

Apply this diff:

-    """
-    this readibility sucsk
-    """
+    # TODO: Improve readability of the following loop.

123-141: Avoid duplicating prereq lookup logic; extract a shared helper.

This re-implements the API’s prereq computation. Prefer extracting a utility (e.g., services/courses.py:get_direct_prereqs(graph, code, sem)) used by both the API and this view to keep behavior in sync and simplify maintenance.

I can draft a small helper module and wire both call-sites to it if you’d like.

app/templates/index.html (6)

90-92: Inline JSON directly; avoid JSON.parse roundtrip.

Jinja’s tojson produces a JS literal. Using JSON.parse on it is unnecessary and adds failure surface.

Apply this diff:

-    // Server-provided previously completed courses (from cookie)
-    const PRECOMPLETED = JSON.parse('{{ completed_courses | tojson | safe }}');
+    // Server-provided previously completed courses (from cookie)
+    const PRECOMPLETED = {{ completed_courses | tojson }};

163-164: Anchor the validation regex to the end.

Without $, inputs that merely start with a valid code will pass.

Apply this diff:

-    const CODE_RE = /^[A-Z]{3,4}\d{4}[A-Z]?/;
+    const CODE_RE = /^[A-Z]{3,4}\d{4}[A-Z]?$/;

25-25: Rename temporary form id for clarity and update listener.

“temp-form-name” reads as a placeholder. Use a stable, descriptive id.

Apply this diff:

-      <form id="temp-form-name" action="{{ url_for('main.unlocks_redirect') }}" method="post">
+      <form id="course-form" action="{{ url_for('main.unlocks_redirect') }}" method="post">
-    document.getElementById('temp-form-name').addEventListener('submit', function(e) {
+    document.getElementById('course-form').addEventListener('submit', function(e) {

Also applies to: 165-165


201-213: Handle redirects explicitly to avoid fetching a full HTML response before navigation.

Fetch follows the 302 and downloads the entire unlocks page, then you navigate to it again. Consider manual redirect to use the Location header directly.

Example change:

-      fetch(this.action, {
-        method: 'POST',
-        body: formData
-      }).then(response => {
-        if (response.ok) {
-          window.location.href = response.url;
-        } else {
-          alert('Submission failed');
-        }
-      }).catch(error => {
+      fetch(this.action, {
+        method: 'POST',
+        body: formData,
+        redirect: 'manual',
+      }).then(response => {
+        const loc = response.headers.get('Location');
+        if (loc) {
+          window.location.assign(loc);
+        } else if (response.ok) {
+          window.location.assign(response.url);
+        } else {
+          alert('Submission failed');
+        }
+      }).catch(error => {
         console.error('Error:', error);
         alert('Submission failed');
       });

216-226: Remove duplicate input event listener.

Two nearly identical listeners reset the border color; keep one.

Apply this diff:

-    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);
+    document.addEventListener('input', function(e) {
+      if (e.target.tagName === 'INPUT') {
+        e.target.style.borderColor = '';
+      }
+    });

148-157: Clamp prepopulation target and call updateButtonStates after ensureInputCount.

If PRECOMPLETED is very large, ensureInputCount will now cap additions, but it’s safer to clamp the requested count, then refresh controls immediately.

Apply this diff:

-    if (Array.isArray(PRECOMPLETED) && PRECOMPLETED.length) {
-      ensureInputCount(Math.max(minCourses, PRECOMPLETED.length));
+    if (Array.isArray(PRECOMPLETED) && PRECOMPLETED.length) {
+      ensureInputCount(Math.max(minCourses, PRECOMPLETED.length));
+      updateButtonStates();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 38282ea and 1cc2570.

📒 Files selected for processing (2)
  • app/routes/main.py (4 hunks)
  • app/templates/index.html (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/routes/main.py (2)
app/config.py (1)
  • get_config (30-32)
app/routes/api.py (2)
  • unlocks (9-20)
  • prereqs (24-43)
🪛 Ruff (0.12.2)
app/routes/main.py

64-64: f-string without any placeholders

Remove extraneous f prefix

(F541)

🪛 GitHub Actions: Run Tests
app/routes/main.py

[error] 43-43: TypeError: the JSON object must be str, bytes or bytearray, not NoneType. 'completed' is None when json.loads(completed) is executed at app/routes/main.py:43 (triggered by tests/test_routes.py::test_home).

Comment on lines +63 to 69
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")
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.

Comment on lines +137 to +146
// 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++;
}
}
}
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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
app/routes/main.py (2)

63-65: Remove unnecessary f-string (Ruff F541) and validate client-provided JSON before setting the cookie.

Sanitize, normalize, and re-serialize to a canonical JSON array string so downstream routes consume trusted input.

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")
+        norm_set: set[str] = set()
+        for c in items:
+            if isinstance(c, str):
+                n = normalise(c)
+                if n:
+                    norm_set.add(n)
+        completed = json.dumps(sorted(norm_set))
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.warning("Invalid completed-courses in form: %s", e)
+        completed = "[]"

91-97: Mirror robust cookie parsing here (default to "[]", catch TypeError, and normalize).

This route still crashes on json.loads(None) and set(...) on scalars; it also needs normalization for reliable comparisons.

Apply this diff:

-    completed = request.cookies.get("completed-courses")
-    try:
-        completed = set(json.loads(completed))
-    except json.JSONDecodeError as e:
-        current_app.logger.exception(f"Error decoding JSON: {e}")
-        completed = set()
+    completed_raw = request.cookies.get("completed-courses")
+    try:
+        items = json.loads(completed_raw or "[]")
+        if not isinstance(items, list):
+            raise ValueError("completed-courses cookie must be a JSON list")
+        completed = set()
+        for c in items:
+            if isinstance(c, str):
+                n = normalise(c)
+                if n:
+                    completed.add(n)
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.exception("Error decoding completed-courses cookie: %s", e)
+        completed = set()
🧹 Nitpick comments (3)
app/routes/main.py (3)

71-82: Set cookie security attributes and a finite lifetime.

Prefer HttpOnly + SameSite=Lax and Secure from config. This reduces XSS exposure and CSRF surface; also bound cookie size/lifetime.

Apply this diff:

-    resp = make_response(
+    resp = make_response(
         redirect(
             url_for(
                 "main.unlocks_page",
                 code=code,
                 semester=sem,
                 view_type=view,
             )
         )
     )
-    resp.set_cookie("completed-courses", completed)
+    resp.set_cookie(
+        "completed-courses",
+        completed,
+        max_age=60 * 60 * 24 * 180,  # 180 days
+        httponly=True,
+        samesite="Lax",
+        secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)),
+    )
     return resp

Optional: enforce a conservative size cap before setting the cookie (e.g., if len(completed) > 3500: log and fall back to "[]").


120-122: Remove stray triple-quoted strings.

These are no-op runtime string literals that reduce readability and slightly bloat bytecode. Replace with comments or delete.

Apply this diff:

-    """
-    this readibility sucsk
-    """
+    # (removed stray string literal)
@@
-        """
-        Get prerequisites for course c using the same logic as the url_for(api_bp.prereqs()) API route
-        please review this code carefully in code review. It may be wise to opt for an internal API call if we want
-        to use the prereqs API elsewhere in the app. For now, it is more efficient to just duplicate the logic
-        """
+        # Get prerequisites for course c using the same logic as the API route.
+        # Consider refactoring to a shared helper if the logic is reused elsewhere.

Also applies to: 124-128


41-47: Optional: factor cookie parsing into a helper to avoid duplication.

Both routes now share identical parsing/normalization. Extract into a private helper for maintainability.

Example helper to add near normalise():

def _parse_completed_cookie(raw: str | None) -> set[str]:
    try:
        items = json.loads(raw or "[]")
        if not isinstance(items, list):
            raise ValueError("completed-courses must be a list")
        out: set[str] = set()
        for c in items:
            if isinstance(c, str):
                n = normalise(c)
                if n:
                    out.add(n)
        return out
    except (json.JSONDecodeError, TypeError, ValueError):
        current_app.logger.exception("Error decoding completed-courses cookie")
        return set()

Then use completed = _parse_completed_cookie(request.cookies.get("completed-courses")) in both places. I can push this refactor if you want.

Also applies to: 91-97

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1cc2570 and af811ac.

📒 Files selected for processing (1)
  • app/routes/main.py (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/routes/main.py (2)
app/config.py (1)
  • get_config (30-32)
app/routes/api.py (2)
  • unlocks (9-20)
  • prereqs (24-43)
🪛 Ruff (0.12.2)
app/routes/main.py

64-64: f-string without any placeholders

Remove extraneous f prefix

(F541)

🔇 Additional comments (3)
app/routes/main.py (3)

13-16: Imports look good.

Adding make_response and json is appropriate for the new cookie-based flow.


68-69: LGTM: view_type validation.

Early gating to allowed values is correct.


110-113: Error message may misattribute ValueError source.

struct.postreqs can raise ValueError for reasons other than “course not found” (e.g., bad semester). Consider surfacing the actual message or distinguishing causes.

If you want to keep it minimal:

-    except ValueError:
-        abort(400, f"Course not found in catalog: {base}")
+    except ValueError as e:
+        abort(400, str(e))

Comment on lines 41 to 47
completed = request.cookies.get("completed-courses", "[]")
try:
completed = set(json.loads(completed))
except json.JSONDecodeError as e:
current_app.logger.exception(f"Error decoding JSON: {e}")
completed = set()

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

⚠️ Potential issue

Harden cookie parsing and normalize course codes to prevent crashes and inconsistent matches.

json.loads may return non-lists (e.g., "5"), making set(...) raise TypeError; elements may also be unnormalized, breaking set logic against graph codes. Validate type, normalize via normalise(), dedupe, and log with parameterized logging.

Apply this diff:

-    completed = request.cookies.get("completed-courses", "[]")
-    try:
-        completed = set(json.loads(completed))
-    except json.JSONDecodeError 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)
+    completed_raw = request.cookies.get("completed-courses")
+    try:
+        items = json.loads(completed_raw or "[]")
+        if not isinstance(items, list):
+            raise ValueError("completed-courses cookie must be a JSON list")
+        norm: set[str] = set()
+        for c in items:
+            if isinstance(c, str):
+                n = normalise(c)
+                if n:
+                    norm.add(n)
+        completed = norm
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.exception("Error decoding completed-courses cookie: %s", e)
+        completed = set()
+
+    # Convert to a sorted list for stable JSON serialization in template
+    completed_list = sorted(completed)

Also applies to: 48-50, 57-57

🤖 Prompt for AI Agents
In app/routes/main.py around lines 41-47 (also apply same changes at 48-50 and
57), json.loads may return non-list or contain unnormalized items causing
TypeError and inconsistent matches; after loading, verify the result is a list,
coerce/ignore non-string items, run each string through normalise(), build a
deduplicated set from the normalized values, and replace direct
set(json.loads(...)) with this validated flow; on JSON decode or unexpected
types log errors using parameterized logging (no f-strings) and ensure fallback
to an empty set when invalid.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
app/routes/main.py (3)

41-49: Harden cookie parsing: validate type, normalize course codes, and avoid broad except.

Current code trusts cookie JSON and builds a set directly. If the cookie holds a non-list (e.g., "5"), set(...) raises TypeError; also, codes aren’t normalized, so comparisons against graph codes may fail. Prefer narrow exceptions and parameterized logging. This mirrors prior feedback.

Apply this diff:

-    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)
+    completed_raw = request.cookies.get("completed-courses")
+    try:
+        items = json.loads(completed_raw or "[]")
+        if not isinstance(items, list):
+            raise ValueError("completed-courses cookie must be a JSON list")
+        norm: set[str] = set()
+        for c in items:
+            if isinstance(c, str):
+                n = normalise(c)
+                if n:
+                    norm.add(n)
+        completed = norm
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.exception("Error decoding completed-courses cookie: %s", e)
+        completed = set()
+
+    # Convert to a sorted list for stable JSON serialization in template
+    completed_list = sorted(completed)

63-69: Sanitize client JSON and set a secure, long‑lived cookie; drop unnecessary f-string; return 303.

  • Remove the f-string on Line 64 (Ruff F541).
  • Parse/validate/normalize/dedupe the client-provided JSON, and enforce MAX_COURSES_TAKEN.
  • Set cookie with security attributes (HttpOnly, SameSite, Secure) and a reasonable TTL.
  • Use a 303 redirect for POST/Redirect/GET.

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")
+        # normalize, dedupe, and cap to configured max (preserve order)
+        max_n = int(getattr(config, "MAX_COURSES_TAKEN", 20))
+        norm: list[str] = []
+        seen: set[str] = set()
+        for c in items:
+            if isinstance(c, str):
+                n = normalise(c)
+                if n and n not in seen:
+                    seen.add(n)
+                    norm.append(n)
+            if len(norm) >= max_n:
+                break
+        completed_json = json.dumps(norm)
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.warning("Invalid completed-courses in form: %s", e)
+        completed_json = "[]"
@@
-    resp = make_response(
-        redirect(
+    resp = make_response(
+        redirect(
             url_for(
                 "main.unlocks_page",
                 code=code,
                 semester=sem,
                 view_type=view,
-            )
+            ),
+            code=303,
         )
     )
-    resp.set_cookie("completed-courses", completed)
+    resp.set_cookie(
+        "completed-courses",
+        completed_json,
+        max_age=60 * 60 * 24 * 180,  # ~180 days
+        httponly=True,
+        samesite="Lax",
+        secure=request.is_secure,
+        path="/",
+    )
     return resp

Also applies to: 71-83


91-96: Fix crash when cookie is absent or malformed; normalize cookie values.

json.loads(None) raises TypeError, and a non-list JSON causes downstream errors. Mirror the index-route parsing and normalize codes for consistent set membership checks. This was flagged previously; ensure it’s actually applied here.

Apply this diff:

-    completed = request.cookies.get("completed-courses")
-    try:
-        completed = set(json.loads(completed))
-    except json.JSONDecodeError as e:
-        current_app.logger.exception(f"Error decoding JSON: {e}")
-        completed = set()
+    completed_raw = request.cookies.get("completed-courses")
+    try:
+        items = json.loads(completed_raw or "[]")
+        if not isinstance(items, list):
+            raise ValueError("completed-courses cookie must be a JSON list")
+        completed = {normalise(c) for c in items if isinstance(c, str) and normalise(c)}
+    except (json.JSONDecodeError, TypeError, ValueError) as e:
+        current_app.logger.exception("Error decoding completed-courses cookie: %s", e)
+        completed = set()
🧹 Nitpick comments (2)
app/routes/main.py (2)

120-122: Remove stray triple-quoted string; replace with a comment.

It’s not a docstring and adds noise (typos included).

Apply this diff:

-    """
-    this readibility sucsk
-    """
+    # TODO: Improve readability of the unlocks logic

13-16: Minor: split combined import and keep imports one-per-line (PEP 8 E401).

Not blocking, just future-proofing for linters.

Suggested tweak:

-from flask import (
+from flask import (
@@
-)
-from app.config import get_config
-import re, json
+)
+from app.config import get_config
+import json
+import re
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between af811ac and 97e5abc.

📒 Files selected for processing (1)
  • app/routes/main.py (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/routes/main.py (3)
tests/conftest.py (1)
  • app (7-14)
app/config.py (1)
  • get_config (30-32)
app/routes/api.py (2)
  • unlocks (9-20)
  • prereqs (24-43)
🪛 Ruff (0.12.2)
app/routes/main.py

64-64: f-string without any placeholders

Remove extraneous f prefix

(F541)

🔇 Additional comments (1)
app/routes/main.py (1)

68-69: LGTM: view validation is clear and strict.

Membership check against a closed set keeps the route contract tight.

Comment on lines +41 to +49
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)
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.

@aleguy02
Copy link
Owner Author

@coderabbitai ignore

@aleguy02 aleguy02 merged commit f93db8b into main Aug 24, 2025
1 of 2 checks passed
@aleguy02 aleguy02 deleted the chore/improve-completed-courses-ux branch August 24, 2025 19:10
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 24, 2025

✅ Actions performed

Reviews paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant