Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial Waitlisting Feature Dev (#506)
Browse files Browse the repository at this point in the history
* added models and serializers for waitlisted student

* worked on api and testing

* added list of waitlisted students

* tested waitlist code, refactored for sections, changed to delete, added functionality for when students drop will auto call waitlist

* convert viewset, remove add from waitlist from views (#498)

* adds file for tests, updates model logic, and removes some model logic from view

* updated waitlist

* waitlist small changes

* completed merge and small fixes and finished testing

* deleted json

* reverted docker to maain

---------

Co-authored-by: Isabella Alpert <[email protected]>
Co-authored-by: Faith Dennis <[email protected]>
Co-authored-by: Faith Dennis <[email protected]>
4 people authored Nov 19, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 21563c3 commit ed65782
Showing 12 changed files with 474 additions and 105 deletions.
16 changes: 16 additions & 0 deletions csm_web/scheduler/admin.py
Original file line number Diff line number Diff line change
@@ -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 = (
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
17 changes: 17 additions & 0 deletions csm_web/scheduler/migrations/0034_course_max_waitlist.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
63 changes: 48 additions & 15 deletions csm_web/scheduler/models.py
Original file line number Diff line number Diff line change
@@ -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(
(
"<SectionOccurrence> SO automatically created for student"
" %s in course %s for date %s"
),
"<SectionOccurrence> SO automatically created for student"
" %s in course %s for date %s",
self.user.email,
course.name,
now.date(),
)
logger.info(
(
"<Attendance> Attendance automatically created for student"
" %s in course %s for date %s"
),
"<Attendance> 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(
10 changes: 10 additions & 0 deletions csm_web/scheduler/serializers.py
Original file line number Diff line number Diff line change
@@ -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",
)


18 changes: 18 additions & 0 deletions csm_web/scheduler/tests/models/test_waitlisted_student.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions csm_web/scheduler/urls.py
Original file line number Diff line number Diff line change
@@ -23,5 +23,7 @@
path("matcher/<int:pk>/mentors/", views.matcher.mentors),
path("matcher/<int:pk>/configure/", views.matcher.configure),
path("matcher/<int:pk>/create/", views.matcher.create),
path("waitlist/<int:pk>/add/", views.waitlistedStudent.add),
path("waitlist/<int:pk>/drop/", views.waitlistedStudent.drop),
path("export/", views.export_data),
]
2 changes: 1 addition & 1 deletion csm_web/scheduler/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from . import matcher
from . import matcher, waitlistedStudent
from .course import CourseViewSet
from .export import export_data
from .profile import ProfileViewSet
8 changes: 6 additions & 2 deletions csm_web/scheduler/views/profile.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
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")):
serializer_class = None
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(),
],
215 changes: 137 additions & 78 deletions csm_web/scheduler/views/section.py
Original file line number Diff line number Diff line change
@@ -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(
"<Enrollment:Failure> 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(
"<Enrollment:Failure> 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(
"<Enrollment:Critical> 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(
"<Enrollment> 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(
"<Enrollment:Success> 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(
"<Enrollment:Success> 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(
"<Waitlist:Skipped> 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(
"<Enrollment:Success> 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(
"<Enrollment:Failure> 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(
"<Enrollment:Failure> 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(
"<Enrollment:Critical> 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(
"<Enrollment> 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(
"<Enrollment:Success> 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(
"<Enrollment:Success> 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):
33 changes: 24 additions & 9 deletions csm_web/scheduler/views/student.py
Original file line number Diff line number Diff line change
@@ -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/<pk>/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"<Drop> User {log_str(request.user)} dropped Section {log_str(student.section)} for Student user {log_str(student.user)}"
"<Drop> 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"<Drop> Deleted {num_deleted} attendances for user {log_str(student.user)} in Section {log_str(student.section)} after {now.date()}"
f"<Drop> 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"<Attendance:Failure> Could not record attendance for User {log_str(request.user)}, used non-existent attendance id {request.data['id']}"
"<Attendance:Failure> 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:Success> Attendance {log_str(attendance)} recorded for User {log_str(request.user)}"
f"<Attendance:Success> Attendance {log_str(attendance)} recorded for"
f" User {log_str(request.user)}"
)
return Response(status=status.HTTP_204_NO_CONTENT)
logger.error(
f"<Attendance:Failure> Could not record attendance for User {log_str(request.user)}, errors: {serializer.errors}"
"<Attendance:Failure> Could not record attendance for User"
f" {log_str(request.user)}, errors: {serializer.errors}"
)
return Response(serializer.errors, status=status.HTTP_422_UNPROCESSABLE_ENTITY)

130 changes: 130 additions & 0 deletions csm_web/scheduler/views/waitlistedStudent.py
Original file line number Diff line number Diff line change
@@ -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/<pk>/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/<pk>/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(
"<Drop> 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(
"<Waitlist:Success> User %s enrolled into Waitlist for Section %s",
user,
section,
)
else:
logger.warning(
"<Waitlist:Failure> User %s not enroll in Waitlist for Section %s: %s",
user,
section,
reason,
)

0 comments on commit ed65782

Please sign in to comment.