Skip to content
Merged
3 changes: 2 additions & 1 deletion backend/app/models/course.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING

from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func

Expand All @@ -16,6 +16,7 @@ class Course(Base):
"""Represents a course offered by the college."""

__tablename__ = "course"
__table_args__ = (UniqueConstraint("subject", "code", name="uq_course_subject_code"),)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

course_id: Mapped[int] = mapped_column(Integer, primary_key=True)
subject: Mapped[str] = mapped_column(String(10))
Expand Down
4 changes: 4 additions & 0 deletions backend/app/routers/course.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/section.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class SectionUpdate(BaseModel):

class CourseInfo(BaseModel):
course_id: int
subject: str
code: int
name: str
description: str
credits: int
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/section.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ def get_rich_sections(db: Session, schedule_id: int) -> list[SectionRichResponse
crosslisted_section_id=s.crosslisted_section_id,
course=CourseInfo(
course_id=s.course.course_id,
subject=s.course.subject,
code=s.course.code,
name=s.course.name,
description=s.course.description,
credits=s.course.credits,
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_section_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 10 additions & 10 deletions backend/tests/test_section_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion frontend/openapi.json

Large diffs are not rendered by default.

33 changes: 18 additions & 15 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -56,21 +57,23 @@ function App() {
</div>
</div>
) : (
<div className="flex h-screen overflow-hidden bg-gray-50">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<div className="p-8">
<Routes>
<Route path="/" element={<Navigate to="/schedules" replace />} />
<Route path="/schedules" element={<ScheduleList />} />
<Route path="/schedules/:scheduleId" element={<Schedules />} />
<Route path="/faculty/schedules/:scheduleId" element={<Schedules readOnly />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/courses" element={<Courses />} />
</Routes>
</div>
</main>
</div>
<UserProvider>
<div className="flex h-screen overflow-hidden bg-gray-50">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<div className="p-8">
<Routes>
<Route path="/" element={<Navigate to="/schedules" replace />} />
<Route path="/schedules" element={<ScheduleList />} />
<Route path="/schedules/:scheduleId" element={<Schedules />} />
<Route path="/faculty/schedules/:scheduleId" element={<Schedules readOnly />} />
<Route path="/faculty" element={<Faculty />} />
<Route path="/courses" element={<Courses />} />
</Routes>
</div>
</main>
</div>
</UserProvider>
)}
</BrowserRouter>
);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export interface CourseCreate {

export interface CourseInfo {
course_id: number;
subject: string;
code: number;
name: string;
description: string;
credits: number;
Expand Down
52 changes: 21 additions & 31 deletions frontend/src/components/ScheduleSectionRowView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ export default function ScheduleSectionRowView({
const [catalogCourses, setCatalogCourses] = useState<CourseResponse[]>([]);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
const api = getAutomatedCourseSchedulerAPI();
api.getCoursesCoursesGet().then((cs) => setCatalogCourses(cs)).catch(() => {});
}, []);

const handleInstructorMouseEnter = useCallback((e: React.MouseEvent<HTMLElement>, instructor: InstructorInfo) => {
const rect = e.currentTarget.getBoundingClientRect();
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
Expand All @@ -124,42 +129,23 @@ export default function ScheduleSectionRowView({
[],
);

useEffect(() => {
const api = getAutomatedCourseSchedulerAPI();
api
.getCoursesCoursesGet()
.then((cs) => setCatalogCourses(cs))
.catch(() => {});
}, []);

const catalogById = useMemo(() => {
const m = new Map<number, CourseResponse>();
for (const c of catalogCourses) m.set(c.course_id, c);
return m;
}, [catalogCourses]);

const courseMetaForUi = useCallback(
(section: SectionRichResponse): { code: string | null; name: string } => {
const cat = catalogById.get(section.course.course_id);
const name = cat?.name ?? section.course.name;
const subject = cat?.subject?.trim();
const codeNo = cat?.code;
const code = subject && codeNo != null ? `${subject}${codeNo}` : null;
const { subject, code: codeNo, name } = section.course;
const code = subject.trim() && codeNo != null ? `${subject}${codeNo}` : null;
return { code, name };
},
[catalogById],
[],
);

const courseLabelForUi = useCallback(
(section: SectionRichResponse) => {
const cat = catalogById.get(section.course.course_id);
return formatCourseLabel({
name: cat?.name ?? section.course.name,
subject: cat?.subject,
code: cat?.code,
});
},
[catalogById],
(section: SectionRichResponse) =>
formatCourseLabel({
name: section.course.name,
subject: section.course.subject,
code: section.course.code,
}),
[],
);

const handleSort = (key: SortKey) => {
Expand Down Expand Up @@ -576,8 +562,8 @@ export default function ScheduleSectionRowView({

{/* Section # */}
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
Section {section.section_number}
<span className="inline-flex items-center gap-1 ml-1">
{section.section_number}
<CrosslistSectionHint section={section} allSections={sections} />
</span>
</td>
Expand Down Expand Up @@ -661,6 +647,8 @@ export default function ScheduleSectionRowView({
timeBlocks={timeBlocks}
campusId={campusId}
campusName={campusName}
courses={catalogCourses}
scheduleSections={sections}
onClose={handleEditClose}
onTimeBlockCreated={(tb: TimeBlockFull) => setTimeBlocks((prev) => [...prev, tb].sort((a, b) => {
const ta = parseTimeToMinutes(a.start_time);
Expand All @@ -679,6 +667,8 @@ export default function ScheduleSectionRowView({
timeBlocks={timeBlocks}
campusId={campusId}
campusName={campusName}
courses={catalogCourses}
scheduleSections={sections}
onClose={() => setCreating(false)}
onTimeBlockCreated={(tb: TimeBlockFull) => setTimeBlocks((prev) => [...prev, tb].sort((a, b) => {
const ta = parseTimeToMinutes(a.start_time);
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/SectionComments.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -8,7 +9,7 @@ function formatDate(iso: string): string {
}

export default function SectionComments({ sectionId }: { sectionId: number }) {
const [me, setMe] = useState<UserResponse | null>(null);
const { me } = useUser();
const [comments, setComments] = useState<CommentResponse[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
Expand Down Expand Up @@ -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.');
Expand Down
28 changes: 9 additions & 19 deletions frontend/src/components/SectionMutationDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,8 @@ interface BaseProps {
timeBlocks: TimeBlockFull[];
campusId: number | null;
campusName: string | null;
courses: CourseResponse[];
scheduleSections: SectionRichResponse[];
onClose: () => void;
/** Called when admin creates a new time block inline — parent should add it to its list. */
onTimeBlockCreated?: (tb: TimeBlockFull) => void;
Expand All @@ -465,7 +467,7 @@ function Label({ children }: { children: React.ReactNode }) {
}

export default function SectionMutationDrawer(props: Props) {
const { scheduleId, timeBlocks, campusId, campusName, onClose, onTimeBlockCreated } = props;
const { scheduleId, timeBlocks, campusId, campusName, courses, scheduleSections, onClose, onTimeBlockCreated } = props;
const isEdit = props.mode === 'edit';
const section = isEdit ? props.section : null;
const sectionWarnings: WarningResponse[] = isEdit ? (props.warnings ?? []) : [];
Expand All @@ -485,10 +487,7 @@ export default function SectionMutationDrawer(props: Props) {
section?.crosslisted_section_id ?? null,
);

// Remote data
const [courses, setCourses] = useState<CourseResponse[]>([]);
const [faculty, setFaculty] = useState<FacultyResponse[]>([]);
const [scheduleSections, setScheduleSections] = useState<SectionRichResponse[]>([]);
const [loadingData, setLoadingData] = useState(true);

const catalogById = useMemo(() => {
Expand Down Expand Up @@ -521,22 +520,13 @@ export default function SectionMutationDrawer(props: Props) {

useEffect(() => {
const api = getAutomatedCourseSchedulerAPI();
Promise.allSettled([
// All catalog courses — "must already exist" means in the catalog, not in this schedule
api.getCoursesCoursesGet(),
// Faculty scoped to this campus; active only
api.getFacultyFacultyGet(
campusName ? { campus: campusName, active_only: true } : { active_only: true },
),
// Schedule sections, to detect double-booking conflicts
api.getScheduleSectionsRichSchedulesScheduleIdSectionsRichGet(scheduleId),
]).then(([courseResult, facultyResult, sectionsResult]) => {
if (courseResult.status === 'fulfilled') setCourses(courseResult.value);
if (facultyResult.status === 'fulfilled') setFaculty(facultyResult.value);
if (sectionsResult.status === 'fulfilled') setScheduleSections(sectionsResult.value);
api.getFacultyFacultyGet(
campusName ? { campus: campusName, active_only: true } : { active_only: true },
).then((result) => {
setFaculty(result);
setLoadingData(false);
});
}, [campusName, scheduleId]);
}).catch(() => setLoadingData(false));
}, [campusName]);

const crosslistOptions = useMemo((): SelectOption<number | null>[] => {
const none: SelectOption<number | null> = { value: null, label: 'Not crosslisted' };
Expand Down
Loading
Loading