diff --git a/.babelrc.json b/.babelrc.json index 4dfe13cb0..986584ab0 100644 --- a/.babelrc.json +++ b/.babelrc.json @@ -1,4 +1,4 @@ { "presets": ["@babel/preset-react", "@babel/preset-typescript"], - "plugins": ["transform-class-properties", "lodash", "@babel/plugin-transform-runtime", "dynamic-import-node"] + "plugins": ["transform-class-properties", "@babel/plugin-transform-runtime", "dynamic-import-node"] } diff --git a/csm_web/frontend/src/components/course/Course.tsx b/csm_web/frontend/src/components/course/Course.tsx index 7282f1b66..c66f682d4 100644 --- a/csm_web/frontend/src/components/course/Course.tsx +++ b/csm_web/frontend/src/components/course/Course.tsx @@ -116,7 +116,10 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): let currDaySections = sections && sections[currDayGroup]; if (currDaySections && !showUnavailable) { - currDaySections = currDaySections.filter(({ numStudentsEnrolled, capacity }) => numStudentsEnrolled < capacity); + currDaySections = currDaySections.filter( + ({ numStudentsEnrolled, capacity, numStudentsWaitlisted, waitlistCapacity }) => + numStudentsEnrolled < capacity || numStudentsWaitlisted < waitlistCapacity + ); } const enrollmentDate = @@ -206,6 +209,7 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): key={section.id} userIsCoordinator={userIsCoordinator} courseOpen={course.enrollmentOpen} + courseId={course.id} {...section} /> )) diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx index e7dfc7c20..49c26a643 100644 --- a/csm_web/frontend/src/components/course/SectionCard.tsx +++ b/csm_web/frontend/src/components/course/SectionCard.tsx @@ -2,8 +2,13 @@ import React, { useState } from "react"; import { Link, Navigate } from "react-router-dom"; import { formatSpacetimeInterval } from "../../utils/datetime"; -import { EnrollUserMutationResponse, useEnrollUserMutation } from "../../utils/queries/sections"; -import { Mentor, Spacetime } from "../../utils/types"; +import { useProfiles } from "../../utils/queries/base"; +import { + EnrollUserMutationResponse, + useEnrollUserMutation, + useEnrollStudentToWaitlistMutation +} from "../../utils/queries/sections"; +import { Mentor, Role, Spacetime } from "../../utils/types"; import Modal, { ModalCloser } from "../Modal"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; @@ -11,6 +16,7 @@ import ClockIcon from "../../../static/frontend/img/clock.svg"; import GroupIcon from "../../../static/frontend/img/group.svg"; import LocationIcon from "../../../static/frontend/img/location.svg"; import UserIcon from "../../../static/frontend/img/user.svg"; +import WaitlistIcon from "../../../static/frontend/img/waitlist.svg"; import XCircle from "../../../static/frontend/img/x_circle.svg"; interface SectionCardProps { @@ -22,6 +28,9 @@ interface SectionCardProps { description: string; userIsCoordinator: boolean; courseOpen: boolean; + numStudentsWaitlisted: number; + waitlistCapacity: number; + courseId: number; } export const SectionCard = ({ @@ -32,12 +41,21 @@ export const SectionCard = ({ capacity, description, userIsCoordinator, - courseOpen + courseOpen, + numStudentsWaitlisted, + waitlistCapacity, + courseId }: SectionCardProps): React.ReactElement => { /** * Mutation to enroll a student in the section. */ const enrollStudentMutation = useEnrollUserMutation(id); + /** + * Mutation to enroll a student in the section's waitlist. + */ + const enrollStudentWaitlistMutation = useEnrollStudentToWaitlistMutation(id); + + const { data: profiles } = useProfiles(); /** * Whether to show the modal (after an attempt to enroll). @@ -51,19 +69,26 @@ export const SectionCard = ({ * The error message if the enrollment failed. */ const [errorMessage, setErrorMessage] = useState(""); + /** + * Whether to show the swap/waitlist confirmation modal. + */ + const [showSwapConfirm, setShowSwapConfirm] = useState(false); + /** + * Whether the pending action is a waitlist join (vs direct enroll). + */ + const [pendingIsWaitlist, setPendingIsWaitlist] = useState(false); /** - * Handle enrollment in the section. + * Check if the user is already enrolled in another section of this course. */ - const enroll = () => { - if (!courseOpen) { - setShowModal(true); - setEnrollmentSuccessful(false); - setErrorMessage("The course is not open for enrollment."); - return; - } + const isAlreadyEnrolled = profiles?.some(p => p.courseId === courseId && p.role === Role.STUDENT) ?? false; - enrollStudentMutation.mutate(undefined, { + /** + * Perform the actual mutation (enroll or waitlist). + */ + const performEnroll = (useWaitlist: boolean) => { + const mutation = useWaitlist ? enrollStudentWaitlistMutation : enrollStudentMutation; + mutation.mutate(undefined, { onSuccess: () => { setEnrollmentSuccessful(true); setShowModal(true); @@ -76,6 +101,31 @@ export const SectionCard = ({ }); }; + /** + * Handle enrollment in the section. + */ + const enroll = () => { + if (!courseOpen) { + setShowModal(true); + setEnrollmentSuccessful(false); + setErrorMessage("The course is not open for enrollment."); + return; + } + + // Determine if we should use waitlist mutation (enrolled capacity is full but waitlist is not full) + const isEnrolledFull = numStudentsEnrolled >= capacity; + const shouldUseWaitlist = isEnrolledFull && numStudentsWaitlisted < waitlistCapacity; + + // If user is already enrolled in another section, show a confirmation warning + if (isAlreadyEnrolled) { + setPendingIsWaitlist(shouldUseWaitlist); + setShowSwapConfirm(true); + return; + } + + performEnroll(shouldUseWaitlist); + }; + /** * Handle closeing of the modal. */ @@ -114,7 +164,8 @@ export const SectionCard = ({ const iconWidth = "1.3em"; const iconHeight = "1.3em"; - const isFull = numStudentsEnrolled >= capacity; + const isFull = numStudentsEnrolled >= capacity && numStudentsWaitlisted >= waitlistCapacity; + const isEnrolledFull = numStudentsEnrolled >= capacity; if (!showModal && enrollmentSuccessful) { // redirect to the section page if the user was successfully enrolled in the section return ; @@ -130,6 +181,43 @@ export const SectionCard = ({ return ( + {showSwapConfirm && ( + setShowSwapConfirm(false)}> +
+ {pendingIsWaitlist ? ( + <> +

Join waitlist?

+

+ You are currently enrolled in another section of this course. When a spot opens up on this waitlist, + you will be automatically dropped from your current section and enrolled in this one. +

+ + ) : ( + <> +

Switch sections?

+

+ You are currently enrolled in another section of this course. Enrolling here will{" "} + drop you from your current section and enroll you in this one. +

+ + )} +
+ + +
+
+
+ )} {showModal && {modalContents()}}
@@ -171,7 +259,11 @@ export const SectionCard = ({ {mentor.name}

- {`${numStudentsEnrolled}/${capacity}`} + {`Enrolled: ${numStudentsEnrolled}/${capacity}`} +

+

+ {" "} + {`Waitlisted: ${numStudentsWaitlisted}/${waitlistCapacity}`}

{userIsCoordinator ? ( @@ -184,7 +276,7 @@ export const SectionCard = ({ disabled={!courseOpen || isFull} onClick={isFull ? undefined : enroll} > - ENROLL + {isFull ? "FULL" : isEnrolledFull ? "JOIN WAITLIST" : "ENROLL"} )}
diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx index ff2f5a5aa..03857ed1b 100644 --- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx +++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; import { useUserEmails } from "../../utils/queries/base"; -import { useEnrollStudentMutation } from "../../utils/queries/sections"; +import { useEnrollStudentMutation, useCoordEnrollStudentToWaitlistMutation } from "../../utils/queries/sections"; import LoadingSpinner from "../LoadingSpinner"; import Modal from "../Modal"; @@ -22,6 +22,10 @@ enum CoordModalStates { interface CoordinatorAddStudentModalProps { closeModal: (arg0?: boolean) => void; sectionId: number; + title: string; + mutation: ( + sectionId: number + ) => ReturnType | ReturnType; } interface RequestType { @@ -50,10 +54,12 @@ interface ActionType { export function CoordinatorAddStudentModal({ closeModal, - sectionId + sectionId, + title, + mutation }: CoordinatorAddStudentModalProps): React.ReactElement { const { data: userEmails, isSuccess: userEmailsLoaded } = useUserEmails(); - const enrollStudentMutation = useEnrollStudentMutation(sectionId); + const enrollMutation = mutation(sectionId); const [emailsToAdd, setEmailsToAdd] = useState([""]); const [response, setResponse] = useState({} as ResponseType); @@ -127,8 +133,8 @@ export function CoordinatorAddStudentModal({ request.actions["capacity"] = responseActions.get("capacity") as string; } - enrollStudentMutation.mutate(request, { - onError: ({ status, json }) => { + enrollMutation.mutate(request, { + onError: ({ status, json }: { status: number; json: any }) => { if (status === 500) { // internal error setResponse({ @@ -182,7 +188,7 @@ export function CoordinatorAddStudentModal({ const initial_component = ( -

Add new students

+

Add new {title}

{emailsToAdd.map((email, index) => ( @@ -297,6 +303,9 @@ export function CoordinatorAddStudentModal({ conflictDetail = "User is already a coordinator for the course!"; } else if (email_obj.detail.reason === "mentor") { conflictDetail = "User is already a mentor for the course!"; + } else { + // display the reason string directly (e.g. from waitlist coord-add) + conflictDetail = email_obj.detail.reason; } drop_disabled = true; } else if (email_obj.detail.section.id == sectionId) { diff --git a/csm_web/frontend/src/components/section/MentorSection.tsx b/csm_web/frontend/src/components/section/MentorSection.tsx index e0ce4387d..0506c200d 100644 --- a/csm_web/frontend/src/components/section/MentorSection.tsx +++ b/csm_web/frontend/src/components/section/MentorSection.tsx @@ -17,6 +17,7 @@ interface MentorSectionProps { capacity: number; description: string; courseRestricted: boolean; + waitlistCapacity: number; } export default function MentorSection({ @@ -28,7 +29,8 @@ export default function MentorSection({ capacity, description, userRole, - mentor + mentor, + waitlistCapacity }: MentorSectionProps) { return ( } /> diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index e2e76b309..d3ecddcee 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -1,6 +1,11 @@ import React, { useState } from "react"; -import { useSectionStudents } from "../../utils/queries/sections"; +import { + useSectionStudents, + useSectionWaitlistedStudents, + useEnrollStudentMutation, + useCoordEnrollStudentToWaitlistMutation +} from "../../utils/queries/sections"; import { Mentor, Spacetime, Student } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import { CoordinatorAddStudentModal } from "./CoordinatorAddStudentModal"; @@ -30,6 +35,103 @@ interface MentorSectionInfoProps { description: string; id: number; courseRestricted: boolean; + waitlistCapacity: number; +} + +interface SectionInfoProps { + title: string; + students: Student[]; + studentsLoaded: boolean; + studentsLoadError: boolean; + isCoordinator: boolean; + sectionId: number; + courseRestricted: boolean; + mutation: ( + sectionId: number + ) => ReturnType | ReturnType; +} + +export function SectionInfo({ + title, + mutation, + students, + studentsLoaded, + studentsLoadError, + isCoordinator, + sectionId, + courseRestricted +}: SectionInfoProps): React.ReactElement { + const [isAddingStudent, setIsAddingStudent] = useState(false); + + const closeAddModal = () => { + setIsAddingStudent(false); + }; + return ( + + {studentsLoaded ? ( + // done loading + + + + + + + + + {(students.length === 0 ? [{ name: "No students enrolled", email: "", id: -1 }] : students).map( + ({ name, email, id: studentId }: Student) => ( + + + + ) + )} + {isCoordinator && ( + + + + + + )} + +
Name
+ {isCoordinator && studentId !== -1 && ( + + )} + {name || email} +
+ +
+ {isCoordinator && isAddingStudent && ( + + )} +
+ ) : studentsLoadError ? ( + // error loading +

Students could not be loaded

+ ) : ( + // not done loading + + )} +
+ ); } export default function MentorSectionInfo({ @@ -39,86 +141,49 @@ export default function MentorSectionInfo({ capacity, id: sectionId, description, - courseRestricted + courseRestricted, + waitlistCapacity }: MentorSectionInfoProps) { const { data: students, isSuccess: studentsLoaded, isError: studentsLoadError } = useSectionStudents(sectionId); + const { + data: waitlistedStudents, + isSuccess: waitlistedStudentsLoaded, + isError: waitlistedStudentsLoadError + } = useSectionWaitlistedStudents(sectionId); const [showModal, setShowModal] = useState(ModalStates.NONE); const [focusedSpacetimeID, setFocusedSpacetimeID] = useState(-1); - const [isAddingStudent, setIsAddingStudent] = useState(false); const [deleteType, setDeleteType] = useState(false); const closeModal = () => setShowModal(ModalStates.NONE); - const closeAddModal = () => { - setIsAddingStudent(false); - }; - return (

{`${ isCoordinator ? `${mentor.name || mentor.email}'s` : "My" } Section`}

- - {studentsLoaded ? ( - // done loading - - - - - - - - - {(students.length === 0 ? [{ name: "No students enrolled", email: "", id: -1 }] : students).map( - ({ name, email, id: studentId }: Student) => ( - - - - ) - )} - {isCoordinator && ( - - - - - - )} - -
Name
- {isCoordinator && studentId !== -1 && ( - - )} - {name || email} -
- -
- {isCoordinator && isAddingStudent && ( - - )} -
- ) : studentsLoadError ? ( - // error loading -

Students could not be loaded

- ) : ( - // not done loading - - )} -
+ + +
{spacetimes.map(({ override, ...spacetime }, index) => ( )} @@ -228,6 +294,9 @@ export default function MentorSectionInfo({

Description: {description}

+

+ Waitlist Capacity: {waitlistCapacity} +

diff --git a/csm_web/frontend/src/components/section/MetaEditModal.tsx b/csm_web/frontend/src/components/section/MetaEditModal.tsx index 90b8dd398..0657e9b84 100644 --- a/csm_web/frontend/src/components/section/MetaEditModal.tsx +++ b/csm_web/frontend/src/components/section/MetaEditModal.tsx @@ -10,16 +10,18 @@ interface MetaEditModalProps { closeModal: () => void; capacity: number; description: string; + waitlistCapacity: number; } export default function MetaEditModal({ closeModal, sectionId, capacity, - description + description, + waitlistCapacity }: MetaEditModalProps): React.ReactElement { // use existing capacity and description as initial values - const [formState, setFormState] = useState({ capacity: capacity, description: description }); + const [formState, setFormState] = useState({ capacity, description, waitlistCapacity }); const [validationText, setValidationText] = useState(""); const sectionUpdateMutation = useSectionUpdateMutation(sectionId); @@ -31,7 +33,7 @@ export default function MetaEditModal({ }); const handleChange = ({ target: { name, value } }: React.ChangeEvent) => { - if (name === "capacity") { + if (name === "capacity" || name === "waitlist-capacity") { setFormState(prevFormState => ({ ...prevFormState, [name]: parseInt(value) })); } else { setFormState(prevFormState => ({ ...prevFormState, [name]: value })); @@ -93,6 +95,20 @@ export default function MetaEditModal({ onChange={handleChange} /> +
{validationText !== "" && (
diff --git a/csm_web/frontend/src/components/section/Section.tsx b/csm_web/frontend/src/components/section/Section.tsx index aff0d204c..88890420f 100644 --- a/csm_web/frontend/src/components/section/Section.tsx +++ b/csm_web/frontend/src/components/section/Section.tsx @@ -6,7 +6,7 @@ import { useSection } from "../../utils/queries/sections"; import { Override, Role, Spacetime } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import MentorSection from "./MentorSection"; -import StudentSection from "./StudentSection"; +import { WaitlistStudentSection, StudentSection } from "./StudentSection"; import scssColors from "../../css/base/colors-export.module.scss"; import "../../css/section.scss"; @@ -22,13 +22,15 @@ export default function Section(): React.ReactElement | null { } return ; } - + console.log(section); switch (section.userRole) { case Role.COORDINATOR: case Role.MENTOR: return ; case Role.STUDENT: return ; + case Role.WAITLIST: + return ; default: return null; } diff --git a/csm_web/frontend/src/components/section/StudentSection.tsx b/csm_web/frontend/src/components/section/StudentSection.tsx index 2bd994988..fa35bcd41 100644 --- a/csm_web/frontend/src/components/section/StudentSection.tsx +++ b/csm_web/frontend/src/components/section/StudentSection.tsx @@ -5,8 +5,10 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { DEFAULT_TIMEZONE } from "../../utils/datetime"; import { useDropUserMutation, + useDropWaitlistMutation, useStudentAttendances, - useStudentSubmitWordOfTheDayMutation + useStudentSubmitWordOfTheDayMutation, + useWaitlistPosition } from "../../utils/queries/sections"; import { AttendancePresence, Mentor, Override, Role, Spacetime } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; @@ -30,7 +32,49 @@ interface StudentSectionType { associatedProfileId: number; } -export default function StudentSection({ +export function WaitlistStudentSection({ + id, + course, + courseTitle, + mentor, + spacetimes, + override, + associatedProfileId +}: StudentSectionType) { + const { data: positionData, isSuccess: positionLoaded } = useWaitlistPosition(id); + + return ( + + + + +
+ + {positionLoaded ?
You are #{positionData.position} on the waitlist
: } +

+ You will be automatically enrolled when a spot opens up. +

+
+ +
+ + } + /> +
+
+ ); +} + +export function StudentSection({ id, course, courseTitle, @@ -75,13 +119,14 @@ interface StudentSectionInfoProps { spacetimes: Spacetime[]; override?: Override; associatedProfileId: number; + isWaitlisted?: boolean; } // eslint-disable-next-line no-unused-vars -function StudentSectionInfo({ mentor, spacetimes, associatedProfileId }: StudentSectionInfoProps) { +function StudentSectionInfo({ mentor, spacetimes, associatedProfileId, isWaitlisted }: StudentSectionInfoProps) { return ( -

My Section

+

{isWaitlisted ? "My Waitlisted Section" : "My Section"}

{mentor && ( @@ -98,7 +143,7 @@ function StudentSectionInfo({ mentor, spacetimes, associatedProfileId }: Student override={override} /> ))} - + {!isWaitlisted && }
); @@ -154,6 +199,52 @@ function DropSection({ profileId }: DropSectionProps) { } } +enum DropWaitlistStage { + INITIAL = "INITIAL", + CONFIRM = "CONFIRM", + DROPPED = "DROPPED" +} + +function DropWaitlist({ profileId }: DropSectionProps) { + const waitlistDropMutation = useDropWaitlistMutation(profileId); + const [stage, setStage] = useState(DropWaitlistStage.INITIAL); + + const performDrop = () => { + waitlistDropMutation.mutate(undefined, { + onSuccess: () => { + setStage(DropWaitlistStage.DROPPED); + } + }); + }; + + switch (stage) { + case DropWaitlistStage.INITIAL: + return ( + +
Leave Waitlist
+ +
+ ); + case DropWaitlistStage.CONFIRM: + return ( + setStage(DropWaitlistStage.INITIAL)}> +
+
Are you sure you want to leave the waitlist?
+

You will lose your position and are not guaranteed a spot if you rejoin.

+ +
+
+ ); + case DropWaitlistStage.DROPPED: + return ; + } +} + interface StudentSectionAttendanceProps { associatedProfileId: number; id: number; diff --git a/csm_web/frontend/src/css/base/_variables.scss b/csm_web/frontend/src/css/base/_variables.scss index 88dc34b55..a4fe41783 100644 --- a/csm_web/frontend/src/css/base/_variables.scss +++ b/csm_web/frontend/src/css/base/_variables.scss @@ -15,6 +15,7 @@ $csm-theme-default: black; $csm-mentor: $csm-green; $csm-coordinator: #6517b3; $csm-student: #ffab2e; +$csm-waitlist: #1c81df; $csm-danger: #ff7272; $csm-danger-darkened: #eb6060; $csm-neutral: #c0c0c0; diff --git a/csm_web/frontend/src/css/base/colors-export.module.scss b/csm_web/frontend/src/css/base/colors-export.module.scss index 61296b2ff..da849c400 100644 --- a/csm_web/frontend/src/css/base/colors-export.module.scss +++ b/csm_web/frontend/src/css/base/colors-export.module.scss @@ -21,6 +21,7 @@ student: $csm-student; mentor: $csm-mentor; coordinator: $csm-coordinator; + waitlist: $csm-waitlist; attendance-present: $csm-attendance-present; attendance-excused: $csm-attendance-excused; diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index 075af89ff..71885cc1f 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -65,6 +65,46 @@ export const useSectionStudents = (id: number): UseQueryResult => { + const queryResult = useQuery( + ["sections", id, "waitlisted-students"], + async () => { + if (isNaN(id)) { + throw new PermissionError("Invalid section id"); + } + + const resp1 = await fetchNormalized(`/waitlist/${id}/`); + if (resp1.ok) { + const students = await resp1.json(); + return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } + + // if (resp1.status === 404) { + // const resp2 = await fetchNormalized(`/sections/${id}/waitlisted`); + // if (resp2.ok) { + // const students = await resp2.json(); + // return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + // } + // handlePermissionsError(resp2.status); + // throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + // } + + handlePermissionsError(resp1.status); + throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + }, + { retry: handleRetry } + ); + + handleError(queryResult); + return queryResult; +}; + /** * Hook to get the attendances for a section. */ @@ -383,6 +423,91 @@ export const useEnrollUserMutation = (sectionId: number): UseMutationResult => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async () => { + const response = await fetchWithMethod(`waitlist/${sectionId}/add`, HTTP_METHODS.PUT); + if (response.ok) { + return; + } else { + throw await response.json(); + } + }, + { + onSuccess: () => { + // invalidate all queries for the section + queryClient.invalidateQueries(["sections", sectionId]); + // invalidate profiles query for the user + queryClient.invalidateQueries(["profiles"]); + } + } + ); + + // handle error in component + return mutationResult; +}; + +/** + * Hook to get the current user's waitlist position for a section. + * + * Returns { position: number } where position is 1-indexed rank. + */ +export const useWaitlistPosition = (sectionId: number): UseQueryResult<{ position: number }, ServerError> => { + const queryResult = useQuery<{ position: number }, Error>( + ["waitlist", sectionId, "position"], + async () => { + const response = await fetchNormalized(`/waitlist/${sectionId}/position`); + if (response.ok) { + return await response.json(); + } else { + handlePermissionsError(response.status); + throw new ServerError(`Failed to fetch waitlist position for section ${sectionId}`); + } + }, + { retry: handleRetry } + ); + + handleError(queryResult); + return queryResult; +}; + +/** + * Hook to drop the current user from a waitlist. + * + * Uses the waitlisted student profile ID (associatedProfileId when role is WAITLIST). + */ +export const useDropWaitlistMutation = (waitlistedStudentId: number) => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async () => { + const response = await fetchWithMethod(`waitlist/${waitlistedStudentId}/drop`, HTTP_METHODS.PATCH); + if (!response.ok) { + throw new ServerError(`Failed to drop from waitlist`); + } + }, + { + onSuccess: () => { + queryClient.invalidateQueries(["sections"]); + queryClient.invalidateQueries(["waitlist"]); + queryClient.invalidateQueries(["profiles"]); + } + } + ); + + return mutationResult; +}; + interface EnrollStudentMutationRequest { emails: Array<{ [email: string]: string }>; actions: { @@ -405,6 +530,18 @@ interface EnrollStudentMutationResponse { }; } +function normalizeEnrollError(response: Response, payload: any) { + if (!payload) { + return { errors: { critical: `Request failed (${response.status}).` } }; + } + + if (payload.detail && !payload.errors && !payload.progress) { + return { errors: { critical: payload.detail } }; + } + + return payload; +} + /** * Enroll a list of students into a given section. * @@ -412,6 +549,7 @@ interface EnrollStudentMutationResponse { * Failure response body contains the JSON response along with the response status. * * Invalidates all queries associated with the section. + * This endpoint is used by BOTH coordinators and mentors. */ export const useEnrollStudentMutation = ( sectionId: number @@ -423,7 +561,53 @@ export const useEnrollStudentMutation = ( if (response.ok) { return; } else { - throw { status: response.status, json: await response.json() }; + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + throw { status: response.status, json: normalizeEnrollError(response, payload) }; + } + }, + { + onSuccess: () => { + // invalidate all queries for the section + queryClient.invalidateQueries(["sections", sectionId]); + } + } + ); + + // handle error in component + return mutationResult; +}; + +/** + * Enroll a list of waitlisted students into a given section. + * + * On success, returns nothing; on failure, returns the response body. + * Failure response body contains the JSON response along with the response status. + * + * Invalidates all queries associated with the section. + * This endpoint is used by coordinators only. + */ +export const useCoordEnrollStudentToWaitlistMutation = ( + sectionId: number +): UseMutationResult => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async (body: EnrollStudentMutationRequest) => { + const response = await fetchWithMethod(`waitlist/${sectionId}/coordadd`, HTTP_METHODS.PUT, body); + if (response.ok) { + return; + } else { + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + throw { status: response.status, json: normalizeEnrollError(response, payload) }; } }, { @@ -470,6 +654,7 @@ export const useSectionCreateMutation = (): UseMutationResult; [Role.MENTOR]: Set; [Role.COORDINATOR]: Set; + [Role.WAITLIST]: Set; } /** @@ -15,7 +16,8 @@ export function emptyRoles(): Roles { return { [Role.STUDENT]: new Set(), [Role.MENTOR]: new Set(), - [Role.COORDINATOR]: new Set() + [Role.COORDINATOR]: new Set(), + [Role.WAITLIST]: new Set() }; } diff --git a/csm_web/frontend/static/frontend/img/waitlist.svg b/csm_web/frontend/static/frontend/img/waitlist.svg new file mode 100644 index 000000000..a718dd853 --- /dev/null +++ b/csm_web/frontend/static/frontend/img/waitlist.svg @@ -0,0 +1,6 @@ + + + hourglass-line + + + diff --git a/csm_web/pytest.ini b/csm_web/pytest.ini index 2ec4d27ba..5cd26643d 100644 --- a/csm_web/pytest.ini +++ b/csm_web/pytest.ini @@ -1,3 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = csm_web.settings python_files = tests.py test_*.py *_tests.py +addopts = --reuse-db -p no:cacheprovider diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 41806e587..d36ee1bef 100644 --- a/csm_web/scheduler/admin.py +++ b/csm_web/scheduler/admin.py @@ -15,6 +15,7 @@ Spacetime, Student, User, + WaitlistedStudent, ) # Helper methods @@ -239,6 +240,16 @@ def has_delete_permission(self, request, obj=None): return request.user.is_superuser +@admin.register(WaitlistedStudent) +class WaitlistedStudentAdmin(BasePermissionModelAdmin): + autocomplete_fields = ( + "user", + "section", + "course", + ) + list_display = ("id", "user", "section", "course", "position", "active") + + @admin.register(Student) class StudentAdmin(BasePermissionModelAdmin): fieldsets = ( @@ -514,6 +525,7 @@ def get_students(self, obj): "admin:scheduler_student_change", ) for student in obj.section.students.all() + if student.active ) return format_html("".join(student_links)) diff --git a/csm_web/scheduler/factories.py b/csm_web/scheduler/factories.py index 36dee9d41..f11604381 100644 --- a/csm_web/scheduler/factories.py +++ b/csm_web/scheduler/factories.py @@ -23,6 +23,7 @@ Spacetime, Student, User, + WaitlistedStudent, day_to_number, week_bounds, ) @@ -114,6 +115,7 @@ def title(self): BUILDINGS = ("Cory", "Soda", "Kresge", "Moffitt") +DEFAULT_WAITLIST_CAP = 3 class SpacetimeFactory(factory.django.DjangoModelFactory): @@ -197,6 +199,7 @@ class Meta: model = Section capacity = factory.LazyFunction(lambda: random.randint(3, 6)) + waitlist_capacity = DEFAULT_WAITLIST_CAP @classmethod def _create(cls, model_class, *args, **kwargs): @@ -255,6 +258,20 @@ def date(self): spacetime = factory.SubFactory(SpacetimeFactory) +class WaitlistedStudentFactory(factory.django.DjangoModelFactory): + class Meta: + model = WaitlistedStudent + + user = factory.SubFactory(UserFactory) + section = factory.SubFactory(SectionFactory) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """Handles uniqueness and assigns position correctly.""" + waitlisted_student, _ = model_class.objects.get_or_create(**kwargs) + return waitlisted_student + + class ResourceFactory(factory.django.DjangoModelFactory): class Meta: model = Resource @@ -456,10 +473,12 @@ def create_demo_account(): user=demo_user, course=Course.objects.get(name=large_course_name) ) - print(""" + print( + """ A demo account has been created with username 'demo_user' and password 'pass' Log in at localhost:8000/admin/ - """) + """ + ) # make demo_user a coord for one more course coord_2_whitelist_course = random.choice( @@ -491,20 +510,24 @@ def create_demo_account(): ) large_course_section.mentor.user = demo_mentor_user - print(""" + print( + """ A demo mentor has been created with username 'demo_mentor' and password 'pass' Log in at localhost:8000/admin/ - """) + """ + ) def confirm_run(): """Display warning message for user to confirm flushing the database.""" - choice = input("""You have requested a flush of the database. + choice = input( + """You have requested a flush of the database. This will DELETE EVERYTHING IN THE DATABASE, and return all tables to an empty state. Are you sure you want to do this? - Type 'yes' to continue, or 'no' to abort: """) + Type 'yes' to continue, or 'no' to abort: """ + ) while choice not in ("yes", "no"): choice = input("Please type 'yes' or 'no' (without the quotes): ") return choice == "yes" @@ -533,6 +556,7 @@ def generate_test_data(preconfirm=False): spacetime_objects = [] section_occurrence_objects = [] student_objects = [] + waitlisted_student_objects = [] print("Creating model instances... ", end="") _create_models_start = time.perf_counter_ns() @@ -575,6 +599,20 @@ def generate_test_data(preconfirm=False): ) student_objects.extend(students) + if len(student_users) >= section.capacity: + waitlisted_users = UserFactory.build_batch( + random.randint(0, section.waitlist_capacity) + ) + user_objects.extend(waitlisted_users) + waitlisted_students = [] + for waitlisted_user in waitlisted_users: + waitlisted_students.append( + WaitlistedStudentFactory.build( + section=section, course=course, user=waitlisted_user + ) + ) + waitlisted_student_objects.extend(waitlisted_students) + # courses with many sections/students for ( course_name, @@ -634,6 +672,7 @@ def generate_test_data(preconfirm=False): random.shuffle(spacetime_objects) random.shuffle(section_occurrence_objects) random.shuffle(student_objects) + random.shuffle(waitlisted_student_objects) print("Saving models to database... ", end="") _save_models_start = time.perf_counter_ns() @@ -645,6 +684,7 @@ def generate_test_data(preconfirm=False): Spacetime.objects.bulk_create(spacetime_objects) SectionOccurrence.objects.bulk_create(section_occurrence_objects) Student.objects.bulk_create(student_objects) + WaitlistedStudent.objects.bulk_create(waitlisted_student_objects) print(f"({(time.perf_counter_ns() - _save_models_start)/1e6:.6f} ms)") diff --git a/csm_web/scheduler/migrations/0035_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py b/csm_web/scheduler/migrations/0035_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py new file mode 100644 index 000000000..1f134f041 --- /dev/null +++ b/csm_web/scheduler/migrations/0035_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 5.1.6 on 2026-02-25 12:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("scheduler", "0034_mentor_family"), + ] + + operations = [ + migrations.AddField( + model_name="course", + name="max_waitlist_enroll", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name="section", + name="waitlist_capacity", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.CreateModel( + name="WaitlistedStudent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="An inactive student is a dropped student.", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "position", + models.PositiveIntegerField( + blank=True, + help_text="Manual position on the waitlist. Lower numbers have higher priority.", + null=True, + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="scheduler.course", + ), + ), + ( + "section", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="waitlist_set", + to="scheduler.section", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["position", "timestamp"], + }, + ), + ] diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 6d37cd56d..4d3ff8aab 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -8,13 +8,15 @@ from django.db import models from django.db.models.fields.related_descriptors import ReverseOneToOneDescriptor from django.dispatch import receiver -from django.utils import functional, timezone +from django.utils import timezone from rest_framework.serializers import ValidationError logger = logging.getLogger(__name__) logger.info = logger.warning +DEFAULT_WAITLIST_CAP = 3 + class DayOfWeekField(models.Field): DAYS = ( @@ -73,6 +75,30 @@ def can_enroll_in_course(self, course, bypass_enrollment_time=False): is_valid_enrollment_time = course.is_open() return is_valid_enrollment_time and not is_associated + def can_waitlist_in_course(self, course, bypass_enrollment_time=False): + """Determine whether this user is allowed to waitlist in the given course.""" + # check restricted first + if course.is_restricted and not self.is_whitelisted_for(course): + return False + + if bypass_enrollment_time: + return True + + if self.priority_enrollment: + now = timezone.now().astimezone(timezone.get_default_timezone()) + return self.priority_enrollment < now < course.enrollment_end + + return course.is_open() + + def can_enroll_in_waitlist(self, course): + """Determine whether this user is allowed to waitlist in the given course.""" + return ( + self.waitlistedstudent_set.filter( + active=True, section__mentor__course=course + ).count() + < course.max_waitlist_enroll + ) + def is_whitelisted_for(self, course: "Course"): """Determine whether this user is whitelisted for the given course.""" return not course.is_restricted or self.whitelist.filter(pk=course.pk).exists() @@ -170,12 +196,12 @@ class Course(ValidatingModel): enrollment_start = models.DateTimeField() enrollment_end = models.DateTimeField() permitted_absences = models.PositiveSmallIntegerField() - # time limit for wotd submission; + # time limit fdocor wotd submission; # section occurrence date + day limit, rounded to EOD word_of_the_day_limit = models.DurationField(null=True, blank=True) - is_restricted = models.BooleanField(default=False) whitelist = models.ManyToManyField("User", blank=True, related_name="whitelist") + max_waitlist_enroll = models.PositiveSmallIntegerField(default=DEFAULT_WAITLIST_CAP) def __str__(self): return self.name @@ -204,6 +230,14 @@ def is_open(self): now = timezone.now().astimezone(timezone.get_default_timezone()) return self.enrollment_start < now < self.enrollment_end + def is_coordinator(self, user): + """ + Returns boolean + - True if is coord + - False if is not coord + """ + return self.coordinator_set.filter(user=user).exists() + class Profile(ValidatingModel): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) @@ -223,6 +257,44 @@ class Meta: abstract = True +class WaitlistedStudent(Profile): + """ + Represents a given "instance" of a waitlisted student. Every section in which a student enrolls + on the waitlist should have a new WaitlistedStudent profile. + """ + + section = models.ForeignKey( + "Section", on_delete=models.CASCADE, related_name="waitlist_set" + ) + active = models.BooleanField( + default=True, help_text="An inactive student is a dropped student." + ) + timestamp = models.DateTimeField(auto_now_add=True) + position = models.PositiveIntegerField( + null=True, + blank=True, + help_text=( + "Manual position on the waitlist. Lower numbers have higher priority." + ), + ) + + class Meta: + ordering = ["position", "timestamp"] + + def save(self, *args, **kwargs): + if self.active and self.position is None: + max_pos = ( + WaitlistedStudent.objects.filter(section=self.section, active=True) + .exclude(pk=self.pk) + .order_by("-position") + .values_list("position", flat=True) + .first() + ) + self.position = (max_pos or 0) + 1 + + super().save(*args, **kwargs) + + class Student(Profile): """ Represents a given "instance" of a student. Every section in which a student enrolls should @@ -317,7 +389,7 @@ class Mentor(Profile): class Coordinator(Profile): """ - This profile is used to allow coordinators to acess the admin page. + This profile is used to allow coordinators to access the admin page. """ def save(self, *args, **kwargs): @@ -335,6 +407,7 @@ class Meta: class Section(ValidatingModel): # course = models.ForeignKey(Course, on_delete=models.CASCADE) capacity = models.PositiveSmallIntegerField() + waitlist_capacity = models.PositiveSmallIntegerField(default=DEFAULT_WAITLIST_CAP) mentor = OneToOneOrNoneField( Mentor, on_delete=models.CASCADE, blank=True, null=True ) @@ -361,11 +434,26 @@ def day_time(self): # def course(self): # return self.mentor.course - @functional.cached_property + @property def current_student_count(self): """Query the number of students currently enrolled in this section.""" return self.students.filter(active=True).count() + @property + def current_waitlist_count(self): + """Query the number of waitlisted students currently enrolled in this section.""" + return WaitlistedStudent.objects.filter(active=True, section=self).count() + + @property + def is_waitlist_full(self): + """Returns whether waitlist is open""" + return self.current_waitlist_count >= self.waitlist_capacity + + @property + def is_section_full(self): + """Returns whether section capacity is open""" + return self.current_student_count >= self.capacity + def delete(self, *args, **kwargs): if self.current_student_count and not kwargs.get("force"): raise models.ProtectedError( diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index a945563da..03f2cd508 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -20,6 +20,7 @@ Spacetime, Student, User, + WaitlistedStudent, Worksheet, day_to_number, ) @@ -29,13 +30,19 @@ class Role(Enum): COORDINATOR = "COORDINATOR" STUDENT = "STUDENT" MENTOR = "MENTOR" + WAITLIST = "WAITLIST" def get_profile_role(profile): """Return role (enum) depending on the profile type""" - for role, klass in zip(Role, (Coordinator, Student, Mentor)): - if isinstance(profile, klass): - return role.value + if isinstance(profile, Coordinator): + return Role.COORDINATOR.value + elif isinstance(profile, Student): + return Role.STUDENT.value + elif isinstance(profile, Mentor): + return Role.MENTOR.value + elif isinstance(profile, WaitlistedStudent): + return Role.WAITLIST.value return None @@ -269,6 +276,14 @@ class Meta: fields = ("id", "name", "email", "attendances", "section") +class WaitlistedStudentSerializer(serializers.ModelSerializer): + email = serializers.EmailField(source="user.email") + + class Meta: + model = WaitlistedStudent + fields = ("id", "name", "email", "section", "position") + + class CoordStudentSerializer(serializers.ModelSerializer): """ Serializer for the coordinator view of students @@ -340,6 +355,7 @@ class SectionSerializer(serializers.ModelSerializer): user_role = serializers.SerializerMethodField() associated_profile_id = serializers.SerializerMethodField() course_restricted = serializers.BooleanField(source="mentor.course.is_restricted") + num_students_waitlisted = serializers.SerializerMethodField() def get_num_students_enrolled(self, obj): """Retrieve the number of students enrolled in the section""" @@ -349,6 +365,14 @@ def get_num_students_enrolled(self, obj): else obj.current_student_count ) + def get_num_students_waitlisted(self, obj): + """Retrieve the number of students waitlisted for the section""" + return ( + obj.num_waitlisted_annotation + if hasattr(obj, "num_waitlisted_annotation") + else obj.current_waitlist_count + ) + def user_associated_profile(self, obj): """Retrieve the user profile associated with the section""" user = self.context.get("request") and self.context.get("request").user @@ -357,6 +381,9 @@ def user_associated_profile(self, obj): try: return obj.students.get(user=user) except Student.DoesNotExist: + waitlisted_student = obj.waitlist_set.filter(user=user).first() + if waitlisted_student: + return waitlisted_student coordinator = obj.mentor.course.coordinator_set.filter(user=user).first() if coordinator: return coordinator @@ -391,6 +418,8 @@ class Meta: "user_role", "course_title", "course_restricted", + "waitlist_capacity", + "num_students_waitlisted", ) diff --git a/csm_web/scheduler/tests/models/test_waitlisted_student.py b/csm_web/scheduler/tests/models/test_waitlisted_student.py new file mode 100644 index 000000000..f11188635 --- /dev/null +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -0,0 +1,880 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone +from scheduler.factories import ( + CoordinatorFactory, + CourseFactory, + MentorFactory, + SectionFactory, + UserFactory, +) +from scheduler.models import Student, WaitlistedStudent + + +@pytest.fixture(name="setup_waitlist") +def fixture_setup_waitlist(db): # pylint: disable=unused-argument + """ + Set up a mentor user, student user, course, and section for waitlist testing + """ + mentor_user, student_user = UserFactory.create_batch(2) + course = CourseFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + return mentor_user, student_user, course, section + + +@pytest.mark.django_db +def test_create_waitlisted_student(setup_waitlist): + """ + Given we create a waitlisted student object, + When we call create + It correctly creates a waitlisted student object for the section and user. + """ + mentor_user, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + assert waitlisted_student.user == waitlisted_student_user + assert WaitlistedStudent.objects.count() == 1 + assert ( + WaitlistedStudent.objects.get(user=waitlisted_student_user).user + == waitlisted_student_user + ) + + assert waitlisted_student.course == course + assert waitlisted_student.section == section + assert waitlisted_student.section.mentor.user == mentor_user + assert waitlisted_student.section.mentor.course == course + assert waitlisted_student.section.mentor.section == section + + assert ( + waitlisted_student.section.students.count() == 0 + ) # no students were added to the section + assert waitlisted_student.section.waitlist_set.count() == 1 + + +@pytest.mark.django_db +def test_user_cannot_enroll_in_course(setup_waitlist, client): + """ + Given a student or mentor in the course, + When they attempt to enroll or waitlist for a section, + Then they are denied with an appropriate error. + """ + mentor_user, user, _, section = setup_waitlist + + client.force_login(mentor_user) + response = client.put(f"/api/waitlist/{section.pk}/add/") + + assert response.status_code == 403 + assert response.data["detail"] == "Mentors cannot waitlist in a course they mentor." + assert WaitlistedStudent.objects.count() == 0 + + client.force_login(user) + response = client.put(f"/api/waitlist/{section.pk}/add/") # should auto enroll user + assert WaitlistedStudent.objects.count() == 0 + assert Student.objects.count() == 1 + + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/" + ) # fails because user is in section + assert response.status_code == 403 + assert response.data["detail"] == "User is already enrolled in this section." + assert WaitlistedStudent.objects.count() == 0 + + +@pytest.mark.django_db +def test_user_can_waitlist_only_once(setup_waitlist, client): + """ + Given a user already on the waitlist for a section, + When they attempt to join the same waitlist again, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, _, section = setup_waitlist + + while not section.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.put(f"/api/waitlist/{section.pk}/add/") + + client.force_login(waitlisted_student_user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.count() == 1 + + client.force_login(waitlisted_student_user) + response = client.put(f"/api/waitlist/{section.pk}/add/") + + assert response.status_code == 403 + assert response.data["detail"] == "User is already waitlisted in this section." + assert WaitlistedStudent.objects.count() == 1 + + +@pytest.mark.django_db +def test_waitlist_is_full(setup_waitlist, client): + """ + Given a section where the waitlist is full, + When a user attempts to join the waitlist, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, _, section = setup_waitlist + + while not section.is_waitlist_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + client.force_login(waitlisted_student_user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + assert response.status_code == 403 + assert response.data["detail"] == "There is no space available in this waitlist." + assert WaitlistedStudent.objects.count() == 3 + + +@pytest.mark.django_db +def test_user_exceeds_max_waitlists_for_course(setup_waitlist, client): + """ + Given a user who has waitlisted in the maximum number of waitlists allowed for the course, + When they attempt to join another waitlist for the course, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + # Create and fill sections, waitlist until max waitlists achieved + for _ in range(course.max_waitlist_enroll): + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section_test = SectionFactory.create( + mentor=mentor, capacity=1, waitlist_capacity=1 + ) + + while not section_test.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.put( + f"/api/waitlist/{section_test.pk}/add/", + content_type="application/json", + ) + + client.force_login(waitlisted_student_user) + response = client.put( + f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" + ) + # Verify max waitlists achieved + assert WaitlistedStudent.objects.count() == course.max_waitlist_enroll + + while not section.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + # Verify errors when attempting to add another waitlist + client.force_login(waitlisted_student_user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + assert response.status_code == 403 + assert WaitlistedStudent.objects.count() == course.max_waitlist_enroll + + # Check if user is dropped from all waitlists for a course when adding to a course section + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section_test = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=1) + + client.force_login(waitlisted_student_user) + response = client.put( + f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" + ) + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 0 + ) + + +@pytest.mark.django_db +def test_user_enrolled_from_waitlist_and_dropped_from_others(setup_waitlist, client): + """ + Given a user waitlisted in two sections for a course, + When a student in one of the sections drops, + Then the user is enrolled into that section + and dropped from their other waitlists for the course. + """ + _, waitlisted_student_user, course, section1 = setup_waitlist + + # Set up a second section in the same course + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section2 = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + # Add user to both waitlists + for _ in range(section1.capacity): + test_user = UserFactory.create_batch(1)[0] + client.force_login(test_user) + _ = client.put(f"/api/waitlist/{section1.pk}/add/") + + for _ in range(section2.capacity): + test_user = UserFactory.create_batch(1)[0] + client.force_login(test_user) + _ = client.put(f"/api/waitlist/{section2.pk}/add/") + + client.force_login(waitlisted_student_user) + _ = client.put(f"/api/waitlist/{section1.pk}/add/", content_type="application/json") + _ = client.put(f"/api/waitlist/{section2.pk}/add/", content_type="application/json") + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 2 + ) + + # Enroll from waitlist + test_student = Student.objects.filter(user=test_user, active=True).first() + client.force_login(test_user) + _ = client.patch(f"/api/students/{test_student.pk}/drop/") + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 0 + ) + + +@pytest.mark.django_db +def test_enrolled_student_swaps_to_open_section(client): + """ + Given a student enrolled in section A, + When they try to add to section B which has room, + Then they are swapped: dropped from A and enrolled in B. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=2, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=2, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 200 + # User should now be in section2, not section1 + active_student = Student.objects.filter(user=enrolled_user, active=True).first() + assert active_student is not None + assert active_student.section == section2 + assert ( + Student.objects.filter( + user=enrolled_user, active=True, section=section1 + ).count() + == 0 + ) + # No waitlist entries should exist + assert ( + WaitlistedStudent.objects.filter(user=enrolled_user, active=True).count() == 0 + ) + + +@pytest.mark.django_db +def test_enrolled_student_can_waitlist_other_section(client): + """ + Given a student enrolled in a section, + When they waitlist for another section in the same course, + Then they are allowed to waitlist. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section2) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.filter( + user=enrolled_user, section=section2, active=True + ).exists() + + +@pytest.mark.django_db +def test_waitlist_promotion_swaps_section(client): + """ + Given a student is enrolled in section A and waitlisted in section B, + When a spot opens in section B, + Then the student is swapped into section B and removed from section A. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + other_user = UserFactory.create() + other_student = Student.objects.create( + user=other_user, course=course, section=section2 + ) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + assert response.status_code == 201 + + client.force_login(other_user) + _ = client.patch(f"/api/students/{other_student.pk}/drop/") + + active_student = Student.objects.filter(user=enrolled_user, active=True).first() + assert active_student is not None + assert active_student.section == section2 + assert ( + Student.objects.filter( + user=enrolled_user, active=True, section=section1 + ).count() + == 0 + ) + assert ( + WaitlistedStudent.objects.filter(user=enrolled_user, active=True).count() == 0 + ) + + +@pytest.mark.django_db +def test_waitlist_cascades_to_previous_section(client): + """ + Given a student swaps from section A to section B via waitlist, + When they leave section A, + Then section A's waitlist is promoted as well. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + waitlisted_user = UserFactory.create() + client.force_login(waitlisted_user) + _ = client.put( + f"/api/waitlist/{section1.pk}/add/", data={}, content_type="application/json" + ) + + other_user = UserFactory.create() + other_student = Student.objects.create( + user=other_user, course=course, section=section2 + ) + + client.force_login(enrolled_user) + _ = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + client.force_login(other_user) + _ = client.patch(f"/api/students/{other_student.pk}/drop/") + + promoted_student = Student.objects.filter( + user=waitlisted_user, active=True, section=section1 + ).first() + assert promoted_student is not None + + +@pytest.mark.django_db +def test_user_drops_themselves_successfully(setup_waitlist, client): + """ + Given a user on the waitlist for a section, + When they attempt to drop themselves, + Then the waitlisted_student's active field is set to False, + And the endpoint returns a 204 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + client.force_login(waitlisted_student_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 204 + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is False + + +@pytest.mark.django_db +def test_coordinator_drops_student_successfully(setup_waitlist, client): + """ + Given a coordinator for the course associated with a section, + When they attempt to drop another user from the waitlist, + Then the waitlisted_student's active field is set to False, + And the endpoint returns a 204 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + coordinator_user = UserFactory.create_batch(1)[0] + _ = CoordinatorFactory.create(user=coordinator_user, course=course) + + client.force_login(coordinator_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 204 + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is False + + +@pytest.mark.django_db +def test_user_drops_without_permission(setup_waitlist, client): + """ + Given a user who is not a coordinator, + When they attempt to drop themselves from another section waitlist, + Then a PermissionDenied exception is raised, + And the endpoint returns a 403 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + unauthorized_user = UserFactory.create_batch(1)[0] + client.force_login(unauthorized_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 403 + assert ( + response.data["detail"] + == "You do not have permission to drop this student from the waitlist" + ) + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is True + + +@pytest.mark.django_db +def test_user_drops_from_nonexistent_waitlisted_student(setup_waitlist, client): + """ + Given a user on the waitlist for a non-existent section, + When they attempt to drop themselves, + Then the endpoint returns a 404 status code. + """ + _, _, course, _ = setup_waitlist + coordinator_user = UserFactory.create_batch(1)[0] + _ = CoordinatorFactory.create(user=coordinator_user, course=course) + + client.force_login(coordinator_user) + response = client.patch("/api/waitlist/999/drop/") + + assert response.data["detail"] == "Student is not on the waitlist for this section" + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_positions_update_properly(): + """ + Given a waitlist with existing students, + When new students are added and dropped, + Positions are auto-assigned as max+1 and gaps are left after drops + (rank is computed at query time, not by compacting). + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=5, waitlist_capacity=5) + + # Create 3 waitlisted students (positions auto-assigned as 1, 2, 3) + user1 = UserFactory.create() + ws1 = WaitlistedStudent.objects.create(user=user1, course=course, section=section) + user2 = UserFactory.create() + ws2 = WaitlistedStudent.objects.create(user=user2, course=course, section=section) + user3 = UserFactory.create() + ws3 = WaitlistedStudent.objects.create(user=user3, course=course, section=section) + + ws1.refresh_from_db() + ws2.refresh_from_db() + ws3.refresh_from_db() + + assert ws1.position == 1 + assert ws2.position == 2 + assert ws3.position == 3 + + # Drop ws2; positions are NOT compacted — ws1 stays 1, ws3 stays 3 + ws2.active = False + ws2.save() + + ws1.refresh_from_db() + ws3.refresh_from_db() + + assert ws1.position == 1 + assert ws3.position == 3 + + # Add a new student; should get max(1,3) + 1 = 4 + user4 = UserFactory.create() + ws4 = WaitlistedStudent.objects.create(user=user4, course=course, section=section) + ws4.refresh_from_db() + + assert ws4.position == 4 + + +@pytest.mark.django_db +def test_waitlist_respects_priority_enrollment(client): + """ + Given a course that is not open for enrollment yet, + When a user has priority enrollment in the window, + Then they can still waitlist. + """ + now = timezone.now() + course = CourseFactory.create( + enrollment_start=now + timedelta(days=7), + enrollment_end=now + timedelta(days=14), + section_start=(now + timedelta(days=8)).date(), + valid_until=(now + timedelta(days=30)).date(), + ) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + # Fill section to force waitlist + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + user.priority_enrollment = now - timedelta(days=1) + user.save() + + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.filter( + user=user, section=section, active=True + ).exists() + + +@pytest.mark.django_db +def test_waitlist_denied_outside_enrollment_window(client): + """ + Given a course that is not open for enrollment and no priority enrollment, + When a user attempts to waitlist, + Then they are denied. + """ + now = timezone.now() + course = CourseFactory.create( + enrollment_start=now + timedelta(days=7), + enrollment_end=now + timedelta(days=14), + section_start=(now + timedelta(days=8)).date(), + valid_until=(now + timedelta(days=30)).date(), + ) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 403 + assert response.data["detail"] == "User cannot waitlist in this course." + + +@pytest.mark.django_db +def test_waitlist_restricted_course_requires_whitelist(client): + """ + Given a restricted course without whitelist access, + When a user attempts to waitlist, + Then they are denied. + """ + course = CourseFactory.create(is_restricted=True) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 403 + assert response.data["detail"] == "User cannot waitlist in this course." + + +@pytest.mark.django_db +def test_coord_add_requires_coordinator(client): + """ + Given a non-coordinator user, + When they attempt to add a student to a waitlist via coord endpoint, + Then they are denied. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor) + + non_coord_user = UserFactory.create() + client.force_login(non_coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "test@berkeley.edu"}]}, + content_type="application/json", + ) + + assert response.status_code == 403 + assert response.data["detail"] == "You must be a coord to perform this action." + + +@pytest.mark.django_db +def test_waitlist_count_endpoint(client): + """ + Given a section with waitlisted students, + When the count endpoint is requested, + Then it returns the waitlist count. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor) + + user = UserFactory.create() + WaitlistedStudent.objects.create(user=user, course=course, section=section) + + client.force_login(user) + response = client.get( + f"/api/waitlist/{section.pk}/count_waitlist/", + HTTP_ACCEPT="application/json", + ) + + assert response.status_code == 200 + assert int(response.content.decode("utf-8")) == 1 + + +@pytest.mark.django_db +def test_position_endpoint_returns_rank(client): + """ + Given a waitlist with gaps in position numbers, + When a user requests their position, + Then the endpoint returns the 1-indexed rank (count of active students + with lower positions + 1), not the raw position value. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=5) + + # Fill the section so users are forced onto the waitlist + filler_user = UserFactory.create() + Student.objects.create(user=filler_user, course=course, section=section) + + # Create 3 waitlisted students (positions 1, 2, 3) + user1 = UserFactory.create() + WaitlistedStudent.objects.create(user=user1, course=course, section=section) + user2 = UserFactory.create() + ws2 = WaitlistedStudent.objects.create(user=user2, course=course, section=section) + user3 = UserFactory.create() + WaitlistedStudent.objects.create(user=user3, course=course, section=section) + + # Verify initial ranks + client.force_login(user1) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 1 + + client.force_login(user3) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 3 + + # Drop user2 — creates a gap (positions 1, _, 3) + ws2.active = False + ws2.save() + + # user3's rank should now be 2 (only user1 has a lower position) + client.force_login(user3) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 2 + + # A user not on the waitlist gets 404 + non_waitlisted = UserFactory.create() + client.force_login(non_waitlisted) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_coord_add_success_and_mixed_results(client): + """ + Given a coordinator adding students by email, + When some emails succeed and some are already enrolled, + Then the response reports per-email status and returns 422 on errors. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=2, waitlist_capacity=3) + + coord_user = UserFactory.create() + CoordinatorFactory.create(user=coord_user, course=course) + + # Successful add — section has room, so user gets enrolled + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "new_student@berkeley.edu"}]}, + content_type="application/json", + ) + assert response.status_code == 200 + + # Add the same email again — should conflict + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "new_student@berkeley.edu"}]}, + content_type="application/json", + ) + assert response.status_code == 422 + assert response.data["progress"][0]["status"] == "CONFLICT" + + # Mixed batch — one new, one duplicate + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={ + "emails": [ + {"email": "another_student@berkeley.edu"}, + {"email": "new_student@berkeley.edu"}, + ] + }, + content_type="application/json", + ) + assert response.status_code == 422 + statuses = [r["status"] for r in response.data["progress"]] + assert statuses == ["OK", "CONFLICT"] + + # Empty emails returns 422 + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": []}, + content_type="application/json", + ) + assert response.status_code == 422 + + +@pytest.mark.django_db +def test_view_waitlist_permissions(client): + """ + Given a section with a waitlist, + When different users request the waitlist view, + Then only the mentor and coordinators are allowed to see it. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + # Fill section so next user gets waitlisted + filler_user = UserFactory.create() + Student.objects.create(user=filler_user, course=course, section=section) + + waitlisted_user = UserFactory.create() + WaitlistedStudent.objects.create( + user=waitlisted_user, course=course, section=section + ) + + # Mentor can view + client.force_login(mentor_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 200 + assert len(response.data) == 1 + + # Coordinator can view + coord_user = UserFactory.create() + CoordinatorFactory.create(user=coord_user, course=course) + client.force_login(coord_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 200 + assert len(response.data) == 1 + + # Random user cannot view + random_user = UserFactory.create() + client.force_login(random_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_drop_preserves_position(): + """ + Given a waitlisted student with an assigned position, + When they are dropped, + Then the position value is preserved (not cleared to None). + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=5, waitlist_capacity=5) + + user = UserFactory.create() + ws = WaitlistedStudent.objects.create(user=user, course=course, section=section) + ws.refresh_from_db() + assert ws.position == 1 + + ws.active = False + ws.save() + ws.refresh_from_db() + + assert ws.active is False + assert ws.position == 1 # position preserved, not cleared diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 509ac228a..ca711aee7 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -23,6 +23,18 @@ path("matcher//mentors/", views.matcher.mentors), path("matcher//configure/", views.matcher.configure), path("matcher//create/", views.matcher.create), + path("waitlist//add/", views.waitlistedStudent.add), + path("waitlist//drop/", views.waitlistedStudent.drop), + path("waitlist//", views.waitlistedStudent.view), + path("waitlist//coordadd/", views.waitlistedStudent.add_by_coord), + path( + "waitlist//count_waitlist/", + views.waitlistedStudent.count_waitlist, + ), + path( + "waitlist//position/", + views.waitlistedStudent.position, + ), path("coord//students/", views.coord.view_students), path("coord//mentors/", views.coord.view_mentors), path("coord//section/", views.coord.delete_section), diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py index 6b499bbd5..f6c9881d9 100644 --- a/csm_web/scheduler/views/__init__.py +++ b/csm_web/scheduler/views/__init__.py @@ -1,4 +1,4 @@ -from . import matcher +from . import matcher, waitlistedStudent from .coord import delete_section, view_mentors, view_students from .course import CourseViewSet from .export import export_data diff --git a/csm_web/scheduler/views/profile.py b/csm_web/scheduler/views/profile.py index 383fa7238..fc34c5cc9 100644 --- a/csm_web/scheduler/views/profile.py +++ b/csm_web/scheduler/views/profile.py @@ -1,9 +1,8 @@ -from .utils import viewset_with - from django.db.models.query import EmptyQuerySet from rest_framework.response import Response from ..serializers import ProfileSerializer +from .utils import viewset_with class ProfileViewSet(*viewset_with("list")): @@ -11,10 +10,15 @@ class ProfileViewSet(*viewset_with("list")): queryset = EmptyQuerySet def list(self, request): + """ + Lists out the profiles created by students, waitlisted students, + mentors, and coords. + """ return Response( ProfileSerializer( [ *request.user.student_set.filter(active=True, banned=False), + *request.user.waitlistedstudent_set.filter(active=True), *request.user.mentor_set.all(), # .exclude(section=None), *request.user.coordinator_set.all(), ], diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 47c4ca060..1bc875632 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -25,6 +25,7 @@ StudentSerializer, ) +from ..models import WaitlistedStudent from .utils import ( get_object_or_error, log_str, @@ -34,6 +35,220 @@ ) +def add_student(section, user): # make this endpoint for only adding as a student + """ + Helper Function: + + Adds a student to a section (initiated by an API call) + """ + # Checks that user is able to enroll in the course + if not user.can_enroll_in_course(section.mentor.course): + logger.warning( + " User %s was unable to enroll in Section %s" + " because they are already involved in this course", + log_str(user), + log_str(section), + ) + raise PermissionDenied( + "You are already either mentoring for this course or enrolled in a" + " section, or the course is closed for enrollment", + status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + # Check that the section is not full, wouldn't we want that to allow that + if section.is_section_full: + logger.warning( + " User %s was unable to enroll in Section %s" + " because it was full", + log_str(user), + log_str(section), + ) + raise PermissionDenied( + "There is no space available in this section", status.HTTP_423_LOCKED + ) + + # Check that the student exists only once + student_queryset = user.student_set.filter( + active=False, course=section.mentor.course + ) + if student_queryset.count() > 1: + logger.error( + " Multiple student objects exist in the" + " database (Students %s)!", + student_queryset.all(), + ) + raise PermissionDenied( + "An internal error occurred; email mentors@berkeley.edu" + " immediately. (Duplicate students exist in the database (Students" + f" {student_queryset.all()}))", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + if student_queryset.count() == 1: + student = student_queryset.get() + old_section = student.section + student.section = section + student.active = True + # generate new attendance objects for this student + # in all section occurrences past this date + now = timezone.now().astimezone(timezone.get_default_timezone()) + future_section_occurrences = section.sectionoccurrence_set.filter( + Q(date__gte=now.date()) + ) + for section_occurrence in future_section_occurrences: + Attendance( + student=student, sectionOccurrence=section_occurrence, presence="" + ).save() + logger.info( + " Created %s new attendances for user %s in Section %s", + len(future_section_occurrences), + log_str(student.user), + log_str(section), + ) + logger.info( + " User %s swapping into Section %s from Section %s", + log_str(student.user), + log_str(section), + log_str(old_section), + ) + else: + student = Student.objects.create( + user=user, section=section, course=section.mentor.course + ) + + student.save() + + logger.info( + " User %s enrolled in Section %s", + log_str(student.user), + log_str(section), + ) + + # Removes all waitlists the student that added was a part of + waitlist_set = WaitlistedStudent.objects.filter( + user=user, active=True, course=student.course + ) + + for waitlist in waitlist_set: + waitlist.active = False + # waitlist.delete() + waitlist.save() + + logger.info( + " User %s removed from all Waitlists for Course %s", + log_str(user), + log_str(student.course), + ) + + return Response({"id": student.id}, status=status.HTTP_201_CREATED) + + +def swap_into_section(section, user): + """ + Helper Function: + + Swaps a user into a new section by dropping their current + section enrollment first if needed. Handles attendance cleanup. + + Returns the old section ID if the user was swapped, or None if + the user was not previously enrolled. + """ + active_student = user.student_set.filter( + active=True, course=section.mentor.course + ).first() + + old_section_id = None + if active_student is not None: + if active_student.section == section: + raise PermissionDenied("User is already enrolled in this section") + + # drop from current section + old_section = active_student.section + old_section_id = old_section.id + active_student.active = False + active_student.save() + + try: + add_student(section, user) + except PermissionDenied: + if active_student is not None: + active_student.active = True + active_student.save() + raise + + if active_student is not None: + now = timezone.now().astimezone(timezone.get_default_timezone()) + active_student.attendance_set.filter( + Q( + sectionOccurrence__date__gte=now.date(), + sectionOccurrence__section=old_section, + ) + ).delete() + + return old_section_id + + +def add_from_waitlist(pk): + """ + Helper function for adding from waitlist. Called by drop user api + + Checks to see if it is possible to add a student to a section off the waitlist. + Will remove added student from all other waitlists as well + - Will only add ONE student + - Waitlist student is deactivated + - Changes nothing if fails to add class + + """ + # Finds section and waitlist student, searches for position + # (manually inserted student) then timestamp + cascade_section_id = None + response = None + with transaction.atomic(): + section = Section.objects.select_for_update().get(pk=pk) + waitlisted_students = list( + WaitlistedStudent.objects.select_for_update() + .filter(active=True, section=section) + .order_by("position", "timestamp") + ) + + # Check if there are waitlisted students + if not waitlisted_students: + logger.info( + " No waitlist users for section %s", + log_str(section), + ) + response = Response(status=status.HTTP_204_NO_CONTENT) + return response + + for waitlisted_student in waitlisted_students: + try: + cascade_section_id = swap_into_section( + waitlisted_student.section, waitlisted_student.user + ) + except PermissionDenied: + continue + + logger.info( + " User %s removed from all Waitlists for Course %s", + log_str(waitlisted_student.user), + log_str(waitlisted_student.course), + ) + response = Response(status=status.HTTP_201_CREATED) + break + + if response is None: + logger.info( + " No eligible waitlist users for section %s", + log_str(section), + ) + response = Response(status=status.HTTP_204_NO_CONTENT) + + if cascade_section_id is not None: + add_from_waitlist(pk=cascade_section_id) + + return response + + class SectionViewSet(*viewset_with("retrieve", "partial_update", "create")): serializer_class = SectionSerializer @@ -62,6 +277,7 @@ def get_queryset(self): Q(mentor__user=self.request.user) | Q(students__user=self.request.user) | Q(mentor__course__coordinator__user=self.request.user) + | Q(waitlist_set__user=self.request.user) ) .distinct() ) @@ -178,6 +394,7 @@ def partial_update(self, request, pk=None): data={ "capacity": request.data.get("capacity"), "description": request.data.get("description"), + "waitlist_capacity": request.data.get("waitlist_capacity"), }, partial=True, ) @@ -599,90 +816,28 @@ class RestrictedAction: ) section.save() + # expand waitlist capacity return Response(status=status.HTTP_200_OK) def _student_add(self, request, section): """ - Adds a student to a section (initiated by a student) + Adds a student to a section (initiated by a student). + If the student is already enrolled in another section for the same + course, swaps them into this section instead. """ - if not request.user.can_enroll_in_course(section.mentor.course): - logger.warning( - " User %s was unable to enroll in Section %s" - " because they are already involved in this course", - log_str(request.user), - log_str(section), - ) - raise PermissionDenied( - "You are already either mentoring for this course or enrolled in a" - " section, or the course is closed for enrollment", - status.HTTP_422_UNPROCESSABLE_ENTITY, - ) - if section.current_student_count >= section.capacity: - logger.warning( - " User %s was unable to enroll in Section %s" - " because it was full", - log_str(request.user), - log_str(section), - ) - raise PermissionDenied( - "There is no space available in this section", status.HTTP_423_LOCKED - ) + course = section.mentor.course + user = request.user - student_queryset = request.user.student_set.filter( - active=False, course=section.mentor.course - ) - if student_queryset.count() > 1: - logger.error( - " Multiple student objects exist in the" - " database (Students %s)!", - student_queryset.all(), - ) - return PermissionDenied( - "An internal error occurred; email mentors@berkeley.edu" - " immediately. (Duplicate students exist in the database (Students" - f" {student_queryset.all()}))", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - if student_queryset.count() == 1: - student = student_queryset.get() - old_section = student.section - student.section = section - student.active = True - # generate new attendance objects for this student - # in all section occurrences past this date - now = timezone.now().astimezone(timezone.get_default_timezone()) - future_section_occurrences = section.sectionoccurrence_set.filter( - Q(date__gte=now.date()) - ) - for section_occurrence in future_section_occurrences: - Attendance( - student=student, sectionOccurrence=section_occurrence, presence="" - ).save() - logger.info( - " Created %s new attendances for user %s in Section %s", - len(future_section_occurrences), - log_str(student.user), - log_str(section), - ) - student.save() - logger.info( - " User %s swapped into Section %s from Section %s", - log_str(student.user), - log_str(section), - log_str(old_section), - ) - return Response(status=status.HTTP_204_NO_CONTENT) + if user.student_set.filter(active=True, section=section).exists(): + raise PermissionDenied("You are already enrolled in this section.") - # student_queryset.count() == 0 - student = Student.objects.create( - user=request.user, section=section, course=section.mentor.course - ) - logger.info( - " User %s enrolled in Section %s", - log_str(student.user), - log_str(section), - ) - return Response({"id": student.id}, status=status.HTTP_201_CREATED) + if user.student_set.filter(active=True, course=course).exists(): + old_section_id = swap_into_section(section, user) + if old_section_id is not None: + add_from_waitlist(pk=old_section_id) + return Response(status=status.HTTP_200_OK) + + return add_student(section, user) @action(detail=True, methods=["get", "put"]) def wotd(self, request, pk=None): diff --git a/csm_web/scheduler/views/student.py b/csm_web/scheduler/views/student.py index fc3d29c8c..bffbba906 100644 --- a/csm_web/scheduler/views/student.py +++ b/csm_web/scheduler/views/student.py @@ -1,16 +1,15 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django.utils import timezone -from scheduler.models import Attendance, SectionOccurrence from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -import datetime -from .utils import log_str, logger, get_object_or_error from ..models import Student from ..serializers import AttendanceSerializer, StudentSerializer +from .section import add_from_waitlist +from .utils import get_object_or_error, log_str, logger class StudentViewSet(viewsets.GenericViewSet): @@ -28,6 +27,13 @@ def get_queryset(self): @action(detail=True, methods=["patch"]) def drop(self, request, pk=None): + """ + PATCH: /api/students//drop + + Drops student from class + - Turns inactive + - Attempts to add from waitlist + """ student = get_object_or_error(self.get_queryset(), pk=pk) is_coordinator = student.course.coordinator_set.filter( user=request.user @@ -43,7 +49,10 @@ def drop(self, request, pk=None): student.course.whitelist.remove(student.user) student.save() logger.info( - f" User {log_str(request.user)} dropped Section {log_str(student.section)} for Student user {log_str(student.user)}" + " User %s dropped Section %sfor Student user %s", + request.user, + student.section, + student.user, ) # filter attendances and delete future attendances now = timezone.now().astimezone(timezone.get_default_timezone()) @@ -54,12 +63,22 @@ def drop(self, request, pk=None): ) ).delete() logger.info( - f" Deleted {num_deleted} attendances for user {log_str(student.user)} in Section {log_str(student.section)} after {now.date()}" + " Deleted %s attendances for user %s in Section %s after %s", + num_deleted, + log_str(student.user), + log_str(student.section), + now.date(), ) + add_from_waitlist(pk=student.section.id) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["get", "put"]) def attendances(self, request, pk=None): + """ + GET or PUT: /api/students//attendances + + Endpoint to get or edit a student's attendance + """ student = get_object_or_error(self.get_queryset(), pk=pk) if request.method == "GET": return Response( @@ -80,19 +99,25 @@ def attendances(self, request, pk=None): }, ) except ObjectDoesNotExist: - logger.error( - f" Could not record attendance for User {log_str(request.user)}, used non-existent attendance id {request.data['id']}" + logger.info( + " Could not record attendance for user" + "%s used non-existent attendance id %s", + log_str(request.user), + request.data["id"], ) return Response(status=status.HTTP_400_BAD_REQUEST) if serializer.is_valid(): attendance = serializer.save() logger.info( - f" Attendance {log_str(attendance)} recorded for User {log_str(request.user)}" + " Attendance %s recorded for user %s", + log_str(attendance), + log_str(request.user), ) return Response(status=status.HTTP_204_NO_CONTENT) - logger.error( - f" Could not record attendance for User {log_str(request.user)}, errors: {serializer.errors}" + logger.info( + " Could not record attendance for user %s errors: %s", + log_str(request.user), + serializer.errors, ) return Response(serializer.errors, status=status.HTTP_422_UNPROCESSABLE_ENTITY) - diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py new file mode 100644 index 000000000..ec05f8699 --- /dev/null +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -0,0 +1,291 @@ +from django.db import transaction +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.response import Response +from scheduler.serializers import WaitlistedStudentSerializer +from scheduler.views.utils import get_object_or_error + +from ..models import Section, User, WaitlistedStudent +from .section import add_from_waitlist, add_student, swap_into_section +from .utils import logger + + +@api_view(["GET"]) +def view(request, pk=None): + """ + Endpoint: /api/waitlist/ + pk = section id + + GET: View all students on the waitlist for a section + """ + section = get_object_or_error(Section.objects, pk=pk) + if section.mentor is None: + raise NotFound("This section has no mentor assigned.") + is_mentor = request.user == section.mentor.user + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() + ) + if not is_mentor and not is_coord: + raise PermissionDenied("You do not have permission to view this waitlist") + + waitlist_queryset = WaitlistedStudent.objects.filter(active=True, section=section) + return Response(WaitlistedStudentSerializer(waitlist_queryset, many=True).data) + + +@api_view(["PUT"]) +def add(request, pk=None): + """ + Endpoint: /api/waitlist//add + pk= section id + + PUT: Add a new waitlist student to section. Pass in section id. Called by user + who wants to be waitlisted. NOT by coordinator on behalf of student. + - if user cannot enroll in section, deny permission + - if user is already on waitlist for this section, deny + - if waitlist is full, deny permission + - if section is not full, enroll instead. + """ + + with transaction.atomic(): + section = get_object_or_error(Section.objects, pk=pk) + section = Section.objects.select_for_update().get(pk=section.pk) + student = request.user + + response = _add_to_waitlist_or_section( + section, + student, + bypass_enrollment_time=False, + ) + if response is not None: + # User was auto-enrolled (section had room), return the enrollment response + return response + + # User was added to the waitlist (not enrolled) + log_enroll_result(True, request.user, section) + return Response(status=status.HTTP_201_CREATED) + + +@api_view(["PUT"]) +def add_by_coord(request, pk=None): + """ + Endpoint: /api/waitlist//coordadd + pk = section id + + PUT: Add students to waitlist (or section if room) by coordinator. + Processes each email independently — failures for one email do not + prevent other emails from being processed. + + Request body: + emails: list of {"email": str} + """ + + section = get_object_or_error(Section.objects, pk=pk) + + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() + ) + if not is_coord: + raise PermissionDenied("You must be a coord to perform this action.") + + data = request.data or {} + + if not data.get("emails"): + return Response( + {"error": "Must specify emails of students to waitlist"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + # Deduplicate and validate email list + email_set = set() + emails = [] + for obj in data.get("emails"): + email = obj.get("email") if isinstance(obj, dict) else obj + if email and email not in email_set: + emails.append(email) + email_set.add(email) + + if not emails: + return Response( + {"error": "Must specify email of student to waitlist"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + results = [] + any_errors = False + for email in emails: + with transaction.atomic(): + section = Section.objects.select_for_update().get(pk=section.pk) + user, _ = User.objects.get_or_create( + username=email.split("@")[0], email=email + ) + try: + _add_to_waitlist_or_section( + section, + user, + bypass_enrollment_time=True, + ) + results.append({"email": email, "status": "OK"}) + logger.info( + " User %s added to Waitlist for Section %s by coordinator %s", + user, + section, + request.user, + ) + except PermissionDenied as exc: + any_errors = True + results.append( + { + "email": email, + "status": "CONFLICT", + "detail": {"reason": str(exc.detail)}, + } + ) + logger.warning( + " User %s not added to Waitlist for Section %s: %s", + user, + section, + exc.detail, + ) + + response_data = {"errors": {}, "progress": results} + if any_errors: + return Response(response_data, status=status.HTTP_422_UNPROCESSABLE_ENTITY) + return Response(status=status.HTTP_200_OK) + + +def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): + """Add a user to a section or its waitlist. + + Returns a Response when the user was enrolled (or swapped) into the section, + or None when the user was added to the waitlist. + """ + course = section.mentor.course + + if not user.can_waitlist_in_course( + course, bypass_enrollment_time=bypass_enrollment_time + ): + raise PermissionDenied("User cannot waitlist in this course.") + + if user.student_set.filter(active=True, section=section).exists(): + raise PermissionDenied("User is already enrolled in this section.") + + if user.mentor_set.filter(section__mentor__course=course).exists(): + raise PermissionDenied("Mentors cannot waitlist in a course they mentor.") + + if not section.is_section_full: + # If user is already enrolled in another section, swap them + if user.student_set.filter(active=True, course=course).exists(): + old_section_id = swap_into_section(section, user) + if old_section_id is not None: + add_from_waitlist(pk=old_section_id) + return Response(status=status.HTTP_200_OK) + return add_student(section, user) + + if section.is_waitlist_full: + raise PermissionDenied("There is no space available in this waitlist.") + + if not user.can_enroll_in_waitlist(course): + raise PermissionDenied( + "User is waitlisted in the max amount of waitlists for this course." + ) + + if WaitlistedStudent.objects.filter( + active=True, section=section, user=user + ).exists(): + raise PermissionDenied("User is already waitlisted in this section.") + + WaitlistedStudent.objects.create(user=user, section=section, course=course) + return None + + +@api_view(["PATCH"]) +def drop(request, pk=None): + """ + Endpoint: /api/waitlist//drop + pk = waitlisted student id + + PATCH: Drop a student off the waitlist. + - sets to inactive. Called by user or coordinator. + + """ + user = request.user + waitlisted_student = WaitlistedStudent.objects.filter(pk=pk).first() + if waitlisted_student is None: + raise NotFound("Student is not on the waitlist for this section") + section = waitlisted_student.section + course = section.mentor.course + is_coordinator = course.coordinator_set.filter(user=user).exists() + + # Check that the user has permissions to drop this student + if waitlisted_student.user != user and not is_coordinator: + raise PermissionDenied( + "You do not have permission to drop this student from the waitlist" + ) + # Remove the waitlisted student + waitlisted_student.active = False + # waitlisted_student.delete() + waitlisted_student.save() + logger.info( + " User %s dropped from Waitlist for Section %s", + user, + waitlisted_student.section, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +def log_enroll_result(success, user, section, reason=None): + """Logs waitlist success or failure for a user in a section.""" + if success: + logger.info( + " User %s enrolled into Waitlist for Section %s", + user, + section, + ) + else: + logger.warning( + " User %s not enroll in Waitlist for Section %s: %s", + user, + section, + reason, + ) + + +@api_view(["GET"]) +def count_waitlist(request, pk=None): + """ + Endpoint: /api/waitlist//count_waitlist + pk= section id + """ + section = get_object_or_error(Section.objects, pk=pk) + return Response(section.current_waitlist_count) + + +@api_view(["GET"]) +def position(request, pk=None): + """ + Endpoint: /api/waitlist//position + pk = section id + + GET: Get the current user's position on the waitlist for a section. + Returns {"position": } where position is 1-indexed rank among + active waitlisted students. + """ + section = get_object_or_error(Section.objects, pk=pk) + waitlisted_student = WaitlistedStudent.objects.filter( + active=True, section=section, user=request.user + ).first() + if waitlisted_student is None: + raise NotFound("You are not on the waitlist for this section.") + + # Count how many active waitlisted students have a higher-priority (lower) position + rank = ( + WaitlistedStudent.objects.filter( + active=True, + section=section, + position__lt=waitlisted_student.position, + ).count() + + 1 + ) + + return Response({"position": rank})