diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 4a64342f..b8365ddf 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,21 @@ 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", + ) + + @admin.register(Student) class StudentAdmin(BasePermissionModelAdmin): fieldsets = ( diff --git a/csm_web/scheduler/migrations/0033_section_waitlist_capacity_waitlistedstudent.py b/csm_web/scheduler/migrations/0033_section_waitlist_capacity_waitlistedstudent.py new file mode 100644 index 00000000..663f1e34 --- /dev/null +++ b/csm_web/scheduler/migrations/0033_section_waitlist_capacity_waitlistedstudent.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.7 on 2024-09-24 01:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("scheduler", "0032_word_of_the_day"), + ] + + operations = [ + 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)), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="scheduler.course", + ), + ), + ( + "section", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="scheduler.section", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/csm_web/scheduler/migrations/0034_course_max_waitlist.py b/csm_web/scheduler/migrations/0034_course_max_waitlist.py new file mode 100644 index 00000000..74df332d --- /dev/null +++ b/csm_web/scheduler/migrations/0034_course_max_waitlist.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-09-24 08:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("scheduler", "0033_section_waitlist_capacity_waitlistedstudent"), + ] + + operations = [ + migrations.AddField( + model_name="course", + name="max_waitlist", + field=models.SmallIntegerField(default=3), + ), + ] diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 9e367ab6..81cf7f9f 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 = ( @@ -167,10 +169,10 @@ class Course(ValidatingModel): enrollment_start = models.DateTimeField() enrollment_end = models.DateTimeField() permitted_absences = models.PositiveSmallIntegerField() + # max_waitlist = models.SmallIntegerField(default=3) # time limit for 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") @@ -198,6 +200,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) @@ -217,6 +227,21 @@ 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" + ) + active = models.BooleanField( + default=True, help_text="An inactive student is a dropped student." + ) + timestamp = models.DateTimeField(auto_now_add=True) + + class Student(Profile): """ Represents a given "instance" of a student. Every section in which a student enrolls should @@ -260,19 +285,15 @@ def save(self, *args, **kwargs): ): if settings.DJANGO_ENV != settings.DEVELOPMENT: logger.info( - ( - " SO automatically created for student" - " %s in course %s for date %s" - ), + " SO automatically created for student" + " %s in course %s for date %s", self.user.email, course.name, now.date(), ) logger.info( - ( - " Attendance automatically created for student" - " %s in course %s for date %s" - ), + " Attendance automatically created for student" + " %s in course %s for date %s", self.user.email, course.name, now.date(), @@ -331,6 +352,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 ) @@ -343,15 +365,26 @@ class Section(ValidatingModel): ), ) - # @functional.cached_property - # 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 cd31f741..e447c924 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, ) @@ -249,6 +250,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") + + class SectionSerializer(serializers.ModelSerializer): spacetimes = SpacetimeSerializer(many=True) num_students_enrolled = serializers.SerializerMethodField() @@ -309,6 +318,7 @@ class Meta: "user_role", "course_title", "course_restricted", + "waitlist_capacity", ) 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 00000000..4bdbf76a --- /dev/null +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -0,0 +1,18 @@ +# import pytest +# from django.core.exceptions import ValidationError +# from scheduler.factories import ( +# CourseFactory, +# MentorFactory, +# SectionFactory, +# StudentFactory, +# UserFactory, +# ) +# from scheduler.models import Student, User, WaitlistedStudent + +# @pytest.mark.django_db +# def test_add_waitlist(): +# mentor_user, student_user, waitlist_user = UserFactory.create_batch(3) +# course = CourseFactory.create() +# mentor = MentorFactory.create(course=course, user=mentor_user) +# section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=1) +# student = Student.objects.create(user=student_user, course=course, section=section) diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 8c60cd33..8b787ed3 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("waitlist//add/", views.waitlistedStudent.add), + path("waitlist//drop/", views.waitlistedStudent.drop), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py index 55ed65f3..742e99aa 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 .course import CourseViewSet from .export import export_data from .profile import ProfileViewSet diff --git a/csm_web/scheduler/views/profile.py b/csm_web/scheduler/views/profile.py index 383fa723..fc34c5cc 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 47c4ca06..8fa70435 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,141 @@ ) +def add_student(section, user): + """ + 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 + if section.current_student_count >= section.capacity: + 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(), + ) + 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) + + # student_queryset.count() == 0 + student = Student.objects.create( + user=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) + + +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 + section = Section.objects.get(pk=pk) + waitlisted_student = WaitlistedStudent.objects.filter( + active=True, section=section + ).order_by("timestamp") + + # Check if there are waitlisted students + if len(waitlisted_student) == 0: + logger.info( + " No waitlist users for section %s", + log_str(section), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + waitlisted_student = waitlisted_student[0] + + # Adds the student + add_student(waitlisted_student.section, waitlisted_student.user) + + # Removes all waitlists the student that added was a part of + waitlist_set = WaitlistedStudent.objects.filter( + user=waitlisted_student.user, active=True, course=waitlisted_student.course + ) + for waitlist in waitlist_set: + # waitlist.active = False + waitlist.delete() + + logger.info( + " User %s removed from all Waitlists for Course %s", + log_str(waitlisted_student.user), + log_str(waitlisted_student.course), + ) + return Response(status=status.HTTP_201_CREATED) + + class SectionViewSet(*viewset_with("retrieve", "partial_update", "create")): serializer_class = SectionSerializer @@ -605,84 +741,7 @@ def _student_add(self, request, section): """ Adds a student to a section (initiated by a student) """ - 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 - ) - - 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) - - # 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) + return self.add_student(section, request.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 fc3d29c8..4fa1578b 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,8 +63,11 @@ 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()}" + f" Deleted {num_deleted} attendances for user" + f" {log_str(student.user)} in Section {log_str(student.section)} after" + f" {now.date()}" ) + add_from_waitlist(pk=student.section.id) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["get", "put"]) @@ -81,18 +93,21 @@ 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']}" + " Could not record attendance for User" + f" {log_str(request.user)}, used non-existent attendance id" + f" {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)}" + f" Attendance {log_str(attendance)} recorded for" + f" User {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}" + " Could not record attendance for User" + f" {log_str(request.user)}, errors: {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 00000000..61d9896b --- /dev/null +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -0,0 +1,130 @@ +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 ..models import Section, WaitlistedStudent +from .section import add_student +from .utils import logger + + +@api_view(["POST"]) +def add(request, pk=None): + """ + Endpoint: /api/waitlist//add + + POST: Add a new waitlist student to section. Pass in section id. Called by user + - 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. + """ + section = Section.objects.get(pk=pk) + course = section.mentor.course + user = request.user + + # Checks that student is able to enroll in the course + if not user.can_enroll_in_course(course): + log_enroll_result( + False, + user, + section, + reason=( + "User already involved in this course or course is closed for" + " enrollment" + ), + ) + raise PermissionDenied( + "You are either mentoring for this course, already enrolled in a section, " + "or the course is closed for enrollment.", + code=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + # If there is space in the section, attempt to enroll the student directly + if not section.is_section_full: + return add_student(section, user) + + # If the waitlist is full, throw an error + if section.is_waitlist_full: + log_enroll_result(False, user, section, reason="Waitlist is full") + raise PermissionDenied( + "There is no space available in this section.", code=status.HTTP_423_LOCKED + ) + + # Check if the student is already enrolled in the waitlist for this section + waitlist_queryset = WaitlistedStudent.objects.filter( + active=True, section=section, user=user + ) + if waitlist_queryset.count() != 0: + log_enroll_result( + False, + user, + section, + reason="User is already waitlisted in this section", + ) + raise PermissionDenied( + "You are either already waitlisted in this section.", + code=status.HTTP_423_LOCKED, + ) + + # Create the new waitlist student and save + waitlisted_student = WaitlistedStudent.objects.create( + user=user, section=section, course=course + ) + waitlisted_student.save() + + log_enroll_result(True, request.user, section) + return Response(status=status.HTTP_201_CREATED) + + +@api_view(["PATCH"]) +def drop(request, pk=None): + """ + Endpoint: /api/waitlistedstudent//drop + + PATCH: Drop a student off the waitlist. Pass in section ID + - sets to inactive + + """ + user = request.user + section = Section.objects.get(pk=pk) + waitlisted_student = WaitlistedStudent.objects.filter( + user=user, section=section + ).first() + course = waitlisted_student.course + + # Check that the user has permissions to drop this student + is_coordinator = course.is_coordinator(user) + 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, + )