diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index efbaa8a2..2f66d70c 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; -import { useSectionStudents } from "../../utils/queries/sections"; -import { Mentor, Spacetime, Student } from "../../utils/types"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { useSectionStudents, useDropSectionMutation } from "../../utils/queries/sections"; +import { Mentor, Spacetime, Student, Course } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import { CoordinatorAddStudentModal } from "./CoordinatorAddStudentModal"; import MetaEditModal from "./MetaEditModal"; @@ -8,6 +9,21 @@ import { InfoCard, SectionSpacetime } from "./Section"; import SpacetimeEditModal from "./SpacetimeEditModal"; import StudentDropper from "./StudentDropper"; import SpacetimeDeleteModal from "./SpacetimeDeleteModal"; +import { fetchWithMethod, HTTP_METHODS } from "../../utils/api"; + +import { useProfiles, useUserInfo } from "../../utils/queries/base"; +import { useCourses } from "../../utils/queries/courses"; + +import Modal from "../Modal"; + +import { useMutation, UseMutationResult, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; +import { + handleError, + handlePermissionsError, + handleRetry, + PermissionError, + ServerError +} from "./../../utils/queries/helpers"; // Images import XIcon from "../../../static/frontend/img/x.svg"; @@ -15,6 +31,7 @@ import PencilIcon from "../../../static/frontend/img/pencil.svg"; // Styles import "../../css/coordinator-add-student.scss"; +import { NavLink } from "react-router-dom"; enum ModalStates { NONE = "NONE", @@ -44,6 +61,8 @@ export default function MentorSectionInfo({ }: MentorSectionInfoProps) { const { data: students, isSuccess: studentsLoaded, isError: studentsLoadError } = useSectionStudents(sectionId); + const { data: courses, isSuccess: coursesLoaded, isError: coursesLoadError } = useCourses(); + const [showModal, setShowModal] = useState(ModalStates.NONE); const [focusedSpacetimeID, setFocusedSpacetimeID] = useState(-1); const [isAddingStudent, setIsAddingStudent] = useState(false); @@ -232,6 +251,57 @@ export default function MentorSectionInfo({ + ); } + +interface DropSectionProps { + sectionId: number; +} + +enum DropSectionStage { + INITIAL = "INITIAL", + CONFIRM = "CONFIRM", + DROPPED = "DROPPED" +} + +function DropSection({ sectionId }: DropSectionProps) { + const sectionDropMutation = useDropSectionMutation(sectionId); + const [stage, setStage] = useState(DropSectionStage.INITIAL); + + const performDrop = () => { + sectionDropMutation.mutate(undefined, { + onSuccess: () => { + setStage(DropSectionStage.DROPPED); + } + }); + }; + + switch (stage) { + case DropSectionStage.INITIAL: + return ( + +
Delete Section
+ +
+ ); + case DropSectionStage.CONFIRM: + return ( + setStage(DropSectionStage.INITIAL)}> +
+
Are you sure you want to delete?
+ + +
+
+ ); + case DropSectionStage.DROPPED: + return ; + } +} diff --git a/csm_web/frontend/src/css/coordinator-add-student.scss b/csm_web/frontend/src/css/coordinator-add-student.scss index bdfe1078..3ea680c1 100644 --- a/csm_web/frontend/src/css/coordinator-add-student.scss +++ b/csm_web/frontend/src/css/coordinator-add-student.scss @@ -308,3 +308,33 @@ $modal-effective-height: calc($modal-height - $modal-padding-y); .coordinator-email-response-item-right > * { flex: 1; } + +.coordinator-delete-position { + display: flex; + justify-content: flex-end; +} + +.coordinator-delete-button { + width: 250px; + height: 50px; + display: flex; + justify-content: center; + align-items: center; + + background-color: #ff7272; + cursor: pointer; + border-radius: 10px; + border: none; + outline: none; + transition: background-color 0.4s; + //box-shadow: 0px 8px 24px rgba(149, 157, 165, 0.5); + box-shadow: 0px 4px 4px rgba(198, 198, 198, 0.25); +} + +.coordinator-delete-link { + display: flex; + text-decoration: none; + color: white; + justify-content: space-between; + align-items: center; +} diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index a4bbcf8c..7795ba92 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -6,6 +6,7 @@ import { useMutation, UseMutationResult, useQuery, useQueryClient, UseQueryResul import { fetchNormalized, fetchWithMethod, HTTP_METHODS } from "../api"; import { Attendance, RawAttendance, Section, Spacetime, Student } from "../types"; import { handleError, handlePermissionsError, handleRetry, PermissionError, ServerError } from "./helpers"; +import { useProfiles } from "./base"; /* ===== Queries ===== */ @@ -501,3 +502,36 @@ export const useSectionUpdateMutation = ( handleError(mutationResult); return mutationResult; }; + +/** + * Hook to drop the current section + * + * Invalidates the current user profile query. + */ +export const useDropSectionMutation = (sectionId: number): UseMutationResult => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async () => { + if (isNaN(sectionId) || isNaN(sectionId)) { + throw new PermissionError("Invalid section id"); + } + const response = await fetchWithMethod(`sections/${sectionId}`, HTTP_METHODS.DELETE); + if (response.ok) { + return; + } else { + handlePermissionsError(response.status); + throw new ServerError(`Failed to drop section ${sectionId}`); + } + }, + { + onSuccess: () => { + queryClient.invalidateQueries(["sections", sectionId]); + queryClient.invalidateQueries(["courses"]); + }, + retry: handleRetry + } + ); + + handleError(mutationResult); + return mutationResult; +}; diff --git a/csm_web/scheduler/tests/models/test_section.py b/csm_web/scheduler/tests/models/test_section.py new file mode 100644 index 00000000..83d1885c --- /dev/null +++ b/csm_web/scheduler/tests/models/test_section.py @@ -0,0 +1,77 @@ +import pytest +from django.urls import reverse +from scheduler.factories import ( + CoordinatorFactory, + CourseFactory, + MentorFactory, + SectionFactory, + StudentFactory, + UserFactory, +) +from scheduler.models import Section + + +@pytest.fixture(name="setup") +def setup_test(): + """ + Create a course, coordinator, mentor, and a student for testing. + """ + # Setup course + course = CourseFactory.create() + # Setup sections + section_one = create_section(course) + section_two = create_section(course) + # Setup students + create_students(course, section_one, 3) + create_students(course, section_two, 3) + # Setup coordinator for course + coordinator_user = UserFactory.create() + # Create coordinator for course + CoordinatorFactory.create(user=coordinator_user, course=course) + + return ( + section_one, + coordinator_user, + ) + + +@pytest.mark.django_db +def test_section_delete(client, setup): + """ + Test that a section can be deleted. + """ + ( + section_one, + coordinator_user, + ) = setup + # Login as coordinator + client.force_login(coordinator_user) + # Delete section + response = client.delete(reverse("section-detail", kwargs={"pk": section_one.id})) + # Check that section was deleted + assert response.status_code == 204 + assert Section.objects.count() == 1 + + +def create_students(course, section, quantity): + """ + Creates a given number of students for a given section. + """ + student_users = UserFactory.create_batch(quantity) + students = [] + for student_user in student_users: + student = StudentFactory.create( + user=student_user, course=course, section=section + ) + students.append(student) + return students + + +def create_section(course): + """ + Creates a section for a given course. + """ + mentor_user = UserFactory.create() + mentor = MentorFactory.create(user=mentor_user, course=course) + section = SectionFactory.create(mentor=mentor) + return section diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 1a063140..a1bbef32 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -119,6 +119,45 @@ def create(self, request): serializer = self.serializer_class(section) return Response(serializer.data, status=status.HTTP_201_CREATED) + def destroy(self, request, pk=None): + """ + Handle request to delete section through the UI; + deletes mentor and spacetimes along with it + """ + section = get_object_or_error(Course.objects.all(), pk=pk) + course = section.mentor.course + is_coordinator = course.coordinator_set.filter(user=request.user).exists() + if not is_coordinator: + logger.error( + ( + " Could not delete spacetime, user %s" + " does not have proper permissions" + ), + log_str(request.user), + ) + raise PermissionDenied( + "You must be a coordinator to delete this spacetime!" + ) + # If the course has students, we cannot delete the section + if section.students.count() > 0: + logger.error( + ( + "
Could not delete section %s, it has" + " students. Remove all students manually first." + ), + log_str(section), + ) + return PermissionDenied("Cannot delete section with students") + # Delete all spacetimes in the section + with transaction.atomic(): + for spacetime in section.spacetimes.all(): + spacetime.delete() + # Delete the mentor + section.mentor.delete() + + section.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + def partial_update(self, request, pk=None): """Update section metadata (capacity and description)""" section = get_object_or_error(self.get_queryset(), pk=pk)