diff --git a/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx b/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx index 16121e84..d7d27915 100644 --- a/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx @@ -3,6 +3,7 @@ export interface Time { startTime: number; endTime: number; isLinked: boolean; + location: string; } export interface Slot { diff --git a/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx b/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx index f01f381c..69a79f25 100644 --- a/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx @@ -57,7 +57,8 @@ export function MentorSectionPreferences({ day: time.day, startTime: parseTime(time.startTime), endTime: parseTime(time.endTime), - isLinked: time.isLinked + isLinked: time.isLinked, + location: time.location }); } const parsed_slot: Slot = { @@ -176,6 +177,8 @@ export function MentorSectionPreferences({
  • {time.day} {formatTime(time.startTime)}–{formatTime(time.endTime)} +
    + (location: {time.location})
  • ))} diff --git a/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx b/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx index 706de208..efd97a11 100644 --- a/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { formatTime } from "../utils"; import { CalendarDay, CalendarDayHeader } from "./CalendarDay"; import { CalendarEvent, CalendarEventSingleTime, DAYS } from "./CalendarTypes"; @@ -11,11 +11,14 @@ const SCROLL_AMT = 30; const WIDTH_SCALE = 0.9; +const DEFAULT_LOCATION = "TBD"; + interface Time { day: string; startTime: number; endTime: number; isLinked: boolean; // whether this time is linked to another within a section + location: string; } interface CalendarProps { @@ -63,7 +66,8 @@ export function Calendar({ day: "", startTime: -1, endTime: -1, - isLinked: false + isLinked: false, + location: DEFAULT_LOCATION }); const [eventExtrema, setEventExtrema] = useState<{ min: number; max: number }>({ @@ -222,7 +226,7 @@ export function Calendar({ if (!eventCreationEnabled) { return; } - setCurCreatedEvent({ day, startTime, endTime, isLinked: createdTimes.length > 0 }); + setCurCreatedEvent({ day, startTime, endTime, isLinked: createdTimes.length > 0, location: DEFAULT_LOCATION }); setCreatingEvent(true); setEventHoverIndex(-1); setSelectedEventIndices([]); @@ -237,7 +241,8 @@ export function Calendar({ day: curCreatedEvent.day, startTime: curCreatedEvent.startTime, endTime: end_time, - isLinked: curCreatedEvent.isLinked + isLinked: curCreatedEvent.isLinked, + location: curCreatedEvent.location }); }; @@ -246,12 +251,12 @@ export function Calendar({ return; } onEventCreated(normalizeCreatedEvent()); - setCurCreatedEvent({ day: "", startTime: -1, endTime: -1, isLinked: false }); + setCurCreatedEvent({ day: "", startTime: -1, endTime: -1, isLinked: false, location: DEFAULT_LOCATION }); setCreatingEvent(false); }; const onCreateDragEndCancel = (e: MouseEvent | FocusEvent) => { - setCurCreatedEvent({ day: "", startTime: -1, endTime: -1, isLinked: false }); + setCurCreatedEvent({ day: "", startTime: -1, endTime: -1, isLinked: false, location: DEFAULT_LOCATION }); setCreatingEvent(false); }; @@ -263,7 +268,8 @@ export function Calendar({ day: curCreatedEvent.day, startTime: curCreatedEvent.endTime - INTERVAL_LENGTH, endTime: curCreatedEvent.startTime + INTERVAL_LENGTH, - isLinked: curCreatedEvent.isLinked + isLinked: curCreatedEvent.isLinked, + location: "TBD" }; } }; @@ -296,8 +302,8 @@ export function Calendar({ onEventHover={ disableHover ? () => { - /* do nothing */ - } + /* do nothing */ + } : setEventHoverIndex } onEventClick={eventClickWrapper} diff --git a/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx b/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx index f090a42b..df147233 100644 --- a/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx @@ -9,6 +9,7 @@ interface NumberTime { startTime: number; endTime: number; isLinked: boolean; + location: string; } enum EventType { diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx index a4c0bb4e..09a034c3 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx @@ -31,6 +31,7 @@ interface StrTime { day: string; startTime: string; endTime: string; + location: string; } export function CoordinatorMatcherForm({ @@ -66,7 +67,8 @@ export function CoordinatorMatcherForm({ startTime: parseTime(time.startTime), endTime: parseTime(time.endTime), day: time.day, - isLinked: slot.times.length > 0 + isLinked: slot.times.length > 0, + location: time.location }; }); return { diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx index d5809c7e..c61d6704 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx @@ -10,7 +10,9 @@ import { formatInterval, formatTime, parseTime, serializeTime } from "../utils"; import InfoIcon from "../../../../static/frontend/img/info.svg"; import XIcon from "../../../../static/frontend/img/x.svg"; -import { useMatcherConfigMutation, useMatcherSlotsMutation } from "../../../utils/queries/matcher"; +import { useMatcherSlotsMutation } from "../../../utils/queries/matcher"; + +const DEFAULT_LOCATION = "TBD"; interface TileDetails { days: string[]; @@ -18,6 +20,7 @@ interface TileDetails { startTime: number; endTime: number; length: number; + location: string; } interface CreateStageProps { @@ -61,7 +64,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro daysLinked: true, startTime: -1, endTime: -1, - length: 60 + length: 60, + location: DEFAULT_LOCATION }); /** @@ -69,7 +73,6 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro */ const [edited, setEdited] = useState(false); - const matcherConfigMutation = useMatcherConfigMutation(profile.courseId); const matcherSlotsMutation = useMatcherSlotsMutation(profile.courseId); /** @@ -87,7 +90,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro startTime: React.createRef(), endTime: React.createRef(), length: React.createRef(), - toggle: React.createRef() + toggle: React.createRef(), + location: React.createRef() }; /** @@ -114,7 +118,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro startTime: t, endTime: t + tileDetails.length, // linked only if there are multiple days and user wants to link them - isLinked: tileDetails.daysLinked && tileDetails.days.length > 1 + isLinked: tileDetails.daysLinked && tileDetails.days.length > 1, + location: tileDetails.location }); } } @@ -130,7 +135,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro const times = slot.times.map(time => ({ day: time.day, startTime: serializeTime(time.startTime), - endTime: serializeTime(time.endTime) + endTime: serializeTime(time.endTime), + location: time.location })); return { ...slot, @@ -254,6 +260,22 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro setCurCreatedTimes(newTimes); }; + /** + * Edit the location field of an event + * + * @param index index of time to edit + * @param newLocation new location value + */ + const editTime_location = (index: number, newLocation: string) => { + const newTimes = [...curCreatedTimes]; + if (newLocation.trim() === "") { + newTimes[index]["location"] = DEFAULT_LOCATION; + } else { + newTimes[index]["location"] = newLocation; + } + setCurCreatedTimes(newTimes); + }; + const toggleCreatingTiledEvents = (e: React.ChangeEvent): void => { // current value of checkbox after click const checked = e.target.checked; @@ -297,6 +319,10 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro setTileDetails({ ...tileDetails, daysLinked: e.target.checked }); }; + const editTiled_location = (value: string): void => { + setTileDetails({ ...tileDetails, location: value }); + }; + const saveTiledEvents = () => { const newSlots = []; for (let t = tileDetails.startTime; t <= tileDetails.endTime - tileDetails.length; t += tileDetails.length) { @@ -307,17 +333,28 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro day: day, startTime: t, endTime: t + tileDetails.length, - isLinked: tileDetails.days.length > 1 + isLinked: tileDetails.days.length > 1, + location: tileDetails.location }); } newSlots.push(newEvent); } else { for (const day of tileDetails.days) { - newSlots.push({ times: [{ day: day, startTime: t, endTime: t + tileDetails.length, isLinked: false }] }); + newSlots.push({ + times: [ + { + day: day, + startTime: t, + endTime: t + tileDetails.length, + isLinked: false, + location: tileDetails.location + } + ] + }); } } } - setSlots([...slots, ...newSlots]); + setSlots([...slots, ...newSlots] as Slot[]); // stop creating tiled events tileRefs.toggle.current!.checked = false; toggleCreatingTiledEvents({ target: tileRefs.toggle.current! } as React.ChangeEvent); @@ -327,7 +364,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro * Save the newly created event and times */ const saveEvent = () => { - const newEvent = { times: curCreatedTimes }; + const newEvent: Slot = { times: curCreatedTimes }; setSlots([...slots, newEvent]); setCurCreatedTimes([]); setSavedExistingEvent(null); @@ -340,7 +377,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro */ const cancelEvent = () => { if (savedExistingEvent !== null) { - setSlots([...slots, savedExistingEvent]); + setSlots([...slots, savedExistingEvent] as Slot[]); setSavedExistingEvent(null); } // proceed with resetting current event @@ -472,6 +509,16 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro /> mins +
    +
    Location:
    + editTiled_location(e.target.value)} + /> +
    @@ -494,32 +541,43 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro deleteTime(time_idx)} />
    - - editTime_startTime(time_idx, e.target.value)} - /> - – - editTime_endTime(time_idx, e.target.value)} - /> +
    + + editTime_startTime(time_idx, e.target.value)} + /> + – + editTime_endTime(time_idx, e.target.value)} + /> +
    +
    + Location: + editTime_location(time_idx, e.target.value)} + /> +
    ))} @@ -558,6 +616,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro {time.day} {formatTime(time.startTime)}–{formatTime(time.endTime)} +
    + (location: {time.location}) ))} diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx index 418e9ffc..316eb107 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx @@ -703,7 +703,7 @@ const EditTableRow = ({ * Format a datetime as a string for display. */ const displayTime = (time: Time) => { - return `${DAYS_ABBREV[time.day]} ${formatTime(time.startTime)}\u2013${formatTime(time.endTime)}`; + return `${DAYS_ABBREV[time.day]} ${formatTime(time.startTime)}\u2013${formatTime(time.endTime)} (${time.location})`; }; /** diff --git a/csm_web/frontend/src/utils/queries/matcher.tsx b/csm_web/frontend/src/utils/queries/matcher.tsx index e40cf9fe..c9e5b4a0 100644 --- a/csm_web/frontend/src/utils/queries/matcher.tsx +++ b/csm_web/frontend/src/utils/queries/matcher.tsx @@ -52,6 +52,7 @@ interface MatcherSlotsResponseTime { day: string; startTime: string; endTime: string; + location: string; isLinked: boolean; } diff --git a/csm_web/frontend/static/frontend/css/enrollment_matcher.css b/csm_web/frontend/static/frontend/css/enrollment_matcher.css index 4cb8752c..2ea2a696 100644 --- a/csm_web/frontend/static/frontend/css/enrollment_matcher.css +++ b/csm_web/frontend/static/frontend/css/enrollment_matcher.css @@ -354,7 +354,26 @@ left: -3px; } +.matcher-selected-location { + font-size: 0.9rem; + color: #666; +} + .matcher-created-time { + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.matcher-created-time-location { + font-size: 0.9rem; +} + +.matcher-created-time-location span { + margin-right: 3px; +} + +.matcher-created-time-detail { padding: 5px 0; } diff --git a/csm_web/scheduler/views/matcher.py b/csm_web/scheduler/views/matcher.py index 4082794d..2d2b2fd8 100644 --- a/csm_web/scheduler/views/matcher.py +++ b/csm_web/scheduler/views/matcher.py @@ -1,37 +1,34 @@ import datetime +from django.db import transaction +from django.db.models import Q +from rest_framework import status from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from rest_framework import status - -from django.db.models import Q -from django.db import transaction - from scheduler.models import ( - User, Course, - Section, - Mentor, Matcher, MatcherPreference, MatcherSlot, + Mentor, + Section, Spacetime, + User, ) from scheduler.serializers import ( MatcherPreferenceSerializer, MatcherSlotSerializer, MentorSerializer, ) -from .utils import get_object_or_error, logger - from scheduler.utils.match_solver import ( - get_matches, MentorTuple, - SlotTuple, PreferenceTuple, + SlotTuple, + get_matches, ) +from .utils import get_object_or_error, logger DEFAULT_CAPACITY = 5 TIME_FORMAT = "%H:%M" # Assuming 24-hour format in hh:mm @@ -86,11 +83,11 @@ def slots(request, pk=None): POST: Creates new matcher slots for the given course. - coordinators only - delete all existing slots when receiving this POST request - - maybe update with a PUT request? - - format: [{"times": [{"day": str, "startTime": str, "endTime": str}], "numMentors": int}] - - - to release/close preference submissions: - {"release": bool} + - format: [{ + "times": [{"day": str, "startTime": str, "endTime": str, "location": str}], + "minMentors": int, + "maxMentors": int, + }] """ course = get_object_or_error(Course.objects, pk=pk) matcher = course.matcher @@ -120,11 +117,6 @@ def slots(request, pk=None): # create matcher matcher = Matcher.objects.create(course=course) - """ - Request data: - [{"times": [{"day": str, "startTime": str, "endTime": str}], "numMentors": int}] - """ - if "slots" in request.data: request_slots = request.data["slots"] request_times = [slot["times"] for slot in request_slots] @@ -621,12 +613,13 @@ def create(request, pk=None): start = datetime.datetime.strptime(time["start_time"], TIME_FORMAT) end = datetime.datetime.strptime(time["end_time"], TIME_FORMAT) duration = end - start + location = time.get("location", DEFAULT_LOCATION) Spacetime.objects.create( section=section, duration=duration, start_time=start, day_of_week=time["day"], - location=DEFAULT_LOCATION, + location=location, ) # close the matcher after sections have been created matcher.active = False