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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
seed.py
.env
backend/.env
Missing courses & errors in CarletonCourseMap - Google Docs.pdf
.vscode/settings.json
personalLog.txt
Expand Down
121 changes: 64 additions & 57 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 1 addition & 2 deletions backend/scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/app/map/components/CoursePanel.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/map/utils/buildGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading