From 06ac40baf418cd1795d6c99bb0eede70a4e9a604 Mon Sep 17 00:00:00 2001 From: Zaid Ahmad <109442753+zaidahmad16@users.noreply.github.com> Date: Tue, 26 May 2026 12:43:49 -0400 Subject: [PATCH] fix: patch merge bugs and harden backend security --- .github/workflows/ci.yml | 46 ++++++++ .gitignore | 1 + backend/main.py | 121 +++++++++++--------- backend/scraper.py | 3 +- frontend/app/map/components/CoursePanel.jsx | 1 + frontend/app/map/utils/buildGraph.js | 2 +- 6 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f67d963 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: Backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r backend/requirements.txt + + - name: Check imports + run: python -c "import sys; sys.path.insert(0, 'backend'); import main" + + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + env: + NEXT_PUBLIC_API_URL: http://localhost:8000 diff --git a/.gitignore b/.gitignore index 97d584f..4ab67d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ seed.py .env +backend/.env Missing courses & errors in CarletonCourseMap - Google Docs.pdf .vscode/settings.json personalLog.txt diff --git a/backend/main.py b/backend/main.py index 460be25..5d267d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -190,9 +190,8 @@ def get_programs(request: Request, dept:int=None): conn.close() @app.get("/programs/featured") -def get_featured_programs(): - conn = get_connection() - cur = conn.cursor() +@limiter.limit("60/minute") +def get_featured_programs(request: Request): keywords = [ 'Software Engineering', 'Artificial Intelligence', @@ -201,34 +200,40 @@ def get_featured_programs(): 'Psychology', 'Biology', ] - results = [] - seen_ids = set() - for kw in keywords: - cur.execute(""" - SELECT p.program_id, p.dept_id, p.degree, d.name AS dept_name - FROM programs p - JOIN departments d USING(dept_id) - WHERE p.degree ILIKE %s - ORDER BY length(p.degree) - LIMIT 1 - """, (f'%{kw}%',)) - row = cur.fetchone() - if row and row[0] not in seen_ids: - seen_ids.add(row[0]) - results.append({ - "program_id": row[0], - "dept_id": row[1], - "degree": row[2], - "dept_name": row[3], - }) - cur.close() - conn.close() - return results[:6] + conn = get_connection() + try: + cur = conn.cursor() + results = [] + seen_ids = set() + for kw in keywords: + cur.execute(""" + SELECT p.program_id, p.dept_id, p.degree, d.name AS dept_name + FROM programs p + JOIN departments d USING(dept_id) + WHERE p.degree ILIKE %s + ORDER BY length(p.degree) + LIMIT 1 + """, (f'%{kw}%',)) + row = cur.fetchone() + if row and row[0] not in seen_ids: + seen_ids.add(row[0]) + results.append({ + "program_id": row[0], + "dept_id": row[1], + "degree": row[2], + "dept_name": row[3], + }) + cur.close() + return results[:6] + except Exception as e: + logger.error("GET /programs/featured failed: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + finally: + conn.close() @app.get("/programs/{program_id}") - -def get_programs(program_id: int): - +@limiter.limit("60/minute") +def get_program(request: Request, program_id: int): conn = get_connection() try: cur = conn.cursor() @@ -237,7 +242,6 @@ def get_programs(program_id: int): row = cur.fetchone() if not row: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="Program not found") prog_id, dept_id, degree, layout_cols, notes = row @@ -293,35 +297,38 @@ def get_programs(program_id: int): base_edges = cur.fetchall() cur.close() - finally: - conn.close() - # concentration courses come first so their layout positions take priority; - # base program courses fill in the remaining slots - all_reqs = list(reqs) + [r for r in base_reqs if r not in reqs] - all_edges = list(edges) + [e for e in base_edges if e not in edges] + all_reqs = list(reqs) + [r for r in base_reqs if r not in reqs] + all_edges = list(edges) + [e for e in base_edges if e not in edges] - return { - "program_id": prog_id, - "dept_id": dept_id, - "degree": degree, - "layout_cols": layout_cols, - "requirements": [ - { - "type": r[0], - "courses": r[1], - "credits": float(r[2]) if r[2] else None, - "description": r[3], - "layout_col": r[4], - "layout_row": r[5], - } for r in all_reqs - ], - "edges": [ - {"source": e[0], "target": e[1], "type": e[2]} - for e in all_edges - ], - "notes": notes or [], - } + return { + "program_id": prog_id, + "dept_id": dept_id, + "degree": degree, + "layout_cols": layout_cols, + "requirements": [ + { + "type": r[0], + "courses": r[1], + "credits": float(r[2]) if r[2] else None, + "description": r[3], + "layout_col": r[4], + "layout_row": r[5], + } for r in all_reqs + ], + "edges": [ + {"source": e[0], "target": e[1], "type": e[2]} + for e in all_edges + ], + "notes": notes or [], + } + except HTTPException: + raise + except Exception as e: + logger.error("GET /programs/%s failed: %s", program_id, e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + finally: + conn.close() class Edge(BaseModel): diff --git a/backend/scraper.py b/backend/scraper.py index fb43ff1..b479ab4 100644 --- a/backend/scraper.py +++ b/backend/scraper.py @@ -165,7 +165,6 @@ def scrape_program(name, url): # concurrent prerequisites are course codes that appear in a concurrent clause if prerequisites: - COURSE_RE = re.compile(r'\b([A-Z]{3,4}\s+\d{4}[A-Z]?)\b') # find all "concurrently" clauses: "X may be taken concurrently" or "concurrent with X" concurrent_raw = re.findall( r'([A-Z]{3,4}\s+\d{4}[A-Z]?)\s+(?:may\s+be\s+taken\s+concurrently|concurrently)', @@ -334,7 +333,7 @@ def flush_choose(): choose_buffer.extend(courses) else: flush_choose() - row_type = "elective" if "elective" in lower else "elective" + row_type = "elective" if "elective" in lower else "required" requirements.append({ "type": row_type, "courses": courses, diff --git a/frontend/app/map/components/CoursePanel.jsx b/frontend/app/map/components/CoursePanel.jsx index 53ba662..1d35b33 100644 --- a/frontend/app/map/components/CoursePanel.jsx +++ b/frontend/app/map/components/CoursePanel.jsx @@ -1,4 +1,5 @@ /* course detail panel, always in the DOM and slides in from the right */ +import { useEffect } from 'react' export const CoursePanel = ({ node, onClose }) => { const isOpen = node != null && !node.data?.isElective diff --git a/frontend/app/map/utils/buildGraph.js b/frontend/app/map/utils/buildGraph.js index a2f55a0..3525497 100644 --- a/frontend/app/map/utils/buildGraph.js +++ b/frontend/app/map/utils/buildGraph.js @@ -122,7 +122,7 @@ export const buildGraph = (requirements, edges, courseDetails, numCols = 8) => { // fill every column up to MIN_ROWS with padding nodes const paddingNodes = [] for (let col = 0; col < numCols; col++) { - for (let row = 0; row < gridRows; row++) { + for (let row = 0; row < MIN_ROWS; row++) { if (isOccupied(col, row)) continue // row 4 and above get Free Elective, everything earlier gets Breadth Elective const label = row >= 4 ? 'Free Elective' : 'Breadth Elective'