From fe2f8e1852530b48e73d974bce488f03c379a63f Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 27 Feb 2025 01:10:33 -0800 Subject: [PATCH 01/16] mentor and student view APIs --- .../migrations/0033_mentor_family.py | 18 +++ csm_web/scheduler/models.py | 2 + csm_web/scheduler/serializers.py | 109 ++++++++++++++++++ csm_web/scheduler/urls.py | 2 + csm_web/scheduler/views/__init__.py | 1 + csm_web/scheduler/views/coord.py | 51 ++++++++ 6 files changed, 183 insertions(+) create mode 100644 csm_web/scheduler/migrations/0033_mentor_family.py create mode 100644 csm_web/scheduler/views/coord.py diff --git a/csm_web/scheduler/migrations/0033_mentor_family.py b/csm_web/scheduler/migrations/0033_mentor_family.py new file mode 100644 index 000000000..7a797188d --- /dev/null +++ b/csm_web/scheduler/migrations/0033_mentor_family.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2025-02-27 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("scheduler", "0032_word_of_the_day"), + ] + + operations = [ + migrations.AddField( + model_name="mentor", + name="family", + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 9e367ab60..fb81b1c01 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -310,6 +310,8 @@ class Mentor(Profile): have a new Mentor profile. """ + family = models.CharField(max_length=100, blank=True) + class Coordinator(Profile): """ diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index cd31f7413..b46233b7a 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -79,6 +79,10 @@ def to_representation(self, value): class OverrideReadOnlySerializer(serializers.ModelSerializer): + """ + Serializer for read-only access to overrides + """ + spacetime = serializers.SerializerMethodField() date = serializers.DateField(format="%b. %-d") @@ -96,6 +100,10 @@ class Meta: class SpacetimeSerializer(serializers.ModelSerializer): + """ + Serializer for Spacetime objects + """ + duration = serializers.SerializerMethodField() day_of_week = serializers.SerializerMethodField() location = make_omittable( @@ -130,6 +138,10 @@ class Meta: class CourseSerializer(serializers.ModelSerializer): + """ + Serializer for Course objects + """ + enrollment_open = serializers.SerializerMethodField() user_can_enroll = serializers.SerializerMethodField() @@ -165,6 +177,10 @@ class Meta: class UserSerializer(serializers.ModelSerializer): + """ + Serializer for User objects + """ + class Meta: model = User fields = ("id", "email", "first_name", "last_name", "priority_enrollment") @@ -241,6 +257,10 @@ def update(self, instance, validated_data): class StudentSerializer(serializers.ModelSerializer): + """ + Serializer for Student objects + """ + email = serializers.EmailField(source="user.email") attendances = AttendanceSerializer(source="attendance_set", many=True) @@ -249,7 +269,88 @@ class Meta: fields = ("id", "name", "email", "attendances", "section") +class CoordStudentSerializer(serializers.ModelSerializer): + """ + Serializer for the coordinator view of students + """ + + email = serializers.EmailField(source="user.email") + mentor_name = serializers.CharField(source="section.mentor.name") + num_unexcused = serializers.SerializerMethodField() + day_time = serializers.SerializerMethodField() + + def get_num_unexcused(self, obj): + """ + Count the number of unexcused absences for the student + """ + attendances = obj.attendance_set.all() + return sum(1 for attendance in attendances if attendance.presence == "PR") + + def get_day_time(self, obj): + """ + Get the section time + """ + section_time = obj.section.spacetimes.first() + if section_time: + return f"{section_time.day_of_week[:3]} {section_time.start_time.strftime('%I:%M%p')}" + return None + + class Meta: + model = Student + fields = ( + "id", + "name", + "email", + "num_unexcused", + "section", + "mentor_name", + "day_time", + ) + + +class CoordMentorSerializer(serializers.ModelSerializer): + """ + Serializer for the coordinator view of mentors + """ + + email = serializers.EmailField(source="user.email") + num_students = serializers.SerializerMethodField() + day_time = serializers.SerializerMethodField() + + def get_num_students(self, obj): + """ + Get the number of students in the section + """ + students = obj.section.students.all() + return students.count() + + def get_day_time(self, obj): + """ + Get the section time + """ + section_time = obj.section.spacetimes.first() + if section_time: + return f"{section_time.day_of_week[:3]} {section_time.start_time.strftime('%I:%M%p')}" + return None + + class Meta: + model = Mentor + fields = ( + "id", + "name", + "email", + "num_students", + "section", + "family", + "day_time", + ) + + class SectionSerializer(serializers.ModelSerializer): + """ + Serializer for Section objects + """ + spacetimes = SpacetimeSerializer(many=True) num_students_enrolled = serializers.SerializerMethodField() mentor = MentorSerializer() @@ -313,12 +414,20 @@ class Meta: class WorksheetSerializer(serializers.ModelSerializer): + """ + Serializer for Worksheet objects + """ + class Meta: model = Worksheet fields = ["id", "name", "resource", "worksheet_file", "solution_file"] class LinkSerializer(serializers.ModelSerializer): + """ + Serializer for Link objects + """ + class Meta: model = Link fields = ["id", "name", "resource", "url"] diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 8c60cd334..42c31c574 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -23,5 +23,7 @@ path("matcher//mentors/", views.matcher.mentors), path("matcher//configure/", views.matcher.configure), path("matcher//create/", views.matcher.create), + path("coord//students/", views.coord.view_students), + path("coord//mentors/", views.coord.view_mentors), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py index 55ed65f31..da0f69ee6 100644 --- a/csm_web/scheduler/views/__init__.py +++ b/csm_web/scheduler/views/__init__.py @@ -1,4 +1,5 @@ from . import matcher +from .coord import view_mentors, view_students from .course import CourseViewSet from .export import export_data from .profile import ProfileViewSet diff --git a/csm_web/scheduler/views/coord.py b/csm_web/scheduler/views/coord.py new file mode 100644 index 000000000..6ea08c7f1 --- /dev/null +++ b/csm_web/scheduler/views/coord.py @@ -0,0 +1,51 @@ +from rest_framework.decorators import api_view +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from scheduler.serializers import CoordMentorSerializer, CoordStudentSerializer + +from ..models import Course, Mentor, Student + + +@api_view(["GET"]) +def view_students(request, pk=None): + # pk = course id + """ + Endpoint: /coord//student/ + + GET: view all students in course + """ + + is_coord = bool( + Course.objects.get(pk=pk).coordinator_set.filter(user=request.user).count() + ) + if not is_coord: + raise PermissionDenied( + "You do not have permission to view the coordinator view." + ) + + students = Student.objects.filter(active=True, course=pk).order_by( + "user__first_name" + ) + + return Response(CoordStudentSerializer(students, many=True).data) + + +@api_view(["GET"]) +def view_mentors(request, pk=None): + """ + Endpoint: /coord//mentor/ + + GET: view all mentors in course + """ + + is_coord = bool( + Course.objects.get(pk=pk).coordinator_set.filter(user=request.user).count() + ) + if not is_coord: + raise PermissionDenied( + "You do not have permission to view the coordinator view." + ) + + mentors = Mentor.objects.filter(course=pk).order_by("user__first_name") + print(mentors) + return Response(CoordMentorSerializer(mentors, many=True).data) From 82f4d241c2fdcd55c222d2fa75f5b3075dd92e01 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 27 Feb 2025 01:33:47 -0800 Subject: [PATCH 02/16] API cleanup and speedup --- csm_web/scheduler/models.py | 7 +++++++ csm_web/scheduler/serializers.py | 25 +++---------------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index fb81b1c01..4d93a7493 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -345,6 +345,13 @@ class Section(ValidatingModel): ), ) + @property + def day_time(self): + day_time = self.spacetimes.first() + if day_time: + return f"{day_time.day_of_week[:3]} {day_time.start_time.strftime('%I:%M%p')}" + return None + # @functional.cached_property # def course(self): # return self.mentor.course diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index b46233b7a..be669fecf 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -277,23 +277,13 @@ class CoordStudentSerializer(serializers.ModelSerializer): email = serializers.EmailField(source="user.email") mentor_name = serializers.CharField(source="section.mentor.name") num_unexcused = serializers.SerializerMethodField() - day_time = serializers.SerializerMethodField() + day_time = serializers.CharField(source="section.day_time") def get_num_unexcused(self, obj): """ Count the number of unexcused absences for the student """ - attendances = obj.attendance_set.all() - return sum(1 for attendance in attendances if attendance.presence == "PR") - - def get_day_time(self, obj): - """ - Get the section time - """ - section_time = obj.section.spacetimes.first() - if section_time: - return f"{section_time.day_of_week[:3]} {section_time.start_time.strftime('%I:%M%p')}" - return None + return obj.attendance_set.filter(presence="PR").count() class Meta: model = Student @@ -315,7 +305,7 @@ class CoordMentorSerializer(serializers.ModelSerializer): email = serializers.EmailField(source="user.email") num_students = serializers.SerializerMethodField() - day_time = serializers.SerializerMethodField() + day_time = serializers.CharField(source="section.day_time") def get_num_students(self, obj): """ @@ -324,15 +314,6 @@ def get_num_students(self, obj): students = obj.section.students.all() return students.count() - def get_day_time(self, obj): - """ - Get the section time - """ - section_time = obj.section.spacetimes.first() - if section_time: - return f"{section_time.day_of_week[:3]} {section_time.start_time.strftime('%I:%M%p')}" - return None - class Meta: model = Mentor fields = ( From a9e790934075b2fe47428072df2103f99c509861 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 27 Feb 2025 02:05:12 -0800 Subject: [PATCH 03/16] delete API finished, error checking implemented for all APIs --- csm_web/scheduler/urls.py | 1 + csm_web/scheduler/views/__init__.py | 2 +- csm_web/scheduler/views/coord.py | 33 ++++++++++++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 42c31c574..509ac228a 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -25,5 +25,6 @@ path("matcher//create/", views.matcher.create), path("coord//students/", views.coord.view_students), path("coord//mentors/", views.coord.view_mentors), + path("coord//section/", views.coord.delete_section), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py index da0f69ee6..b6974568e 100644 --- a/csm_web/scheduler/views/__init__.py +++ b/csm_web/scheduler/views/__init__.py @@ -1,5 +1,5 @@ from . import matcher -from .coord import view_mentors, view_students +from .coord import view_mentors, view_students, delete_section from .course import CourseViewSet from .export import export_data from .profile import ProfileViewSet diff --git a/csm_web/scheduler/views/coord.py b/csm_web/scheduler/views/coord.py index 6ea08c7f1..496d2d94b 100644 --- a/csm_web/scheduler/views/coord.py +++ b/csm_web/scheduler/views/coord.py @@ -1,22 +1,24 @@ from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from scheduler.views.utils import get_object_or_error from scheduler.serializers import CoordMentorSerializer, CoordStudentSerializer +from django.core.exceptions import ObjectDoesNotExist -from ..models import Course, Mentor, Student +from ..models import Course, Mentor, Section, Student @api_view(["GET"]) def view_students(request, pk=None): - # pk = course id """ Endpoint: /coord//student/ + pk = course id GET: view all students in course """ is_coord = bool( - Course.objects.get(pk=pk).coordinator_set.filter(user=request.user).count() + get_object_or_error(Course.objects, pk=pk).coordinator_set.filter(user=request.user).count() ) if not is_coord: raise PermissionDenied( @@ -34,12 +36,13 @@ def view_students(request, pk=None): def view_mentors(request, pk=None): """ Endpoint: /coord//mentor/ + pk= course id GET: view all mentors in course """ is_coord = bool( - Course.objects.get(pk=pk).coordinator_set.filter(user=request.user).count() + get_object_or_error(Course.objects, pk=pk).coordinator_set.filter(user=request.user).count() ) if not is_coord: raise PermissionDenied( @@ -47,5 +50,25 @@ def view_mentors(request, pk=None): ) mentors = Mentor.objects.filter(course=pk).order_by("user__first_name") - print(mentors) return Response(CoordMentorSerializer(mentors, many=True).data) + +@api_view(["DELETE"]) +def delete_section(request, pk): + """ + Endpoint: /coord/
/section + pk = section id + + Delete a section and all associated spacetimes and overrides. + """ + section = get_object_or_error(Section.objects, pk=pk) + is_coord = bool( + get_object_or_error(Course.objects, pk=section.mentor.course.id).coordinator_set.filter(user=request.user).count() + ) + if not is_coord: + raise PermissionDenied( + "You do not have permission to view the coordinator view." + ) + + # Delete the section itself, will cascade and delete everything else + section.delete() + return Response(status=204) \ No newline at end of file From 2b64cd31a0740c2b6736968648da95c6517b0ba6 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 27 Feb 2025 02:35:05 -0800 Subject: [PATCH 04/16] completed backend APIs --- csm_web/scheduler/views/coord.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/csm_web/scheduler/views/coord.py b/csm_web/scheduler/views/coord.py index 496d2d94b..3d5b6495a 100644 --- a/csm_web/scheduler/views/coord.py +++ b/csm_web/scheduler/views/coord.py @@ -1,9 +1,8 @@ from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from scheduler.views.utils import get_object_or_error from scheduler.serializers import CoordMentorSerializer, CoordStudentSerializer -from django.core.exceptions import ObjectDoesNotExist +from scheduler.views.utils import get_object_or_error from ..models import Course, Mentor, Section, Student @@ -18,7 +17,9 @@ def view_students(request, pk=None): """ is_coord = bool( - get_object_or_error(Course.objects, pk=pk).coordinator_set.filter(user=request.user).count() + get_object_or_error(Course.objects, pk=pk) + .coordinator_set.filter(user=request.user) + .count() ) if not is_coord: raise PermissionDenied( @@ -42,7 +43,9 @@ def view_mentors(request, pk=None): """ is_coord = bool( - get_object_or_error(Course.objects, pk=pk).coordinator_set.filter(user=request.user).count() + get_object_or_error(Course.objects, pk=pk) + .coordinator_set.filter(user=request.user) + .count() ) if not is_coord: raise PermissionDenied( @@ -52,6 +55,7 @@ def view_mentors(request, pk=None): mentors = Mentor.objects.filter(course=pk).order_by("user__first_name") return Response(CoordMentorSerializer(mentors, many=True).data) + @api_view(["DELETE"]) def delete_section(request, pk): """ @@ -62,13 +66,13 @@ def delete_section(request, pk): """ section = get_object_or_error(Section.objects, pk=pk) is_coord = bool( - get_object_or_error(Course.objects, pk=section.mentor.course.id).coordinator_set.filter(user=request.user).count() + section.mentor.course.coordinator_set.filter(user=request.user).count() ) if not is_coord: raise PermissionDenied( "You do not have permission to view the coordinator view." ) - + # Delete the section itself, will cascade and delete everything else section.delete() - return Response(status=204) \ No newline at end of file + return Response(status=204) From 2545926b742e564523c42aeddbe5df3b924c166c Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Wed, 5 Mar 2025 21:42:53 -0800 Subject: [PATCH 05/16] initial table --- csm_web/frontend/src/components/App.tsx | 3 + .../components/coord_interface/CoordTable.tsx | 84 +++++++++++++++++++ csm_web/frontend/src/css/coord_interface.scss | 7 ++ 3 files changed, 94 insertions(+) create mode 100644 csm_web/frontend/src/components/coord_interface/CoordTable.tsx create mode 100644 csm_web/frontend/src/css/coord_interface.scss diff --git a/csm_web/frontend/src/components/App.tsx b/csm_web/frontend/src/components/App.tsx index 00e27a42c..0527ef907 100644 --- a/csm_web/frontend/src/components/App.tsx +++ b/csm_web/frontend/src/components/App.tsx @@ -9,6 +9,7 @@ import { emptyRoles, Roles } from "../utils/user"; import CourseMenu from "./CourseMenu"; import Home from "./Home"; import Policies from "./Policies"; +import CoordTable from "./coord_interface/CoordTable"; import { DataExport } from "./data_export/DataExport"; import { EnrollmentMatcher } from "./enrollment_automation/EnrollmentMatcher"; import { Resources } from "./resource_aggregation/Resources"; @@ -38,6 +39,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/csm_web/frontend/src/components/coord_interface/CoordTable.tsx b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx new file mode 100644 index 000000000..07f6633fb --- /dev/null +++ b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from "react"; +import { useLocation, useParams, Link } from "react-router-dom"; +import { fetchJSON } from "../../utils/api"; +import "../../css/coord_interface.scss"; + +interface Student { + id: number; + name: string; + email: string; + numUnexcused: number; + section: number; + mentorName: string; + dayTime: string; +} + +interface Mentor { + id: number; + name: string; + email: string; + numStudents: number; + section: number; + family: string; + dayTime: string; +} + +export default function CoordTable() { + const [tableData, setTableData] = useState([]); + const params = useParams(); + const courseId = Number(params.id); + const { pathname } = useLocation(); + const isStudents = pathname.includes("students"); + + useEffect(() => { + const fetchData = async () => { + const { tempTableData } = await fetchJSON(`/api/coord/${courseId}/${isStudents ? "students" : "mentors"} `); + setTableData(tempTableData); + }; + fetchData(); + }, []); + + return ( + + isStudents ? ( + + + + + + + + ) : ( + + + + + + + ) + {tableData.map(row => + isStudents ? ( + + + + + + + + + + ) : ( + + + + + + + + + + ) + )} +
NameEmailAbsensesMentor NameTime
NameEmailFamilySection Size
{row.name}{row.email}{(row as Student).mentorName}{row.dayTime}{(row as Student).numUnexcused}
{row.name}{row.email}{(row as Mentor).family}{row.dayTime}{(row as Mentor).numStudents}
+ ); +} diff --git a/csm_web/frontend/src/css/coord_interface.scss b/csm_web/frontend/src/css/coord_interface.scss new file mode 100644 index 000000000..385ee3472 --- /dev/null +++ b/csm_web/frontend/src/css/coord_interface.scss @@ -0,0 +1,7 @@ +.table { + width: 100%; + margin-bottom: 1rem; + color: #212529; + border-spacing: 0; + border-collapse: collapse; +} From 88bbbfd5b7f709123343e83e370f0f641f379595 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 6 Mar 2025 00:33:28 -0800 Subject: [PATCH 06/16] fixed table view, created coord query, fixed backend APIs --- .../components/coord_interface/CoordTable.tsx | 92 +++++++------------ csm_web/frontend/src/utils/queries/coord.tsx | 36 ++++++++ csm_web/scheduler/serializers.py | 4 +- 3 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 csm_web/frontend/src/utils/queries/coord.tsx diff --git a/csm_web/frontend/src/components/coord_interface/CoordTable.tsx b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx index 07f6633fb..78954edea 100644 --- a/csm_web/frontend/src/components/coord_interface/CoordTable.tsx +++ b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx @@ -1,28 +1,8 @@ import React, { useEffect, useState } from "react"; -import { useLocation, useParams, Link } from "react-router-dom"; -import { fetchJSON } from "../../utils/api"; +import { useLocation, useParams, useNavigate } from "react-router-dom"; +import { Mentor, Student, getCoordData } from "../../utils/queries/coord"; import "../../css/coord_interface.scss"; -interface Student { - id: number; - name: string; - email: string; - numUnexcused: number; - section: number; - mentorName: string; - dayTime: string; -} - -interface Mentor { - id: number; - name: string; - email: string; - numStudents: number; - section: number; - family: string; - dayTime: string; -} - export default function CoordTable() { const [tableData, setTableData] = useState([]); const params = useParams(); @@ -32,51 +12,49 @@ export default function CoordTable() { useEffect(() => { const fetchData = async () => { - const { tempTableData } = await fetchJSON(`/api/coord/${courseId}/${isStudents ? "students" : "mentors"} `); - setTableData(tempTableData); + setTableData(await getCoordData(courseId, isStudents)); }; fetchData(); }, []); + const navigate = useNavigate(); + return ( - isStudents ? ( - - - - - - - + {isStudents ? ( + + + + + + + ) : ( - - - - - - - ) + + + + + + + + )} {tableData.map(row => isStudents ? ( - - - - - - - - - + navigate(`/sections/${row.section}`)}> + + + + + + ) : ( - - - - - - - - - + navigate(`/sections/${row.section}`)}> + + + + + + ) )}
NameEmailAbsensesMentor NameTime
NameEmailMentor NameTimeUnexcused Absenses
NameEmailFamilySection Size
NameEmailFamilyTimeSection Size
{row.name}{row.email}{(row as Student).mentorName}{row.dayTime}{(row as Student).numUnexcused}
{row.name}{row.email}{(row as Student).mentorName}{row.dayTime}{(row as Student).numUnexcused}
{row.name}{row.email}{(row as Mentor).family}{row.dayTime}{(row as Mentor).numStudents}
{row.name}{row.email}{(row as Mentor).family}{row.dayTime}{(row as Mentor).numStudents}
diff --git a/csm_web/frontend/src/utils/queries/coord.tsx b/csm_web/frontend/src/utils/queries/coord.tsx new file mode 100644 index 000000000..3125dc125 --- /dev/null +++ b/csm_web/frontend/src/utils/queries/coord.tsx @@ -0,0 +1,36 @@ +import { fetchNormalized } from "../api"; +import { handlePermissionsError, PermissionError, ServerError } from "./helpers"; + +export interface Student { + id: number; + name: string; + email: string; + numUnexcused: number; + section: number; + mentorName: string; + dayTime: string; +} + +export interface Mentor { + id: number; + name: string; + email: string; + numStudents: number; + section: number; + family: string; + dayTime: string; +} + +export const getCoordData = async (courseId: number, isStudents: boolean) => { + // query disabled when id undefined + if (isNaN(courseId!)) { + throw new PermissionError("Invalid course id"); + } + const response = await fetchNormalized(`/coord/${courseId}/${isStudents ? "students" : "mentors"}`); + if (response.ok) { + return await response.json(); + } else { + handlePermissionsError(response.status); + throw new ServerError(`Failed to fetch coord course ${courseId}`); + } +}; diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index be669fecf..180efe95b 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -283,7 +283,7 @@ def get_num_unexcused(self, obj): """ Count the number of unexcused absences for the student """ - return obj.attendance_set.filter(presence="PR").count() + return obj.attendance_set.filter(presence="UN").count() class Meta: model = Student @@ -311,7 +311,7 @@ def get_num_students(self, obj): """ Get the number of students in the section """ - students = obj.section.students.all() + students = obj.section.students.filter(active=True) return students.count() class Meta: From 0bbf4f3d080d0cadc73d15213e4edb8d0d31d790 Mon Sep 17 00:00:00 2001 From: gabrielhan23 <69331301+gabrielhan23@users.noreply.github.com> Date: Tue, 2 Sep 2025 21:41:15 -0700 Subject: [PATCH 07/16] Feat/coord interface/frontend (#524) * initial table * table css work * table css work * front end table searching and select data logic * front end table searching and select data logic * search filter works * im done fr now fixed select glitch and styled input and buttons slightly * finished front end table * functions and dropdown basics * touchups for front end filtering integration and css * copy button works and table header structure * fixed filtering bugs and css for action buttons * fixed weird double copy bug * fixed filter copy bug * finished coord interface --------- Co-authored-by: Heidi Chan --- csm_web/frontend/src/components/Home.tsx | 8 +- csm_web/frontend/src/components/SearchBar.tsx | 2 +- .../coord_interface/ActionButton.tsx | 39 ++ .../components/coord_interface/CheckBox.tsx | 29 ++ .../components/coord_interface/CoordTable.tsx | 329 ++++++++++++++-- .../components/coord_interface/DropBox.tsx | 28 ++ .../components/coord_interface/SearchBar.tsx | 24 ++ .../frontend/src/components/course/Course.tsx | 6 +- csm_web/frontend/src/css/base/base.scss | 4 + csm_web/frontend/src/css/coord_interface.scss | 366 +++++++++++++++++- csm_web/frontend/src/css/interface.scss | 0 11 files changed, 786 insertions(+), 49 deletions(-) create mode 100644 csm_web/frontend/src/components/coord_interface/ActionButton.tsx create mode 100644 csm_web/frontend/src/components/coord_interface/CheckBox.tsx create mode 100644 csm_web/frontend/src/components/coord_interface/DropBox.tsx create mode 100644 csm_web/frontend/src/components/coord_interface/SearchBar.tsx create mode 100644 csm_web/frontend/src/css/interface.scss diff --git a/csm_web/frontend/src/components/Home.tsx b/csm_web/frontend/src/components/Home.tsx index da7873e06..6a060adf7 100644 --- a/csm_web/frontend/src/components/Home.tsx +++ b/csm_web/frontend/src/components/Home.tsx @@ -131,9 +131,11 @@ const CourseCard = ({ profiles }: CourseCardProps): React.ReactElement => { if (role === Role.COORDINATOR) { return ( - - - + <> + + + + ); } diff --git a/csm_web/frontend/src/components/SearchBar.tsx b/csm_web/frontend/src/components/SearchBar.tsx index 255a97662..64bc19d59 100644 --- a/csm_web/frontend/src/components/SearchBar.tsx +++ b/csm_web/frontend/src/components/SearchBar.tsx @@ -12,7 +12,7 @@ export const SearchBar = ({ className, refObject, onChange }: SearchBarProps) => return (
- +
); }; diff --git a/csm_web/frontend/src/components/coord_interface/ActionButton.tsx b/csm_web/frontend/src/components/coord_interface/ActionButton.tsx new file mode 100644 index 000000000..799fad48c --- /dev/null +++ b/csm_web/frontend/src/components/coord_interface/ActionButton.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +interface ActionButtonProps { + copyEmail: () => void; + reset: () => void; +} + +export default function ActionButton({ copyEmail, reset }: ActionButtonProps) { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const isStudents = pathname.includes("students"); + function changeURL() { + const newPath = isStudents ? pathname.replace("students", "mentors") : pathname.replace("mentors", "students"); + reset(); + navigate(newPath); + } + return ( +
+ + {isStudents ? ( + + ) : ( + + )} +
+ ); +} diff --git a/csm_web/frontend/src/components/coord_interface/CheckBox.tsx b/csm_web/frontend/src/components/coord_interface/CheckBox.tsx new file mode 100644 index 000000000..26e1aaba0 --- /dev/null +++ b/csm_web/frontend/src/components/coord_interface/CheckBox.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import styles from "../../css/coord_interface.scss"; + +interface CheckBoxProps { + id: string; + onClick?: (e: React.MouseEvent) => void; +} + +export function CheckBox({ id, onClick: onClick }: CheckBoxProps) { + return ( + +
+ + + + + + + +
+ + ); +} diff --git a/csm_web/frontend/src/components/coord_interface/CoordTable.tsx b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx index 78954edea..2ee0fa7a4 100644 --- a/csm_web/frontend/src/components/coord_interface/CoordTable.tsx +++ b/csm_web/frontend/src/components/coord_interface/CoordTable.tsx @@ -1,62 +1,311 @@ import React, { useEffect, useState } from "react"; import { useLocation, useParams, useNavigate } from "react-router-dom"; import { Mentor, Student, getCoordData } from "../../utils/queries/coord"; -import "../../css/coord_interface.scss"; +import ActionButton from "./ActionButton"; +import { CheckBox } from "./CheckBox"; +import DropBox from "./DropBox"; +import { SearchBar } from "./SearchBar"; +import styles from "../../css/coord_interface.scss"; export default function CoordTable() { const [tableData, setTableData] = useState([]); + const [searchData, setSearch] = useState([]); + const [selectedData, setSelected] = useState([]); + const [allSelected, setAllSelected] = useState(false); const params = useParams(); const courseId = Number(params.id); const { pathname } = useLocation(); const isStudents = pathname.includes("students"); + const [currentFilter, setCurrentFilter] = useState(null); + const sectionSizes = !isStudents + ? [...new Set(tableData.map(item => (item as Mentor).numStudents?.toString()))].sort() + : []; // Unique section sizes for mentors + const familyNames = !isStudents ? [...new Set(tableData.map(item => (item as Mentor).family))].sort() : []; // Unique family names for mentors + // On load useEffect(() => { const fetchData = async () => { - setTableData(await getCoordData(courseId, isStudents)); + const data = await getCoordData(courseId, isStudents); + setTableData(data); + setSearch(data); }; fetchData(); - }, []); - + }, [pathname]); const navigate = useNavigate(); + function reset() { + // Used for chainging url + setTableData([]); + setSearch([]); + setSelected([]); + setAllSelected(false); + const checkbox = document.getElementById("checkcheck") as HTMLInputElement; + checkbox.checked = false; + const searchFilter = document.getElementById("search-filter") as HTMLInputElement; + searchFilter.innerText = ""; + searchFilter.value = ""; + currentFilter?.classList.remove("using-filter"); + } + + // Update function for search and selected data + function update(filteredData: (Mentor | Student)[], filteredSelectData: (Mentor | Student)[]) { + setSearch(filteredData as Mentor[] | Student[]); + setSelected(filteredSelectData as Mentor[] | Student[]); + setAllSelected(false); + const checkbox = document.getElementById("checkcheck") as HTMLInputElement; + checkbox.checked = false; + } + + // Select specific checkbox + function selectCheckbox(id: number) { + const checkbox = document.getElementById(id + "check") as HTMLInputElement; + checkbox.checked = !checkbox.checked; + if (checkbox.checked) { + const selectedRow = searchData.find(row => row.id === id); + if (selectedRow) { + setSelected([...selectedData, selectedRow] as Mentor[] | Student[]); + } + } else { + setSelected(selectedData.filter(row => row.id !== id) as Mentor[] | Student[]); + } + } + + // Toggle for checkboxes + function toggleAllCheckboxes() { + if (allSelected) { + deselectAllCheckboxes(); + } else { + selectAllCheckboxes(); + } + setAllSelected(!allSelected); + } + + // Helper for toggle + function selectAllCheckboxes() { + const checkboxes = document.querySelectorAll("input[type=checkbox]"); + checkboxes.forEach(checkbox => { + (checkbox as HTMLInputElement).checked = true; + }); + setSelected(searchData); + } + // Helper for toggle + function deselectAllCheckboxes() { + const checkboxes = document.querySelectorAll("input[type=checkbox]"); + checkboxes.forEach(checkbox => { + (checkbox as HTMLInputElement).checked = false; + }); + setSelected([]); + } + + // Filter search for table + function filterSearch(event: React.ChangeEvent) { + const search = event.target.value.toLowerCase(); + if (search.length === 0) { + update(tableData, selectedData); + return; + } + const filteredData = tableData.filter(row => { + return row.name.toLowerCase().includes(search) || row.email.toLowerCase().includes(search); + }); + const filteredSelectedData = selectedData.filter(row => { + return row.name.toLowerCase().includes(search) || row.email.toLowerCase().includes(search); + }); + + if (currentFilter != null) { + currentFilter.classList.remove("using-filter"); + } + update(filteredData, filteredSelectedData); + } + + // Filter string + function filterString(event: React.MouseEvent, field: keyof Mentor | keyof Student) { + const filter = (event.target as HTMLButtonElement).innerText; + let filteredData: (Student | Mentor)[] = []; + let filteredSelectedData: (Student | Mentor)[] = []; + + filteredData = tableData.filter(row => { + if (field in row) { + const value = row[field as keyof typeof row]; + if (filter.includes("+")) { + return value != null ? value.toString() >= filter.slice(0, -1) : false; + } + return value != null ? value.toString().includes(filter) : false; + } + return false; + }); + filteredSelectedData = selectedData.filter(row => { + if (field in row) { + const value = row[field as keyof typeof row]; + if (filter.includes("+")) { + return value != null ? value.toString() >= filter.slice(0, -1) : false; + } + return value != null ? value.toString().includes(filter) : false; + } + return false; + }); + + checkFilter(event); + update(filteredData, filteredSelectedData); + } + + // Check filter + function checkFilter(event: React.MouseEvent) { + const mainButton = (event.target as HTMLButtonElement).parentElement?.previousElementSibling as HTMLButtonElement; + if (currentFilter != null && currentFilter != mainButton) { + currentFilter.classList.remove("using-filter"); + } + mainButton.classList.add("using-filter"); + setCurrentFilter(mainButton); + const searchFilter = document.getElementById("search-filter") as HTMLInputElement; + if (searchFilter) { + searchFilter.value = ""; + } + } + + // Reset filters + function resetFilters(event: React.MouseEvent) { + const resetButton = event.target as HTMLButtonElement; + if (currentFilter != null && currentFilter != resetButton) { + return; + } + update(tableData, selectedData); + resetButton.classList.remove("using-filter"); + setCurrentFilter(null); + } + + // Function for Copy Email + function copyEmail() { + const selected: string[] = []; + selectedData.forEach(userSelected => { + selected.push(userSelected["email"]); + }); + + const defaultCopy = document.getElementById("default-copy") as HTMLDivElement; + const successCopy = document.getElementById("success-copy") as HTMLDivElement; + + defaultCopy.classList.add("hidden"); + successCopy.classList.remove("hidden"); + + // reset to default state + setTimeout(() => { + defaultCopy.classList.remove("hidden"); + successCopy.classList.add("hidden"); + }, 2000); + + navigator.clipboard.writeText(selected.join(", ")); + } + + // Debugging Function for seeing selected data + // function getSelectedData() { + // console.log(selectedData); + // return selectedData; + // } + return ( - - {isStudents ? ( - - - - - - - - ) : ( - - - - - - - - )} - {tableData.map(row => - isStudents ? ( - navigate(`/sections/${row.section}`)}> - - - - - - +
+
+ +
+
+ + {isStudents ? ( + ) : ( -
navigate(`/sections/${row.section}`)}> - - - - - + <> + + + + )} + + +
+ {isStudents ?
Students List
:
Mentors List
} + +
+ +
NameEmailMentor NameTimeUnexcused Absenses
NameEmailFamilyTimeSection Size
{row.name}{row.email}{(row as Student).mentorName}{row.dayTime}{(row as Student).numUnexcused}
{row.name}{row.email}{(row as Mentor).family}{row.dayTime}{(row as Mentor).numStudents}
+ + + + + + {isStudents ? ( + <> + + + + + ) : ( + <> + + + + + )} - ) - )} -
NameEmailMentor NameTimeUnexcused AbsensesFamilyTimeSection Size
+ + + {searchData.length === 0 ?
No data found...
: null} + + {searchData.map(row => ( + navigate(`/sections/${row.section}`)} + onClick={() => selectCheckbox(row.id)} + > + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + {isStudents ? ( + <> + {row.name} + {row.email} + {(row as Student).mentorName} + {row.dayTime} + {(row as Student).numUnexcused} + + ) : ( + <> + {row.name} + {row.email} + {(row as Mentor).family} + {row.dayTime} + {(row as Mentor).numStudents} + + )} + + ))} + + + ); } diff --git a/csm_web/frontend/src/components/coord_interface/DropBox.tsx b/csm_web/frontend/src/components/coord_interface/DropBox.tsx new file mode 100644 index 000000000..c67291001 --- /dev/null +++ b/csm_web/frontend/src/components/coord_interface/DropBox.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Mentor, Student } from "../../utils/queries/coord"; + +interface DropBoxProps { + items: Array; + name: string; + field: keyof Mentor | keyof Student; + func: (event: React.MouseEvent, field: keyof Mentor | keyof Student) => void; + reset: (event: React.MouseEvent) => void; +} + +export default function DropBox({ name, items, func, field, reset }: DropBoxProps) { + return ( +
+ +
+ {items.map(item => ( + + ))} +
+
+ ); +} diff --git a/csm_web/frontend/src/components/coord_interface/SearchBar.tsx b/csm_web/frontend/src/components/coord_interface/SearchBar.tsx new file mode 100644 index 000000000..90743a85b --- /dev/null +++ b/csm_web/frontend/src/components/coord_interface/SearchBar.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import SearchIcon from "../../../static/frontend/img/search.svg"; + +interface SearchBarProps { + refObject?: React.RefObject; + onChange?: React.ChangeEventHandler; +} + +export const SearchBar = ({ refObject, onChange }: SearchBarProps) => { + return ( +
+ + +
+ ); +}; diff --git a/csm_web/frontend/src/components/course/Course.tsx b/csm_web/frontend/src/components/course/Course.tsx index e1d20cbc3..7282f1b66 100644 --- a/csm_web/frontend/src/components/course/Course.tsx +++ b/csm_web/frontend/src/components/course/Course.tsx @@ -1,6 +1,6 @@ import { DateTime } from "luxon"; import React, { useState } from "react"; -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { DEFAULT_LONG_LOCALE_OPTIONS } from "../../utils/datetime"; import { useCourseSections } from "../../utils/queries/courses"; import { Course as CourseType } from "../../utils/types"; @@ -154,6 +154,10 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): {userIsCoordinator && (
+ + + + {isStudents ? ( ) : ( )}
diff --git a/csm_web/frontend/src/components/coord_interface/CheckBox.tsx b/csm_web/frontend/src/components/coord_interface/CheckBox.tsx index 26e1aaba0..46890dd7b 100644 --- a/csm_web/frontend/src/components/coord_interface/CheckBox.tsx +++ b/csm_web/frontend/src/components/coord_interface/CheckBox.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styles from "../../css/coord_interface.scss"; +import "../../css/coord_interface.scss"; interface CheckBoxProps { id: string; @@ -8,7 +8,7 @@ interface CheckBoxProps { export function CheckBox({ id, onClick: onClick }: CheckBoxProps) { return ( - +