From 0bfe399d8df44ae3c8dd28579b5585a72ac2f784 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 16:00:37 -0400 Subject: [PATCH 01/11] improve error messaging in csv upload --- backend/app/routers/upload.py | 2 +- frontend/src/pages/ScheduleList.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/upload.py b/backend/app/routers/upload.py index f941520..61a7ef2 100644 --- a/backend/app/routers/upload.py +++ b/backend/app/routers/upload.py @@ -90,7 +90,7 @@ def upload_faculty_preferences(file: UploadFile = File(...), db: Session = Depen records_successful=len(to_insert) + len(to_update), records_failed=len(skipped), available_faculty=result.get("available_faculty"), - errors=[f"Skipped {len(skipped)} unrecognized courses: {', '.join(skipped)}"] if skipped else None, + errors=[f"Skipped {len(skipped)} unrecognized courses: {', '.join(skipped[:5])}{'...' if len(skipped) > 5 else ''}"] if skipped else None, ) diff --git a/frontend/src/pages/ScheduleList.tsx b/frontend/src/pages/ScheduleList.tsx index 5cc7d41..4682d9f 100644 --- a/frontend/src/pages/ScheduleList.tsx +++ b/frontend/src/pages/ScheduleList.tsx @@ -443,12 +443,12 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr
{u.description}
{result?.errors?.length ? ( - +
+ + + + {result.errors[0]} +
) : null}
From c1d32ea0747f4a48ace4a6fb272354eba55db949 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 16:31:21 -0400 Subject: [PATCH 02/11] Improve ui for schedule creation --- frontend/src/index.css | 9 ++ frontend/src/pages/ScheduleList.tsx | 204 +++++++++++++++++----------- 2 files changed, 137 insertions(+), 76 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 3d4896a..a89a03b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,14 @@ @import "tailwindcss"; +@keyframes step-enter { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-step-enter { + animation: step-enter 200ms ease-out both; +} + @theme { --color-burgundy-50: #fdf2f4; --color-burgundy-100: #fce7ec; diff --git a/frontend/src/pages/ScheduleList.tsx b/frontend/src/pages/ScheduleList.tsx index 4682d9f..fbcfc60 100644 --- a/frontend/src/pages/ScheduleList.tsx +++ b/frontend/src/pages/ScheduleList.tsx @@ -245,36 +245,51 @@ const STEP_LABELS: Record = { generate: 'Generate Schedule', }; -function StepIndicator({ current }: { current: Step }) { +function StepIndicator({ current, done = false }: { current: Step; done?: boolean }) { const steps: Step[] = ['upload', 'info', 'generate']; const idx = steps.indexOf(current); return ( -
- {steps.map((s, i) => ( -
-
- {i < idx ? ( - - - - ) : ( - i + 1 +
+ {steps.map((s, i) => { + const isCompleted = i < idx || (done && i === idx); + const isActive = i === idx && !done; + return ( +
+
+
+ {isCompleted ? ( + + + + ) : ( + i + 1 + )} +
+ + {STEP_LABELS[s]} + +
+ {i < steps.length - 1 && ( +
+
+
)}
- - {STEP_LABELS[s]} - - {i < steps.length - 1 &&
} -
- ))} + ); + })}
); } @@ -290,6 +305,7 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr 'time-preferences': null, }); const [uploadBusy, setUploadBusy] = useState(null); + const [uploadProgress, setUploadProgress] = useState>({ courses: 0, 'faculty-preferences': 0, 'time-preferences': 0 }); const [uploadError, setUploadError] = useState(null); const coursesRef = useRef(null); const prefRef = useRef(null); @@ -323,6 +339,7 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr async function doUpload(kind: UploadKind, file: File) { setUploadError(null); setUploadBusy(kind); + setUploadProgress((prev) => ({ ...prev, [kind]: 0 })); try { const formData = new FormData(); formData.append('file', file); @@ -330,6 +347,10 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr url: uploadUrl(kind), method: 'POST', data: formData, + onUploadProgress: (e) => { + const pct = e.total ? Math.round((e.loaded * 100) / e.total) : 0; + setUploadProgress((prev) => ({ ...prev, [kind]: pct })); + }, }); setUploadResults((prev) => ({ ...prev, [kind]: res })); } catch (e: unknown) { @@ -396,9 +417,14 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr } } + const canClose = step !== 'generate' || generateDone; + return ( // backdrop -
+
{/* modal */}
void; onCr {/* header */}

New Schedule

- +
+ {u.label} +

{u.description}

+ {result?.errors?.length ? ( +
+ + + + {result.errors[0]} +
+ ) : null} +
+
+
+ { + const file = e.target.files?.[0]; + if (!file) return; + void doUpload(u.kind, file); + e.currentTarget.value = ''; + }} + /> + +
+ {isBusy && ( +
+
+
+ )}
); })} @@ -485,7 +537,7 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr {/* Step 2: Schedule info */} {step === 'info' && ( -
+
{formError && (
{formError}
)} @@ -537,7 +589,7 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr {/* Step 3: Generate */} {step === 'generate' && ( -
+
{generateError && (
{generateError}
)} From 49c92f7a1140ad213cc0cd8bab782a082ee4c813 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 16:57:27 -0400 Subject: [PATCH 03/11] abstract getting user information to context store --- frontend/src/App.tsx | 33 +++++++++-------- frontend/src/components/SectionComments.tsx | 9 +++-- frontend/src/components/Sidebar.tsx | 14 +++----- frontend/src/context/UserContext.tsx | 40 +++++++++++++++++++++ frontend/src/main.tsx | 38 ++++++++++---------- frontend/src/pages/Courses.tsx | 13 ++----- frontend/src/pages/Faculty.tsx | 14 ++------ frontend/src/pages/ScheduleList.tsx | 7 ++-- frontend/src/pages/Schedules.tsx | 16 ++------- 9 files changed, 95 insertions(+), 89 deletions(-) create mode 100644 frontend/src/context/UserContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35b71f4..63d7eda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Courses from './pages/Courses'; import Sidebar from './components/Sidebar'; import LoginButton from './components/LoginButton'; import { useAuthInterceptor } from './hooks/useAuthInterceptor'; +import { UserProvider } from './context/UserContext'; function App() { const { isAuthenticated, isLoading, error } = useAuth0(); @@ -56,21 +57,23 @@ function App() {
) : ( -
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
+ +
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
)} ); diff --git a/frontend/src/components/SectionComments.tsx b/frontend/src/components/SectionComments.tsx index 4ea9199..8863852 100644 --- a/frontend/src/components/SectionComments.tsx +++ b/frontend/src/components/SectionComments.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { getAutomatedCourseSchedulerAPI, type CommentResponse, type UserResponse } from '../api/generated'; +import { getAutomatedCourseSchedulerAPI, type CommentResponse } from '../api/generated'; +import { useUser } from '../context/UserContext'; function formatDate(iso: string): string { const d = new Date(iso); @@ -8,7 +9,7 @@ function formatDate(iso: string): string { } export default function SectionComments({ sectionId }: { sectionId: number }) { - const [me, setMe] = useState(null); + const { me } = useUser(); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [posting, setPosting] = useState(false); @@ -45,9 +46,7 @@ export default function SectionComments({ sectionId }: { sectionId: number }) { setError(null); setLoading(true); try { - const api = getAutomatedCourseSchedulerAPI(); - const [u, cs] = await Promise.all([api.getMeApiUsersMeGet(), api.getCommentsCommentsSectionIdGet(sectionId)]); - setMe(u); + const cs = await getAutomatedCourseSchedulerAPI().getCommentsCommentsSectionIdGet(sectionId); setComments(cs); } catch { setError('Failed to load comments.'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 53561f3..8023a01 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { NavLink } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; -import { getAutomatedCourseSchedulerAPI } from '../api/generated'; +import { useUser } from '../context/UserContext'; function CalendarIcon() { return ( @@ -59,16 +59,10 @@ const NAV_ITEMS = [ export default function Sidebar() { const [collapsed, setCollapsed] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); + const { me } = useUser(); + const isAdmin = me?.role === 'ADMIN'; const { user, logout } = useAuth0(); - useEffect(() => { - getAutomatedCourseSchedulerAPI() - .getMeApiUsersMeGet() - .then((me) => setIsAdmin(me.role === 'ADMIN')) - .catch(() => {}); - }, []); - return (
@@ -697,7 +697,7 @@ export default function Faculty() { type="text" value={adminFormFirstName} onChange={(e) => setAdminFormFirstName(e.target.value)} - className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" + className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-burgundy-500" placeholder="Jane" />
@@ -709,7 +709,7 @@ export default function Faculty() { type="text" value={adminFormLastName} onChange={(e) => setAdminFormLastName(e.target.value)} - className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" + className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-burgundy-500" placeholder="Doe" />
@@ -723,7 +723,7 @@ export default function Faculty() { autoComplete="email" value={adminFormEmail} onChange={(e) => setAdminFormEmail(e.target.value)} - className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" + className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-burgundy-500" placeholder="j.doe@northeastern.edu" />
@@ -757,7 +757,7 @@ export default function Faculty() { @@ -776,7 +776,7 @@ export default function Faculty() { type="button" onClick={() => void handleGenerateAdminInvite()} disabled={adminInviting} - className="px-4 py-2 text-sm font-medium bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors" + className="px-4 py-2 text-sm font-medium bg-burgundy-600 text-white rounded-lg hover:bg-burgundy-700 disabled:opacity-50 transition-colors" > {adminInviting ? 'Generating…' : 'Generate invite link'} From afa95bb2c0b77c059be5a5fc3611c4ffa3da6234 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 19:35:11 -0400 Subject: [PATCH 09/11] update tests with new mock user profile --- frontend/src/pages/Faculty.test.tsx | 4 ++ frontend/src/pages/ScheduleList.test.tsx | 30 ++++++++----- frontend/src/pages/Schedules.test.tsx | 54 ++++++++++++------------ 3 files changed, 51 insertions(+), 37 deletions(-) diff --git a/frontend/src/pages/Faculty.test.tsx b/frontend/src/pages/Faculty.test.tsx index 2719518..61eff16 100644 --- a/frontend/src/pages/Faculty.test.tsx +++ b/frontend/src/pages/Faculty.test.tsx @@ -24,6 +24,10 @@ const mockAdmin: UserResponse = { active: true, }; +vi.mock('../context/UserContext', () => ({ + useUser: () => ({ me: mockAdmin, meError: null, meLoading: false }), +})); + const mockInviteRows: InviteLinkResponse[] = [ { first_name: 'Jane', diff --git a/frontend/src/pages/ScheduleList.test.tsx b/frontend/src/pages/ScheduleList.test.tsx index c8d3dc8..3a58366 100644 --- a/frontend/src/pages/ScheduleList.test.tsx +++ b/frontend/src/pages/ScheduleList.test.tsx @@ -41,11 +41,21 @@ type ApiMocks = { }; let api: ApiMocks; +let mockUserValue: { me: UserResponse | null; meError: string | null; meLoading: boolean } = { + me: null, + meError: null, + meLoading: false, +}; + +vi.mock('../context/UserContext', () => ({ + useUser: () => mockUserValue, +})); -function mockApi(overrides: Partial = {}) { +function mockApi(overrides: Partial = {}, user: UserResponse = viewerUser) { + mockUserValue = { me: user, meError: null, meLoading: false }; api = { getSchedulesSchedulesGet: vi.fn().mockResolvedValue(mockSchedules), - getMeApiUsersMeGet: vi.fn().mockResolvedValue(viewerUser), + getMeApiUsersMeGet: vi.fn().mockResolvedValue(user), updateScheduleSchedulesScheduleIdPut: vi.fn(), deleteScheduleSchedulesScheduleIdDelete: vi.fn().mockResolvedValue(undefined), getAllSemestersSemestersGet: vi.fn().mockResolvedValue([]), @@ -136,7 +146,7 @@ describe('ScheduleList page', () => { }); it('shows "New Schedule" button for admin users', async () => { - mockApi({ getMeApiUsersMeGet: vi.fn().mockResolvedValue(adminUser) }); + mockApi({}, adminUser); renderList(); expect( await screen.findByRole('button', { name: /New Schedule/ }), @@ -152,10 +162,10 @@ describe('ScheduleList page', () => { it('lets admins save a renamed schedule', async () => { const user = userEvent.setup(); const updated: ScheduleResponse = { ...mockSchedules[0], name: 'Fall 2025 (renamed)' }; - mockApi({ - getMeApiUsersMeGet: vi.fn().mockResolvedValue(adminUser), - updateScheduleSchedulesScheduleIdPut: vi.fn().mockResolvedValue(updated), - }); + mockApi( + { updateScheduleSchedulesScheduleIdPut: vi.fn().mockResolvedValue(updated) }, + adminUser, + ); renderList(); await screen.findByText('Fall 2025'); @@ -177,7 +187,7 @@ describe('ScheduleList page', () => { it('deletes a schedule after confirming', async () => { const user = userEvent.setup(); - mockApi({ getMeApiUsersMeGet: vi.fn().mockResolvedValue(adminUser) }); + mockApi({}, adminUser); renderList(); await screen.findByText('Fall 2025'); @@ -196,7 +206,7 @@ describe('ScheduleList page', () => { it('cancels delete when "No" is clicked', async () => { const user = userEvent.setup(); - mockApi({ getMeApiUsersMeGet: vi.fn().mockResolvedValue(adminUser) }); + mockApi({}, adminUser); renderList(); await screen.findByText('Fall 2025'); @@ -212,7 +222,7 @@ describe('ScheduleList page', () => { it('opens the create-schedule modal when admin clicks "New Schedule"', async () => { const user = userEvent.setup(); - mockApi({ getMeApiUsersMeGet: vi.fn().mockResolvedValue(adminUser) }); + mockApi({}, adminUser); renderList(); await user.click(await screen.findByRole('button', { name: /New Schedule/ })); diff --git a/frontend/src/pages/Schedules.test.tsx b/frontend/src/pages/Schedules.test.tsx index 57a2898..e78ad25 100644 --- a/frontend/src/pages/Schedules.test.tsx +++ b/frontend/src/pages/Schedules.test.tsx @@ -4,7 +4,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import Schedules from './Schedules'; import * as wsModule from '../hooks/useScheduleWebSocket'; import * as generated from '../api/generated'; -import type { SectionRichResponse } from '../api/generated'; +import type { SectionRichResponse, UserResponse } from '../api/generated'; // ── Auth0 mock ──────────────────────────────────────────────────────────────── vi.mock('@auth0/auth0-react', () => ({ @@ -14,6 +14,29 @@ vi.mock('@auth0/auth0-react', () => ({ }), })); +// ── UserContext mock ────────────────────────────────────────────────────────── +const viewerUser: UserResponse = { + user_id: 1, + nuid: 100005, + first_name: 'John', + last_name: 'Doe', + email: 'j.doe@northeastern.edu', + role: 'VIEWER', + active: true, +}; + +const adminUser: UserResponse = { ...viewerUser, role: 'ADMIN' }; + +let mockUserValue: { me: UserResponse | null; meError: string | null; meLoading: boolean } = { + me: viewerUser, + meError: null, + meLoading: false, +}; + +vi.mock('../context/UserContext', () => ({ + useUser: () => mockUserValue, +})); + // ── Child component mock ────────────────────────────────────────────────────── vi.mock('../components/ScheduleSectionRowView', () => ({ default: ({ sections, scheduleId }: { sections: SectionRichResponse[]; scheduleId: number }) => ( @@ -58,6 +81,7 @@ function renderAtRoute(path: string) { describe('Schedules page', () => { beforeEach(() => { + mockUserValue = { me: viewerUser, meError: null, meLoading: false }; vi.spyOn(generated, 'getAutomatedCourseSchedulerAPI').mockReturnValue({ getScheduleSchedulesScheduleIdGet: vi.fn().mockResolvedValue({ schedule_id: 42, name: 'Fall 2025', semester_id: 1, draft: false, campus: 1, active: true, @@ -68,15 +92,7 @@ describe('Schedules page', () => { getFacultyFacultyGet: vi.fn().mockResolvedValue([ { NUID: 100005 }, ]), - getMeApiUsersMeGet: vi.fn().mockResolvedValue({ - user_id: 1, - nuid: 100005, - first_name: 'John', - last_name: 'Doe', - email: 'j.doe@northeastern.edu', - role: 'VIEWER', - active: true, - }), + getMeApiUsersMeGet: vi.fn().mockResolvedValue(viewerUser), } as unknown as ReturnType); }); @@ -142,23 +158,7 @@ describe('Schedules page', () => { it('shows Faculty/Admin mode toggle for ADMIN users', async () => { vi.spyOn(wsModule, 'useScheduleWebSocket').mockReturnValue(defaultWsReturn); - vi.spyOn(generated, 'getAutomatedCourseSchedulerAPI').mockReturnValue({ - getScheduleSchedulesScheduleIdGet: vi.fn().mockResolvedValue({ - schedule_id: 42, name: 'Fall 2025', semester_id: 1, draft: false, campus: 1, active: true, - }), - getAllCampusesCampusesGet: vi.fn().mockResolvedValue([ - { campus_id: 1, name: 'Boston', active: true }, - ]), - getMeApiUsersMeGet: vi.fn().mockResolvedValue({ - user_id: 1, - nuid: 100005, - first_name: 'John', - last_name: 'Doe', - email: 'j.doe@northeastern.edu', - role: 'ADMIN', - active: true, - }), - } as unknown as ReturnType); + mockUserValue = { me: adminUser, meError: null, meLoading: false }; renderAtRoute('/schedules/42'); From cc771dbf1485c508bfc1388126a769ea6db2c739 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 19:45:09 -0400 Subject: [PATCH 10/11] fix be tests --- backend/tests/test_schedule.py | 2 +- backend/tests/test_section_lock.py | 6 +++--- backend/tests/test_section_service.py | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/tests/test_schedule.py b/backend/tests/test_schedule.py index a27598b..56af2c4 100644 --- a/backend/tests/test_schedule.py +++ b/backend/tests/test_schedule.py @@ -316,7 +316,7 @@ def test_create_schedule_new_courses_field_is_ignored(client, db_session): campus = _make_campus(db_session) semester = _make_semester(db_session, season="Fall", year=2024) _make_historical_context(db_session, campus, season="Fall", current_year=2024) - new_course = _make_course(db_session, name="CS 3800", description="Theory of Computation", credits=4) + new_course = _make_course(db_session, code=3800, name="CS 3800", description="Theory of Computation", credits=4) db_session.commit() response = client.post( "/schedules", diff --git a/backend/tests/test_section_lock.py b/backend/tests/test_section_lock.py index 8e36028..4526ac4 100644 --- a/backend/tests/test_section_lock.py +++ b/backend/tests/test_section_lock.py @@ -43,11 +43,11 @@ def _make_user(db, nuid=1, role="ADMIN"): return user -def _make_section(db, season="Fall"): +def _make_section(db, season="Fall", course_code=2500): campus = _make_campus(db) semester = _make_semester(db, season=season) schedule = Schedule(name="F24", semester_id=semester.semester_id, campus=campus.campus_id) - course = Course(subject="CS", code=2500, name="CS 2500", description="Fundamentals", credits=4) + course = Course(subject="CS", code=course_code, name=f"CS {course_code}", description="Fundamentals", credits=4) db.add_all([schedule, course]) db.flush() time_block = TimeBlock( @@ -121,7 +121,7 @@ def test_lock_different_user(client: TestClient, db_session: Session) -> None: def test_previous_lock_releases(client: TestClient, db_session: Session) -> None: user = _make_user(db_session) section1 = _make_section(db_session) - section2 = _make_section(db_session, season="Spring") + section2 = _make_section(db_session, season="Spring", course_code=3500) app.dependency_overrides[get_db_user] = lambda: user client.post(f"/sections/{section1.section_id}/lock") diff --git a/backend/tests/test_section_service.py b/backend/tests/test_section_service.py index 5e1cd6f..7002690 100644 --- a/backend/tests/test_section_service.py +++ b/backend/tests/test_section_service.py @@ -261,8 +261,8 @@ def test_error_check_unpreferenced_course_warning(db_session): campus = _make_campus(db_session) semester = _make_semester(db_session) schedule = _make_schedule(db_session, campus, semester) - course_a = _make_course(db_session, name="CS 2500") - course_b = _make_course(db_session, name="CS 3500") + course_a = _make_course(db_session, name="CS 2500", code=2500) + course_b = _make_course(db_session, name="CS 3500", code=3500) tb = _make_time_block(db_session, campus) faculty = _make_faculty(db_session, campus, nuid=1001, email="f1@test.edu") section = _make_section(db_session, schedule, course_a, tb) @@ -283,8 +283,8 @@ def test_error_check_faculty_has_course_preference_no_warning(db_session): campus = _make_campus(db_session) semester = _make_semester(db_session) schedule = _make_schedule(db_session, campus, semester) - course_a = _make_course(db_session, name="CS 2500") - course_b = _make_course(db_session, name="CS 3500") + course_a = _make_course(db_session, name="CS 2500", code=2500) + course_b = _make_course(db_session, name="CS 3500", code=3500) tb = _make_time_block(db_session, campus) faculty = _make_faculty(db_session, campus, nuid=1001, email="f1@test.edu") section = _make_section(db_session, schedule, course_a, tb) @@ -311,8 +311,8 @@ def test_error_check_not_interested_course_preference_counts_as_warning(db_sessi campus = _make_campus(db_session) semester = _make_semester(db_session) schedule = _make_schedule(db_session, campus, semester) - course_a = _make_course(db_session, name="CS 2500") - course_b = _make_course(db_session, name="CS 3500") + course_a = _make_course(db_session, name="CS 2500", code=2500) + course_b = _make_course(db_session, name="CS 3500", code=3500) tb = _make_time_block(db_session, campus) faculty = _make_faculty(db_session, campus, nuid=1001, email="f1@test.edu") section = _make_section(db_session, schedule, course_a, tb) @@ -540,8 +540,8 @@ def test_update_section_returns_unpreferenced_course_warning(db_session): campus = _make_campus(db_session) semester = _make_semester(db_session) schedule = _make_schedule(db_session, campus, semester) - course_a = _make_course(db_session, name="CS 2500") - course_b = _make_course(db_session, name="CS 3500") + course_a = _make_course(db_session, name="CS 2500", code=2500) + course_b = _make_course(db_session, name="CS 3500", code=3500) tb = _make_time_block(db_session, campus) faculty = _make_faculty(db_session, campus, nuid=1001, email="f1@test.edu") section = _make_section(db_session, schedule, course_a, tb) @@ -559,8 +559,8 @@ def test_update_section_no_warnings_when_faculty_prefers_new_course(db_session): campus = _make_campus(db_session) semester = _make_semester(db_session) schedule = _make_schedule(db_session, campus, semester) - course_a = _make_course(db_session, name="CS 2500") - course_b = _make_course(db_session, name="CS 3500") + course_a = _make_course(db_session, name="CS 2500", code=2500) + course_b = _make_course(db_session, name="CS 3500", code=3500) tb = _make_time_block(db_session, campus) faculty = _make_faculty(db_session, campus, nuid=1001, email="f1@test.edu") section = _make_section(db_session, schedule, course_a, tb) From b4b9c402ba648167e836384b3aa5761f6e493af3 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 19:52:22 -0400 Subject: [PATCH 11/11] catch integrity error suggested by coderabbit --- backend/app/routers/course.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/routers/course.py b/backend/app/routers/course.py index 8d00c42..0133e61 100644 --- a/backend/app/routers/course.py +++ b/backend/app/routers/course.py @@ -1,6 +1,7 @@ """Course router.""" from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.database import get_db @@ -45,6 +46,9 @@ def create_course(course: CourseCreate, db: Session = Depends(get_db)): return course_service.create_course(db, course) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + except IntegrityError as e: + db.rollback() + raise HTTPException(status_code=400, detail="A course with this subject and code already exists") from e @router.patch("/{course_id}", response_model=CourseResponse)