From d140eb4ef8bdbe869d4649ba88f3d11e0342dfcf Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 29 Sep 2025 21:29:57 -0700 Subject: [PATCH 01/28] waitlisting backend code: waitlist student model, apis (view, add, and drop), and auto add to section when student is dropped --- csm_web/scheduler/admin.py | 11 + csm_web/scheduler/factories.py | 52 ++- ...urse_student_waitlist_capacity_and_more.py | 81 ++++ csm_web/scheduler/models.py | 106 +++++- csm_web/scheduler/serializers.py | 10 + .../tests/models/test_waitlisted_student.py | 360 ++++++++++++++++++ csm_web/scheduler/urls.py | 3 + csm_web/scheduler/views/__init__.py | 2 +- csm_web/scheduler/views/profile.py | 8 +- csm_web/scheduler/views/section.py | 219 +++++++---- csm_web/scheduler/views/student.py | 47 ++- csm_web/scheduler/views/waitlistedStudent.py | 182 +++++++++ docker-compose.yml | 11 +- 13 files changed, 979 insertions(+), 113 deletions(-) create mode 100644 csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py create mode 100644 csm_web/scheduler/tests/models/test_waitlisted_student.py create mode 100644 csm_web/scheduler/views/waitlistedStudent.py diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 41806e587..367b54d41 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,16 @@ 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", "position") + + @admin.register(Student) class StudentAdmin(BasePermissionModelAdmin): fieldsets = ( diff --git a/csm_web/scheduler/factories.py b/csm_web/scheduler/factories.py index 36dee9d41..44e5551b1 100644 --- a/csm_web/scheduler/factories.py +++ b/csm_web/scheduler/factories.py @@ -23,6 +23,7 @@ Spacetime, Student, User, + WaitlistedStudent, day_to_number, week_bounds, ) @@ -114,6 +115,7 @@ def title(self): BUILDINGS = ("Cory", "Soda", "Kresge", "Moffitt") +DEFAULT_WAITLIST_CAP = 3 class SpacetimeFactory(factory.django.DjangoModelFactory): @@ -197,6 +199,7 @@ class Meta: model = Section capacity = factory.LazyFunction(lambda: random.randint(3, 6)) + max_waitlist_capacity = DEFAULT_WAITLIST_CAP @classmethod def _create(cls, model_class, *args, **kwargs): @@ -255,6 +258,20 @@ def date(self): spacetime = factory.SubFactory(SpacetimeFactory) +class WaitlistedStudentFactory(factory.django.DjangoModelFactory): + class Meta: + model = WaitlistedStudent + + user = factory.SubFactory(UserFactory) + section = factory.SubFactory(SectionFactory) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """Handles uniqueness and assigns position correctly.""" + waitlisted_student, _ = model_class.objects.get_or_create(**kwargs) + return waitlisted_student + + class ResourceFactory(factory.django.DjangoModelFactory): class Meta: model = Resource @@ -456,10 +473,12 @@ def create_demo_account(): user=demo_user, course=Course.objects.get(name=large_course_name) ) - print(""" + print( + """ A demo account has been created with username 'demo_user' and password 'pass' Log in at localhost:8000/admin/ - """) + """ + ) # make demo_user a coord for one more course coord_2_whitelist_course = random.choice( @@ -491,20 +510,24 @@ def create_demo_account(): ) large_course_section.mentor.user = demo_mentor_user - print(""" + print( + """ A demo mentor has been created with username 'demo_mentor' and password 'pass' Log in at localhost:8000/admin/ - """) + """ + ) def confirm_run(): """Display warning message for user to confirm flushing the database.""" - choice = input("""You have requested a flush of the database. + choice = input( + """You have requested a flush of the database. This will DELETE EVERYTHING IN THE DATABASE, and return all tables to an empty state. Are you sure you want to do this? - Type 'yes' to continue, or 'no' to abort: """) + Type 'yes' to continue, or 'no' to abort: """ + ) while choice not in ("yes", "no"): choice = input("Please type 'yes' or 'no' (without the quotes): ") return choice == "yes" @@ -533,6 +556,7 @@ def generate_test_data(preconfirm=False): spacetime_objects = [] section_occurrence_objects = [] student_objects = [] + waitlisted_student_objects = [] print("Creating model instances... ", end="") _create_models_start = time.perf_counter_ns() @@ -575,6 +599,20 @@ def generate_test_data(preconfirm=False): ) student_objects.extend(students) + if len(student_users) >= section.capacity: + waitlisted_users = UserFactory.build_batch( + random.randint(0, section.max_waitlist_capacity) + ) + user_objects.extend(waitlisted_users) + waitlisted_students = [] + for waitlisted_user in waitlisted_users: + waitlisted_students.append( + WaitlistedStudentFactory.build( + section=section, course=course, user=waitlisted_user + ) + ) + waitlisted_student_objects.extend(waitlisted_students) + # courses with many sections/students for ( course_name, @@ -634,6 +672,7 @@ def generate_test_data(preconfirm=False): random.shuffle(spacetime_objects) random.shuffle(section_occurrence_objects) random.shuffle(student_objects) + random.shuffle(waitlisted_student_objects) print("Saving models to database... ", end="") _save_models_start = time.perf_counter_ns() @@ -645,6 +684,7 @@ def generate_test_data(preconfirm=False): Spacetime.objects.bulk_create(spacetime_objects) SectionOccurrence.objects.bulk_create(section_occurrence_objects) Student.objects.bulk_create(student_objects) + WaitlistedStudent.objects.bulk_create(waitlisted_student_objects) print(f"({(time.perf_counter_ns() - _save_models_start)/1e6:.6f} ms)") diff --git a/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py b/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py new file mode 100644 index 000000000..ebd918b11 --- /dev/null +++ b/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 5.1.6 on 2025-09-30 01:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("scheduler", "0033_matcherslot_description"), + ] + + operations = [ + migrations.AddField( + model_name="course", + name="max_waitlist_enroll", + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name="section", + name="max_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)), + ( + "position", + models.PositiveIntegerField( + blank=True, + help_text="Manual position on the waitlist. " + "Lower numbers have higher priority.", + null=True, + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="scheduler.course", + ), + ), + ( + "section", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="waitlist_set", + to="scheduler.section", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["position", "timestamp"], + }, + ), + ] diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index d77185824..699555953 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 = ( @@ -73,6 +75,15 @@ def can_enroll_in_course(self, course, bypass_enrollment_time=False): is_valid_enrollment_time = course.is_open() return is_valid_enrollment_time and not is_associated + def can_enroll_in_waitlist(self, course): + """Determine whether this user is allowed to waitlist in the given course.""" + return ( + self.waitlistedstudent_set.filter( + active=True, section__mentor__course=course + ).count() + < course.max_waitlist_enroll + ) + def is_whitelisted_for(self, course: "Course"): """Determine whether this user is whitelisted for the given course.""" return not course.is_restricted or self.whitelist.filter(pk=course.pk).exists() @@ -170,12 +181,12 @@ class Course(ValidatingModel): enrollment_start = models.DateTimeField() enrollment_end = models.DateTimeField() permitted_absences = models.PositiveSmallIntegerField() - # time limit for wotd submission; + # time limit fdocor 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") + max_waitlist_enroll = models.PositiveSmallIntegerField(default=DEFAULT_WAITLIST_CAP) def __str__(self): return self.name @@ -201,6 +212,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) @@ -220,6 +239,61 @@ 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_set" + ) + active = models.BooleanField( + default=True, help_text="An inactive student is a dropped student." + ) + timestamp = models.DateTimeField(auto_now_add=True) + position = models.PositiveIntegerField( + null=True, + blank=True, + help_text=( + "Manual position on the waitlist. Lower numbers have higher priority." + ), + ) + + class Meta: + ordering = ["position", "timestamp"] + + def save(self, *args, **kwargs): + # manually assigning a position to a student + if self.position is not None: + conflicting_students = ( + WaitlistedStudent.objects.filter( + section=self.section, position__gte=self.position + ) + .exclude(pk=self.pk) + .order_by("position") + ) + # shifting over other student's positions + previous_position = self.position + for student in conflicting_students: + if student.position <= previous_position: + student.position += 1 + previous_position = student.position + student.save() + + super().save(*args, **kwargs) + + # If position is not set, assign it based on timestamp + if self.position is None: + waitlisted_students = WaitlistedStudent.objects.filter(section=self.section) + # assigning a position based on timestamp + if waitlisted_students.count() == 1: + self.position = 1 + else: + self.position = waitlisted_students.count() + WaitlistedStudent.objects.filter(pk=self.pk).update(position=self.position) + + class Student(Profile): """ Represents a given "instance" of a student. Every section in which a student enrolls should @@ -312,7 +386,7 @@ class Mentor(Profile): class Coordinator(Profile): """ - This profile is used to allow coordinators to acess the admin page. + This profile is used to allow coordinators to access the admin page. """ def save(self, *args, **kwargs): @@ -330,6 +404,9 @@ class Meta: class Section(ValidatingModel): # course = models.ForeignKey(Course, on_delete=models.CASCADE) capacity = models.PositiveSmallIntegerField() + max_waitlist_capacity = models.PositiveSmallIntegerField( + default=DEFAULT_WAITLIST_CAP + ) mentor = OneToOneOrNoneField( Mentor, on_delete=models.CASCADE, blank=True, null=True ) @@ -342,15 +419,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.max_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 c2b938c97..59890f157 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", "position") + + 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", + "max_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 000000000..488c792e9 --- /dev/null +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -0,0 +1,360 @@ +import pytest +from scheduler.factories import ( + CoordinatorFactory, + CourseFactory, + MentorFactory, + SectionFactory, + UserFactory, +) +from scheduler.models import Student, WaitlistedStudent + + +@pytest.fixture(name="setup_waitlist") +def fixture_setup_waitlist(db): # pylint: disable=unused-argument + """ + Set up a mentor user, student user, course, and section for waitlist testing + """ + mentor_user, student_user = UserFactory.create_batch(2) + course = CourseFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor) + return mentor_user, student_user, course, section + + +@pytest.mark.django_db +def test_create_waitlisted_student(setup_waitlist): + """ + Given we create a waitlisted student object, + When we call create + It correctly creates a waitlisted student object for the section and user. + """ + mentor_user, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + assert waitlisted_student.user == waitlisted_student_user + assert WaitlistedStudent.objects.count() == 1 + assert ( + WaitlistedStudent.objects.get(user=waitlisted_student_user).user + == waitlisted_student_user + ) + + assert waitlisted_student.course == course + assert waitlisted_student.section == section + assert waitlisted_student.section.mentor.user == mentor_user + assert waitlisted_student.section.mentor.course == course + assert waitlisted_student.section.mentor.section == section + + assert ( + waitlisted_student.section.students.count() == 0 + ) # no students were added to the section + assert waitlisted_student.section.waitlist_set.count() == 1 + + +@pytest.mark.django_db +def test_user_cannot_enroll_in_course(setup_waitlist, client): + """ + Given a student or mentor in the course, + When they attempt to enroll or waitlist for a section, + Then they are denied with an appropriate error. + """ + mentor_user, user, _, section = setup_waitlist + + client.force_login(mentor_user) + response = client.post(f"/api/waitlist/{section.pk}/add/") + + assert response.status_code == 403 + assert ( + response.data["detail"] + == "You are either mentoring for this course, already enrolled in a section, " + "or the course is closed for enrollment." + ) + assert WaitlistedStudent.objects.count() == 0 + + client.force_login(user) + response = client.post( + f"/api/waitlist/{section.pk}/add/" + ) # should auto enroll user + assert WaitlistedStudent.objects.count() == 0 + assert Student.objects.count() == 1 + + client.force_login(user) + response = client.post( + f"/api/waitlist/{section.pk}/add/" + ) # fails because user is in section + assert response.status_code == 403 + assert ( + response.data["detail"] + == "You are either mentoring for this course, already enrolled in a section, " + "or the course is closed for enrollment." + ) + assert WaitlistedStudent.objects.count() == 0 + + +@pytest.mark.django_db +def test_user_can_waitlist_only_once(setup_waitlist, client): + """ + Given a section that is full, + When a user attempts to enroll directly, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, _, section = setup_waitlist + + while not section.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.post(f"/api/waitlist/{section.pk}/add/") + + client.force_login(waitlisted_student_user) + response = client.post( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.count() == 1 + + client.force_login(waitlisted_student_user) + response = client.post(f"/api/waitlist/{section.pk}/add/") + + assert response.status_code == 403 + assert response.data["detail"] == "You are already waitlisted in this section." + assert WaitlistedStudent.objects.count() == 1 + + +@pytest.mark.django_db +def test_waitlist_is_full(setup_waitlist, client): + """ + Given a section where the waitlist is full, + When a user attempts to join the waitlist, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, _, section = setup_waitlist + + while not section.is_waitlist_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.post( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + client.force_login(waitlisted_student_user) + response = client.post(f"/api/waitlist/{section.pk}/add/") + + assert response.status_code == 403 + assert response.data["detail"] == "There is no space available in this section." + assert WaitlistedStudent.objects.count() == 3 + + +@pytest.mark.django_db +def test_user_exceeds_max_waitlists_for_course(setup_waitlist, client): + """ + Given a user who has waitlisted in the maximum number of waitlists allowed for the course, + When they attempt to join another waitlist for the course, + Then they are denied with an appropriate error. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + # Create and fill sections, waitlist until max waitlists achieved + for _ in range(course.max_waitlist_enroll): + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section_test = SectionFactory.create(mentor=mentor) + + while not section_test.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.post(f"/api/waitlist/{section_test.pk}/add/") + + client.force_login(waitlisted_student_user) + response = client.post( + f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" + ) + # Verify max waitlists achieved + assert WaitlistedStudent.objects.count() == course.max_waitlist_enroll + + while not section.is_section_full: + new_student = UserFactory.create_batch(1)[0] + client.force_login(new_student) + response = client.post( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + + # Verify errors when attempting to add another waitlist + client.force_login(waitlisted_student_user) + response = client.post( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) + assert response.status_code == 403 + assert WaitlistedStudent.objects.count() == course.max_waitlist_enroll + + # Check if user is dropped from all waitlists for a course when adding to a course section + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section_test = SectionFactory.create(mentor=mentor) + + client.force_login(waitlisted_student_user) + response = client.post( + f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" + ) + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 0 + ) + + +@pytest.mark.django_db +def test_user_enrolled_from_waitlist_and_dropped_from_others(setup_waitlist, client): + """ + Given a user waitlisted in two sections for a course, + When a student in one of the sections drops, + Then the user is enrolled into that section + and dropped from their other waitlists for the course. + """ + _, waitlisted_student_user, course, section1 = setup_waitlist + + # Set up a second section in the same course + mentor_user = UserFactory.create_batch(1)[0] + mentor = MentorFactory.create(course=course, user=mentor_user) + section2 = SectionFactory.create(mentor=mentor) + + # Add user to both waitlists + for _ in range(section1.capacity): + test_user = UserFactory.create_batch(1)[0] + client.force_login(test_user) + _ = client.post(f"/api/waitlist/{section1.pk}/add/") + + for _ in range(section2.capacity): + test_user = UserFactory.create_batch(1)[0] + client.force_login(test_user) + _ = client.post(f"/api/waitlist/{section2.pk}/add/") + + client.force_login(waitlisted_student_user) + _ = client.post( + f"/api/waitlist/{section1.pk}/add/", content_type="application/json" + ) + _ = client.post( + f"/api/waitlist/{section2.pk}/add/", content_type="application/json" + ) + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 2 + ) + + # Enroll from waitlist + test_student = Student.objects.filter(user=test_user, active=True).first() + client.force_login(test_user) + _ = client.patch(f"/api/students/{test_student.pk}/drop/") + assert ( + WaitlistedStudent.objects.filter( + user=waitlisted_student_user, active=True + ).count() + == 0 + ) + + +@pytest.mark.django_db +def test_user_drops_themselves_successfully(setup_waitlist, client): + """ + Given a user on the waitlist for a section, + When they attempt to drop themselves, + Then the waitlisted_student's active field is set to False, + And the endpoint returns a 204 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + client.force_login(waitlisted_student_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 204 # Unsure why 200 is returned + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is False + + +@pytest.mark.django_db +def test_coordinator_drops_student_successfully(setup_waitlist, client): + """ + Given a coordinator for the course associated with a section, + When they attempt to drop another user from the waitlist, + Then the waitlisted_student's active field is set to False, + And the endpoint returns a 204 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + coordinator_user = UserFactory.create_batch(1)[0] + _ = CoordinatorFactory.create(user=coordinator_user, course=course) + + client.force_login(coordinator_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 204 + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is False + + +@pytest.mark.django_db +def test_user_drops_without_permission(setup_waitlist, client): + """ + Given a user who is not a coordinator, + When they attempt to drop themselves from another section waitlist, + Then a PermissionDenied exception is raised, + And the endpoint returns a 403 status code. + """ + _, waitlisted_student_user, course, section = setup_waitlist + + waitlisted_student = WaitlistedStudent.objects.create( + user=waitlisted_student_user, course=course, section=section + ) + + unauthorized_user = UserFactory.create_batch(1)[0] + client.force_login(unauthorized_user) + response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") + + assert response.status_code == 403 + assert ( + response.data["detail"] + == "You do not have permission to drop this student from the waitlist" + ) + waitlisted_student.refresh_from_db() + assert waitlisted_student.active is True + + +@pytest.mark.django_db +def test_user_drops_from_nonexistent_waitlisted_student(setup_waitlist, client): + """ + Given a user on the waitlist for a non-existent section, + When they attempt to drop themselves, + Then the endpoint returns a 404 status code. + """ + _, _, course, _ = setup_waitlist + coordinator_user = UserFactory.create_batch(1)[0] + _ = CoordinatorFactory.create(user=coordinator_user, course=course) + + client.force_login(coordinator_user) + response = client.patch("/api/waitlist/999/drop/") + + assert response.data["detail"] == "Student is not on the waitlist for this section" + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_positions_update_properly(): + """ + Given a waitlist with existing students, + When a coordinator adds a student on the waitlist at a certain position, + The waitlist students have the correct order. + """ + return True diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 8c60cd334..4d6f9a354 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -23,5 +23,8 @@ 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("waitlist//", views.waitlistedStudent.view), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py index 55ed65f31..742e99aae 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 383fa7238..fc34c5cc9 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 47c4ca060..71b56a00a 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,144 @@ ) +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.is_section_full: + 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 = Student.objects.create( + user=user, section=section, course=section.mentor.course + ) + + # Removes all waitlists the student that added was a part of + waitlist_set = WaitlistedStudent.objects.filter( + user=user, active=True, course=student.course + ) + + for waitlist in waitlist_set: + waitlist.active = False + # waitlist.delete() + waitlist.save() + + 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, searches for position + # (manually inserted student) then timestamp + section = Section.objects.get(pk=pk) + waitlisted_student = ( + WaitlistedStudent.objects.filter(active=True, section=section) + .order_by("position", "timestamp") + .first() + ) + + # Check if there are waitlisted students + if not waitlisted_student: + logger.info( + " No waitlist users for section %s", + log_str(section), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + # Adds the student + add_student(waitlisted_student.section, waitlisted_student.user) + 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 @@ -599,90 +738,14 @@ class RestrictedAction: ) section.save() + # expand waitlist capacity return Response(status=status.HTTP_200_OK) 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 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 fc3d29c8c..bffbba906 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,12 +63,22 @@ 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()}" + " Deleted %s attendances for user %s in Section %s after %s", + num_deleted, + log_str(student.user), + log_str(student.section), + now.date(), ) + add_from_waitlist(pk=student.section.id) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["get", "put"]) def attendances(self, request, pk=None): + """ + GET or PUT: /api/students//attendances + + Endpoint to get or edit a student's attendance + """ student = get_object_or_error(self.get_queryset(), pk=pk) if request.method == "GET": return Response( @@ -80,19 +99,25 @@ 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']}" + logger.info( + " Could not record attendance for user" + "%s used non-existent attendance id %s", + log_str(request.user), + 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)}" + " Attendance %s recorded for user %s", + log_str(attendance), + 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}" + logger.info( + " Could not record attendance for user %s errors: %s", + log_str(request.user), + 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 000000000..c92558338 --- /dev/null +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -0,0 +1,182 @@ +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.response import Response +from scheduler.serializers import WaitlistedStudentSerializer +from scheduler.views.utils import get_object_or_error + +from ..models import Section, Student, WaitlistedStudent +from .section import add_student +from .utils import logger + + +@api_view(["GET"]) +def view(request, pk=None): + """ + Endpoint: /api/waitlist/ + pk = section id + + GET: View all students on the waitlist for a section + """ + section = get_object_or_error(Section.objects, pk=pk) + is_mentor = request.user == section.mentor.user + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() + ) + if not is_mentor and not is_coord: + raise PermissionDenied("You do not have permission to view this waitlist") + + waitlist_queryset = WaitlistedStudent.objects.filter(active=True, section=section) + return Response(WaitlistedStudentSerializer(waitlist_queryset, many=True).data) + + +# CURRENT ISSUES: 61a and eecs16b don't allow adding to waitlist? may not be an issue +@api_view(["POST"]) +def add(request, pk=None): + """ + Endpoint: /api/waitlist//add + pk= section id + + 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 = get_object_or_error(Section.objects, pk=pk) + course = section.mentor.course + user = request.user + student = user + + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() + ) + + if not is_coord: # check if it's a student + # Checks that student is able to enroll in the course + if not student.can_enroll_in_course(course): + log_enroll_result( + False, + student, + 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.", + ) + if is_coord: + data = request.data + email = data.get("email") + if not email: # singular student for now -- may need to adapt to a list + return Response( + {"error": "Must specify email of student to enroll"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + student_queryset = Student.objects.filter( + course=section.mentor.course, user__email=email + ) + + student = student_queryset.first().user + print(student_queryset.count()) + # user is either a coord or a student + # If there is space in the section, attempt to enroll the student directly + if not section.is_section_full: + return add_student(section, student) + + # If the waitlist is full, throw an error + if section.is_waitlist_full: + log_enroll_result(False, student, section, reason="Waitlist is full") + raise PermissionDenied("There is no space available in this section.") + + # If user has waitlisted in the max number of waitlists allowed for the course + if not student.can_enroll_in_waitlist(course): + log_enroll_result( + False, + student, + section, + reason="User has waitlisted in max amount of waitlists for the course", + ) + raise PermissionDenied( + "You are waitlisted in the max amount of waitlists for this course." + ) + + # Check if the student is already enrolled in the waitlist for this section + waitlist_queryset = WaitlistedStudent.objects.filter( + active=True, section=section, user=student + ) + if waitlist_queryset.count() != 0: + log_enroll_result( + False, + student, + section, + reason="User is already waitlisted in this section", + ) + raise PermissionDenied("You are already waitlisted in this section.") + + # Check if the waitlist student has a position (only occurs when manually inserting a student) + specified_position = request.data.get("position", None) + + # Create the new waitlist student and save + waitlisted_student = WaitlistedStudent.objects.create( + user=student, section=section, course=course, position=specified_position + ) + 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/waitlist//drop + pk= section id + + PATCH: Drop a student off the waitlist. Pass in waitlisted student ID + - sets to inactive. Called by user or coordinator. + + """ + user = request.user + waitlisted_student = WaitlistedStudent.objects.filter(pk=pk).first() + if waitlisted_student is None: + raise NotFound("Student is not on the waitlist for this section") + section = waitlisted_student.section + course = section.mentor.course + is_coordinator = course.coordinator_set.filter(user=user).exists() + + # Check that the user has permissions to drop this student + 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, + ) diff --git a/docker-compose.yml b/docker-compose.yml index eb0966c81..12a6b58c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +# version: '3.8' + services: postgres: image: postgres:14 @@ -10,6 +12,7 @@ services: POSTGRES_DB: csm_web_dev PGUSER: postgres POSTGRES_HOST_AUTH_METHOD: trust + node: build: context: . @@ -33,8 +36,8 @@ services: source: ./package-lock.json target: /opt/csm_web/package-lock.json read_only: true - # prevent node modules from being overwritten on host - - notused:/opt/csm_web/app/node_modules + command: sh -c "npm install && npm start" + django: tty: true build: @@ -63,7 +66,3 @@ services: networks: default: name: csm_web_default - -# volume to exclude files from being mounted -volumes: - notused: From fe784c646af0e87cb6cf6dd9e5d88887212d0ee0 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 29 Sep 2025 21:39:47 -0700 Subject: [PATCH 02/28] reverted docker compose --- docker-compose.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 12a6b58c2..eb0966c81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -# version: '3.8' - services: postgres: image: postgres:14 @@ -12,7 +10,6 @@ services: POSTGRES_DB: csm_web_dev PGUSER: postgres POSTGRES_HOST_AUTH_METHOD: trust - node: build: context: . @@ -36,8 +33,8 @@ services: source: ./package-lock.json target: /opt/csm_web/package-lock.json read_only: true - command: sh -c "npm install && npm start" - + # prevent node modules from being overwritten on host + - notused:/opt/csm_web/app/node_modules django: tty: true build: @@ -66,3 +63,7 @@ services: networks: default: name: csm_web_default + +# volume to exclude files from being mounted +volumes: + notused: From af5d7ed1c1aec5dc3e56ca5bb04af9a7d1be3c2e Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 17:56:01 -0700 Subject: [PATCH 03/28] added a count waitlist method --- csm_web/scheduler/views/waitlistedStudent.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index c92558338..e04fd7f0d 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -180,3 +180,13 @@ def log_enroll_result(success, user, section, reason=None): section, reason, ) + + +@api_view(["GET"]) +def count_waitist(request, pk=None): + """ + Endpoint: /api/waitlist//count_waitlist + pk= section id + """ + section = get_object_or_error(Section.objects, pk=pk) + return Response(section.current_waitlist_count()) From 4affe39da7ad3f3f00d3833ff0fd7fa813bc19cc Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 18:38:33 -0700 Subject: [PATCH 04/28] displaying active feature for waitlisted database in admin site --- csm_web/scheduler/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 367b54d41..2faa63957 100644 --- a/csm_web/scheduler/admin.py +++ b/csm_web/scheduler/admin.py @@ -247,7 +247,7 @@ class WaitlistedStudentAdmin(BasePermissionModelAdmin): "section", "course", ) - list_display = ("id", "user", "section", "course", "position") + list_display = ("id", "user", "section", "course", "position", "active") @admin.register(Student) From 260cfdc3b4ea266abf366314ec4043f0f254292e Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 18:41:40 -0700 Subject: [PATCH 05/28] showing only active students when we click on particular section from sections db on admin site --- csm_web/scheduler/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 2faa63957..d36ee1bef 100644 --- a/csm_web/scheduler/admin.py +++ b/csm_web/scheduler/admin.py @@ -525,6 +525,7 @@ def get_students(self, obj): "admin:scheduler_student_change", ) for student in obj.section.students.all() + if student.active ) return format_html("".join(student_links)) From 481626c461b0ef55cbb3a3e88ef90a1bfe3a44b4 Mon Sep 17 00:00:00 2001 From: Eileen Hwang Date: Mon, 27 Oct 2025 17:35:38 -0700 Subject: [PATCH 06/28] update sections --- .../frontend/src/utils/queries/sections.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index 075af89ff..af53cbb34 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -65,6 +65,48 @@ export const useSectionStudents = (id: number): UseQueryResult => { + const queryResult = useQuery( + ["sections", id, "waitlisted-students"], + async () => { + if (isNaN(id)) { + throw new PermissionError("Invalid section id"); + } + + // Preferred: filter via query param if backend supports it. + const resp1 = await fetchNormalized(`/sections/${id}/students?status=waitlisted`); + if (resp1.ok) { + const students = await resp1.json(); + return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } + + // Fallback: dedicated waitlist endpoint. + if (resp1.status === 404) { + const resp2 = await fetchNormalized(`/sections/${id}/waitlisted`); + if (resp2.ok) { + const students = await resp2.json(); + return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } + handlePermissionsError(resp2.status); + throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + } + + handlePermissionsError(resp1.status); + throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + }, + { retry: handleRetry } + ); + + handleError(queryResult); + return queryResult; +}; + /** * Hook to get the attendances for a section. */ From 1dc9477b555c5af3e40a0069c12fca5c242ddecc Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 17:56:01 -0700 Subject: [PATCH 07/28] added a count waitlist method --- csm_web/scheduler/views/waitlistedStudent.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index c92558338..e04fd7f0d 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -180,3 +180,13 @@ def log_enroll_result(success, user, section, reason=None): section, reason, ) + + +@api_view(["GET"]) +def count_waitist(request, pk=None): + """ + Endpoint: /api/waitlist//count_waitlist + pk= section id + """ + section = get_object_or_error(Section.objects, pk=pk) + return Response(section.current_waitlist_count()) From 6a0b7085745de739815ab25f0f2a659da37d8807 Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 18:38:33 -0700 Subject: [PATCH 08/28] displaying active feature for waitlisted database in admin site --- csm_web/scheduler/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 367b54d41..2faa63957 100644 --- a/csm_web/scheduler/admin.py +++ b/csm_web/scheduler/admin.py @@ -247,7 +247,7 @@ class WaitlistedStudentAdmin(BasePermissionModelAdmin): "section", "course", ) - list_display = ("id", "user", "section", "course", "position") + list_display = ("id", "user", "section", "course", "position", "active") @admin.register(Student) From bbaca01015bddd8a1268ed1e9f25c28ce0284323 Mon Sep 17 00:00:00 2001 From: rajoshi Date: Mon, 20 Oct 2025 18:41:40 -0700 Subject: [PATCH 09/28] showing only active students when we click on particular section from sections db on admin site --- csm_web/scheduler/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py index 2faa63957..d36ee1bef 100644 --- a/csm_web/scheduler/admin.py +++ b/csm_web/scheduler/admin.py @@ -525,6 +525,7 @@ def get_students(self, obj): "admin:scheduler_student_change", ) for student in obj.section.students.all() + if student.active ) return format_html("".join(student_links)) From 97cf4a96ac47417a115f1199528ff04397d15368 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Mon, 27 Oct 2025 17:54:07 -0700 Subject: [PATCH 10/28] add max_waitlist_capacity to section partial_update --- csm_web/scheduler/views/section.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 71b56a00a..0f39fec47 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -317,6 +317,7 @@ def partial_update(self, request, pk=None): data={ "capacity": request.data.get("capacity"), "description": request.data.get("description"), + "max_waitlist_capacity": request.data.get("max_waitlist_capacity"), }, partial=True, ) From 61fb87c4abea8c7b64aa29e62a46539f2e857b60 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Mon, 27 Oct 2025 17:54:07 -0700 Subject: [PATCH 11/28] add max_waitlist_capacity to section partial_update --- csm_web/scheduler/factories.py | 4 ++-- .../0034_course_student_waitlist_capacity_and_more.py | 2 +- csm_web/scheduler/models.py | 6 ++---- csm_web/scheduler/serializers.py | 2 +- csm_web/scheduler/views/section.py | 2 +- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/csm_web/scheduler/factories.py b/csm_web/scheduler/factories.py index 44e5551b1..f11604381 100644 --- a/csm_web/scheduler/factories.py +++ b/csm_web/scheduler/factories.py @@ -199,7 +199,7 @@ class Meta: model = Section capacity = factory.LazyFunction(lambda: random.randint(3, 6)) - max_waitlist_capacity = DEFAULT_WAITLIST_CAP + waitlist_capacity = DEFAULT_WAITLIST_CAP @classmethod def _create(cls, model_class, *args, **kwargs): @@ -601,7 +601,7 @@ def generate_test_data(preconfirm=False): if len(student_users) >= section.capacity: waitlisted_users = UserFactory.build_batch( - random.randint(0, section.max_waitlist_capacity) + random.randint(0, section.waitlist_capacity) ) user_objects.extend(waitlisted_users) waitlisted_students = [] diff --git a/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py b/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py index ebd918b11..f4b4145a8 100644 --- a/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py +++ b/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name="section", - name="max_waitlist_capacity", + name="waitlist_capacity", field=models.PositiveSmallIntegerField(default=3), ), migrations.CreateModel( diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 699555953..f1ed48417 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -404,9 +404,7 @@ class Meta: class Section(ValidatingModel): # course = models.ForeignKey(Course, on_delete=models.CASCADE) capacity = models.PositiveSmallIntegerField() - max_waitlist_capacity = models.PositiveSmallIntegerField( - default=DEFAULT_WAITLIST_CAP - ) + waitlist_capacity = models.PositiveSmallIntegerField(default=DEFAULT_WAITLIST_CAP) mentor = OneToOneOrNoneField( Mentor, on_delete=models.CASCADE, blank=True, null=True ) @@ -432,7 +430,7 @@ def current_waitlist_count(self): @property def is_waitlist_full(self): """Returns whether waitlist is open""" - return self.current_waitlist_count >= self.max_waitlist_capacity + return self.current_waitlist_count >= self.waitlist_capacity @property def is_section_full(self): diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index 59890f157..5441b8141 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -318,7 +318,7 @@ class Meta: "user_role", "course_title", "course_restricted", - "max_waitlist_capacity", + "waitlist_capacity", ) diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 0f39fec47..541e55a69 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -317,7 +317,7 @@ def partial_update(self, request, pk=None): data={ "capacity": request.data.get("capacity"), "description": request.data.get("description"), - "max_waitlist_capacity": request.data.get("max_waitlist_capacity"), + "waitlist_capacity": request.data.get("waitlist_capacity"), }, partial=True, ) From 9a20d84d9fbeb972b0d215a7359dc7316cee7d56 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Mon, 27 Oct 2025 18:30:01 -0700 Subject: [PATCH 12/28] add waitlistCapacity to MetaEditModal and MentorSectionInfo --- .../src/components/section/MentorSection.tsx | 5 ++++- .../components/section/MentorSectionInfo.tsx | 8 ++++++- .../src/components/section/MetaEditModal.tsx | 22 ++++++++++++++++--- .../frontend/src/utils/queries/sections.tsx | 1 + 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/csm_web/frontend/src/components/section/MentorSection.tsx b/csm_web/frontend/src/components/section/MentorSection.tsx index e0ce4387d..0506c200d 100644 --- a/csm_web/frontend/src/components/section/MentorSection.tsx +++ b/csm_web/frontend/src/components/section/MentorSection.tsx @@ -17,6 +17,7 @@ interface MentorSectionProps { capacity: number; description: string; courseRestricted: boolean; + waitlistCapacity: number; } export default function MentorSection({ @@ -28,7 +29,8 @@ export default function MentorSection({ capacity, description, userRole, - mentor + mentor, + waitlistCapacity }: MentorSectionProps) { return ( } /> diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index e2e76b309..7e7813175 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -30,6 +30,7 @@ interface MentorSectionInfoProps { description: string; id: number; courseRestricted: boolean; + waitlistCapacity: number; } export default function MentorSectionInfo({ @@ -39,7 +40,8 @@ export default function MentorSectionInfo({ capacity, id: sectionId, description, - courseRestricted + courseRestricted, + waitlistCapacity }: MentorSectionInfoProps) { const { data: students, isSuccess: studentsLoaded, isError: studentsLoadError } = useSectionStudents(sectionId); @@ -218,6 +220,7 @@ export default function MentorSectionInfo({ closeModal={closeModal} capacity={capacity} description={description} + waitlistCapacity={waitlistCapacity} /> )} @@ -228,6 +231,9 @@ export default function MentorSectionInfo({

Description: {description}

+

+ Waitlist Capacity: {waitlistCapacity} +

diff --git a/csm_web/frontend/src/components/section/MetaEditModal.tsx b/csm_web/frontend/src/components/section/MetaEditModal.tsx index 90b8dd398..0657e9b84 100644 --- a/csm_web/frontend/src/components/section/MetaEditModal.tsx +++ b/csm_web/frontend/src/components/section/MetaEditModal.tsx @@ -10,16 +10,18 @@ interface MetaEditModalProps { closeModal: () => void; capacity: number; description: string; + waitlistCapacity: number; } export default function MetaEditModal({ closeModal, sectionId, capacity, - description + description, + waitlistCapacity }: MetaEditModalProps): React.ReactElement { // use existing capacity and description as initial values - const [formState, setFormState] = useState({ capacity: capacity, description: description }); + const [formState, setFormState] = useState({ capacity, description, waitlistCapacity }); const [validationText, setValidationText] = useState(""); const sectionUpdateMutation = useSectionUpdateMutation(sectionId); @@ -31,7 +33,7 @@ export default function MetaEditModal({ }); const handleChange = ({ target: { name, value } }: React.ChangeEvent) => { - if (name === "capacity") { + if (name === "capacity" || name === "waitlist-capacity") { setFormState(prevFormState => ({ ...prevFormState, [name]: parseInt(value) })); } else { setFormState(prevFormState => ({ ...prevFormState, [name]: value })); @@ -93,6 +95,20 @@ export default function MetaEditModal({ onChange={handleChange} /> +
{validationText !== "" && (
diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index af53cbb34..0e62eb100 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -512,6 +512,7 @@ export const useSectionCreateMutation = (): UseMutationResult Date: Mon, 27 Oct 2025 18:32:16 -0700 Subject: [PATCH 13/28] edit waitlistCapacity on frontend (#527) * update sections * added a count waitlist method * displaying active feature for waitlisted database in admin site * showing only active students when we click on particular section from sections db on admin site * add waitlistCapacity to MetaEditModal and MentorSectionInfo --------- Co-authored-by: Eileen Hwang Co-authored-by: rajoshi --- .../src/components/section/MentorSection.tsx | 5 ++- .../components/section/MentorSectionInfo.tsx | 8 +++- .../src/components/section/MetaEditModal.tsx | 22 ++++++++-- .../frontend/src/utils/queries/sections.tsx | 43 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/csm_web/frontend/src/components/section/MentorSection.tsx b/csm_web/frontend/src/components/section/MentorSection.tsx index e0ce4387d..0506c200d 100644 --- a/csm_web/frontend/src/components/section/MentorSection.tsx +++ b/csm_web/frontend/src/components/section/MentorSection.tsx @@ -17,6 +17,7 @@ interface MentorSectionProps { capacity: number; description: string; courseRestricted: boolean; + waitlistCapacity: number; } export default function MentorSection({ @@ -28,7 +29,8 @@ export default function MentorSection({ capacity, description, userRole, - mentor + mentor, + waitlistCapacity }: MentorSectionProps) { return ( } /> diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index e2e76b309..7e7813175 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -30,6 +30,7 @@ interface MentorSectionInfoProps { description: string; id: number; courseRestricted: boolean; + waitlistCapacity: number; } export default function MentorSectionInfo({ @@ -39,7 +40,8 @@ export default function MentorSectionInfo({ capacity, id: sectionId, description, - courseRestricted + courseRestricted, + waitlistCapacity }: MentorSectionInfoProps) { const { data: students, isSuccess: studentsLoaded, isError: studentsLoadError } = useSectionStudents(sectionId); @@ -218,6 +220,7 @@ export default function MentorSectionInfo({ closeModal={closeModal} capacity={capacity} description={description} + waitlistCapacity={waitlistCapacity} /> )} @@ -228,6 +231,9 @@ export default function MentorSectionInfo({

Description: {description}

+

+ Waitlist Capacity: {waitlistCapacity} +

diff --git a/csm_web/frontend/src/components/section/MetaEditModal.tsx b/csm_web/frontend/src/components/section/MetaEditModal.tsx index 90b8dd398..0657e9b84 100644 --- a/csm_web/frontend/src/components/section/MetaEditModal.tsx +++ b/csm_web/frontend/src/components/section/MetaEditModal.tsx @@ -10,16 +10,18 @@ interface MetaEditModalProps { closeModal: () => void; capacity: number; description: string; + waitlistCapacity: number; } export default function MetaEditModal({ closeModal, sectionId, capacity, - description + description, + waitlistCapacity }: MetaEditModalProps): React.ReactElement { // use existing capacity and description as initial values - const [formState, setFormState] = useState({ capacity: capacity, description: description }); + const [formState, setFormState] = useState({ capacity, description, waitlistCapacity }); const [validationText, setValidationText] = useState(""); const sectionUpdateMutation = useSectionUpdateMutation(sectionId); @@ -31,7 +33,7 @@ export default function MetaEditModal({ }); const handleChange = ({ target: { name, value } }: React.ChangeEvent) => { - if (name === "capacity") { + if (name === "capacity" || name === "waitlist-capacity") { setFormState(prevFormState => ({ ...prevFormState, [name]: parseInt(value) })); } else { setFormState(prevFormState => ({ ...prevFormState, [name]: value })); @@ -93,6 +95,20 @@ export default function MetaEditModal({ onChange={handleChange} /> +
{validationText !== "" && (
diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index 075af89ff..0e62eb100 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -65,6 +65,48 @@ export const useSectionStudents = (id: number): UseQueryResult => { + const queryResult = useQuery( + ["sections", id, "waitlisted-students"], + async () => { + if (isNaN(id)) { + throw new PermissionError("Invalid section id"); + } + + // Preferred: filter via query param if backend supports it. + const resp1 = await fetchNormalized(`/sections/${id}/students?status=waitlisted`); + if (resp1.ok) { + const students = await resp1.json(); + return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } + + // Fallback: dedicated waitlist endpoint. + if (resp1.status === 404) { + const resp2 = await fetchNormalized(`/sections/${id}/waitlisted`); + if (resp2.ok) { + const students = await resp2.json(); + return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } + handlePermissionsError(resp2.status); + throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + } + + handlePermissionsError(resp1.status); + throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + }, + { retry: handleRetry } + ); + + handleError(queryResult); + return queryResult; +}; + /** * Hook to get the attendances for a section. */ @@ -470,6 +512,7 @@ export const useSectionCreateMutation = (): UseMutationResult Date: Mon, 27 Oct 2025 19:05:20 -0700 Subject: [PATCH 14/28] add numWaitlistedStudnets to section serializer (#528) --- csm_web/scheduler/serializers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index 5441b8141..d66263f8a 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -267,6 +267,7 @@ class SectionSerializer(serializers.ModelSerializer): user_role = serializers.SerializerMethodField() associated_profile_id = serializers.SerializerMethodField() course_restricted = serializers.BooleanField(source="mentor.course.is_restricted") + num_students_waitlisted = serializers.SerializerMethodField() def get_num_students_enrolled(self, obj): """Retrieve the number of students enrolled in the section""" @@ -276,6 +277,14 @@ def get_num_students_enrolled(self, obj): else obj.current_student_count ) + def get_num_students_waitlisted(self, obj): + """Retrieve the number of students waitlisted for the section""" + return ( + obj.num_waitlisted_annotation + if hasattr(obj, "num_waitlisted_annotation") + else obj.current_waitlist_count + ) + def user_associated_profile(self, obj): """Retrieve the user profile associated with the section""" user = self.context.get("request") and self.context.get("request").user @@ -319,6 +328,7 @@ class Meta: "course_title", "course_restricted", "waitlist_capacity", + "num_students_waitlisted", ) From 2362aae430cbd8040b5c8922978d54fff7bff12e Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 27 Oct 2025 19:05:20 -0700 Subject: [PATCH 15/28] add numWaitlistedStudnets to section serializer (#528) --- .../frontend/src/components/course/SectionCard.tsx | 13 +++++++++++-- csm_web/frontend/src/utils/types.tsx | 2 ++ csm_web/frontend/static/frontend/img/waitlist.svg | 6 ++++++ csm_web/scheduler/serializers.py | 10 ++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 csm_web/frontend/static/frontend/img/waitlist.svg diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx index e7dfc7c20..8f43424cc 100644 --- a/csm_web/frontend/src/components/course/SectionCard.tsx +++ b/csm_web/frontend/src/components/course/SectionCard.tsx @@ -11,6 +11,7 @@ import ClockIcon from "../../../static/frontend/img/clock.svg"; import GroupIcon from "../../../static/frontend/img/group.svg"; import LocationIcon from "../../../static/frontend/img/location.svg"; import UserIcon from "../../../static/frontend/img/user.svg"; +import WaitlistIcon from "../../../static/frontend/img/waitlist.svg"; import XCircle from "../../../static/frontend/img/x_circle.svg"; interface SectionCardProps { @@ -22,6 +23,8 @@ interface SectionCardProps { description: string; userIsCoordinator: boolean; courseOpen: boolean; + numStudentsWaitlisted: number; + waitlistCapacity: number; } export const SectionCard = ({ @@ -32,7 +35,9 @@ export const SectionCard = ({ capacity, description, userIsCoordinator, - courseOpen + courseOpen, + numStudentsWaitlisted, + waitlistCapacity }: SectionCardProps): React.ReactElement => { /** * Mutation to enroll a student in the section. @@ -171,7 +176,11 @@ export const SectionCard = ({ {mentor.name}

- {`${numStudentsEnrolled}/${capacity}`} + {`Enrolled: ${numStudentsEnrolled}/${capacity}`} +

+

+ {" "} + {`Waitlisted: ${numStudentsWaitlisted}/${waitlistCapacity}`}

{userIsCoordinator ? ( diff --git a/csm_web/frontend/src/utils/types.tsx b/csm_web/frontend/src/utils/types.tsx index 551fccf7c..297d9bf76 100644 --- a/csm_web/frontend/src/utils/types.tsx +++ b/csm_web/frontend/src/utils/types.tsx @@ -57,6 +57,8 @@ export interface Section { userRole: Role; courseTitle: string; courseRestricted: boolean; + waitlistCapacity: number; + numStudentsWaitlisted: number; } export interface Mentor { diff --git a/csm_web/frontend/static/frontend/img/waitlist.svg b/csm_web/frontend/static/frontend/img/waitlist.svg new file mode 100644 index 000000000..a718dd853 --- /dev/null +++ b/csm_web/frontend/static/frontend/img/waitlist.svg @@ -0,0 +1,6 @@ + + + hourglass-line + + + diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index 5441b8141..d66263f8a 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -267,6 +267,7 @@ class SectionSerializer(serializers.ModelSerializer): user_role = serializers.SerializerMethodField() associated_profile_id = serializers.SerializerMethodField() course_restricted = serializers.BooleanField(source="mentor.course.is_restricted") + num_students_waitlisted = serializers.SerializerMethodField() def get_num_students_enrolled(self, obj): """Retrieve the number of students enrolled in the section""" @@ -276,6 +277,14 @@ def get_num_students_enrolled(self, obj): else obj.current_student_count ) + def get_num_students_waitlisted(self, obj): + """Retrieve the number of students waitlisted for the section""" + return ( + obj.num_waitlisted_annotation + if hasattr(obj, "num_waitlisted_annotation") + else obj.current_waitlist_count + ) + def user_associated_profile(self, obj): """Retrieve the user profile associated with the section""" user = self.context.get("request") and self.context.get("request").user @@ -319,6 +328,7 @@ class Meta: "course_title", "course_restricted", "waitlist_capacity", + "num_students_waitlisted", ) From bb3707a06f5b86d3a03dd859ec9772c38b5b3db5 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Mon, 3 Nov 2025 18:05:09 -0800 Subject: [PATCH 16/28] allow students to join waitlist for sections --- csm_web/frontend/src/components/course/Course.tsx | 5 ++++- csm_web/frontend/src/components/course/SectionCard.tsx | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/csm_web/frontend/src/components/course/Course.tsx b/csm_web/frontend/src/components/course/Course.tsx index e1d20cbc3..4c5e8e4d9 100644 --- a/csm_web/frontend/src/components/course/Course.tsx +++ b/csm_web/frontend/src/components/course/Course.tsx @@ -116,7 +116,10 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): let currDaySections = sections && sections[currDayGroup]; if (currDaySections && !showUnavailable) { - currDaySections = currDaySections.filter(({ numStudentsEnrolled, capacity }) => numStudentsEnrolled < capacity); + currDaySections = currDaySections.filter( + ({ numStudentsEnrolled, capacity, numStudentsWaitlisted, waitlistCapacity }) => + numStudentsEnrolled < capacity || numStudentsWaitlisted < waitlistCapacity + ); } const enrollmentDate = diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx index 8f43424cc..2f65f8b34 100644 --- a/csm_web/frontend/src/components/course/SectionCard.tsx +++ b/csm_web/frontend/src/components/course/SectionCard.tsx @@ -119,7 +119,8 @@ export const SectionCard = ({ const iconWidth = "1.3em"; const iconHeight = "1.3em"; - const isFull = numStudentsEnrolled >= capacity; + const isFull = numStudentsEnrolled >= capacity && numStudentsWaitlisted >= waitlistCapacity; + const isEnrolledFull = numStudentsEnrolled >= capacity; if (!showModal && enrollmentSuccessful) { // redirect to the section page if the user was successfully enrolled in the section return ; @@ -193,7 +194,7 @@ export const SectionCard = ({ disabled={!courseOpen || isFull} onClick={isFull ? undefined : enroll} > - ENROLL + {isEnrolledFull ? "JOIN WAITLIST" : "ENROLL"} )} From 489118cdab4cbc5d81c1d980f2c9dbb3f51dba74 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 3 Nov 2025 18:25:03 -0800 Subject: [PATCH 17/28] add view waitlist frontend and fix backend endpoint add --- .../section/CoordinatorAddStudentModal.tsx | 11 +- .../components/section/MentorSectionInfo.tsx | 180 +++++++++++------- .../frontend/src/utils/queries/sections.tsx | 28 +-- csm_web/scheduler/views/section.py | 4 +- csm_web/scheduler/views/waitlistedStudent.py | 132 ++++++++++--- 5 files changed, 241 insertions(+), 114 deletions(-) diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx index ff2f5a5aa..a2fdf2159 100644 --- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx +++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx @@ -22,6 +22,7 @@ enum CoordModalStates { interface CoordinatorAddStudentModalProps { closeModal: (arg0?: boolean) => void; sectionId: number; + title: string; } interface RequestType { @@ -50,10 +51,14 @@ interface ActionType { export function CoordinatorAddStudentModal({ closeModal, - sectionId + sectionId, + title }: CoordinatorAddStudentModalProps): React.ReactElement { const { data: userEmails, isSuccess: userEmailsLoaded } = useUserEmails(); - const enrollStudentMutation = useEnrollStudentMutation(sectionId); + const endpoint = title.toLocaleLowerCase().includes("waitlist") + ? `waitlist/${sectionId}/add` + : `sections/${sectionId}/students`; + const enrollStudentMutation = useEnrollStudentMutation(sectionId, endpoint); const [emailsToAdd, setEmailsToAdd] = useState([""]); const [response, setResponse] = useState({} as ResponseType); @@ -182,7 +187,7 @@ export function CoordinatorAddStudentModal({ const initial_component = ( -

Add new students

+

Add new {title}

{emailsToAdd.map((email, index) => ( diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index e2e76b309..bfe8f5fe8 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import { useSectionStudents } from "../../utils/queries/sections"; +import { + useSectionStudents, + useSectionWaitlistedStudents, +} from "../../utils/queries/sections"; import { Mentor, Spacetime, Student } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import { CoordinatorAddStudentModal } from "./CoordinatorAddStudentModal"; @@ -32,6 +35,93 @@ interface MentorSectionInfoProps { courseRestricted: boolean; } +interface SectionInfoProps { + title: string; + students: Student[]; + studentsLoaded: boolean; + studentsLoadError: boolean; + isCoordinator: boolean; + sectionId: number; + courseRestricted: boolean; +} + +export function SectionInfo({ + title, + students, + studentsLoaded, + studentsLoadError, + isCoordinator, + sectionId, + courseRestricted +}: SectionInfoProps): React.ReactElement { + const [isAddingStudent, setIsAddingStudent] = useState(false); + + const closeAddModal = () => { + setIsAddingStudent(false); + }; + return ( + + {studentsLoaded ? ( + // done loading + + + + + + + + + {(students.length === 0 ? [{ name: "No students enrolled", email: "", id: -1 }] : students).map( + ({ name, email, id: studentId }: Student) => ( + + + + ) + )} + {isCoordinator && ( + + + + + + )} + +
Name
+ {isCoordinator && studentId !== -1 && ( + + )} + {name || email} +
+ +
+ {isCoordinator && isAddingStudent && ( + + )} +
+ ) : studentsLoadError ? ( + // error loading +

Students could not be loaded

+ ) : ( + // not done loading + + )} +
+ ); +} + export default function MentorSectionInfo({ spacetimes, isCoordinator, @@ -42,83 +132,43 @@ export default function MentorSectionInfo({ courseRestricted }: MentorSectionInfoProps) { const { data: students, isSuccess: studentsLoaded, isError: studentsLoadError } = useSectionStudents(sectionId); + const { + data: waitlistedStudents, + isSuccess: waitlistedStudentsLoaded, + isError: waitlistedStudentsLoadError + } = useSectionWaitlistedStudents(sectionId); const [showModal, setShowModal] = useState(ModalStates.NONE); const [focusedSpacetimeID, setFocusedSpacetimeID] = useState(-1); - const [isAddingStudent, setIsAddingStudent] = useState(false); const [deleteType, setDeleteType] = useState(false); const closeModal = () => setShowModal(ModalStates.NONE); - const closeAddModal = () => { - setIsAddingStudent(false); - }; - return (

{`${ isCoordinator ? `${mentor.name || mentor.email}'s` : "My" } Section`}

- - {studentsLoaded ? ( - // done loading - - - - - - - - - {(students.length === 0 ? [{ name: "No students enrolled", email: "", id: -1 }] : students).map( - ({ name, email, id: studentId }: Student) => ( - - - - ) - )} - {isCoordinator && ( - - - - - - )} - -
Name
- {isCoordinator && studentId !== -1 && ( - - )} - {name || email} -
- -
- {isCoordinator && isAddingStudent && ( - - )} -
- ) : studentsLoadError ? ( - // error loading -

Students could not be loaded

- ) : ( - // not done loading - - )} -
+ + +
{spacetimes.map(({ override, ...spacetime }, index) => ( a.name.toLowerCase().localeCompare(b.name.toLowerCase())); } - // Fallback: dedicated waitlist endpoint. - if (resp1.status === 404) { - const resp2 = await fetchNormalized(`/sections/${id}/waitlisted`); - if (resp2.ok) { - const students = await resp2.json(); - return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); - } - handlePermissionsError(resp2.status); - throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); - } + // if (resp1.status === 404) { + // const resp2 = await fetchNormalized(`/sections/${id}/waitlisted`); + // if (resp2.ok) { + // const students = await resp2.json(); + // return students.sort((a: Student, b: Student) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + // } + // handlePermissionsError(resp2.status); + // throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); + // } handlePermissionsError(resp1.status); throw new ServerError(`Failed to fetch waitlisted students for section ${id}`); @@ -456,12 +454,14 @@ interface EnrollStudentMutationResponse { * Invalidates all queries associated with the section. */ export const useEnrollStudentMutation = ( - sectionId: number + sectionId: number, + endpoint: string ): UseMutationResult => { const queryClient = useQueryClient(); const mutationResult = useMutation( async (body: EnrollStudentMutationRequest) => { - const response = await fetchWithMethod(`sections/${sectionId}/students`, HTTP_METHODS.PUT, body); + console.log("Enrolling students via endpoint:", endpoint); + const response = await fetchWithMethod(endpoint, HTTP_METHODS.PUT, body); if (response.ok) { return; } else { diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 71b56a00a..18b1dadf2 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -35,7 +35,7 @@ ) -def add_student(section, user): +def add_student(section, user): #make this endpoint for only adding as a student """ Helper Function: @@ -55,7 +55,7 @@ def add_student(section, user): status.HTTP_422_UNPROCESSABLE_ENTITY, ) - # Check that the section is not full + # Check that the section is not full, wouldn't we want that to allow that if section.is_section_full: logger.warning( " User %s was unable to enroll in Section %s" diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index c92558338..d323a91e0 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -31,18 +31,102 @@ def view(request, pk=None): # CURRENT ISSUES: 61a and eecs16b don't allow adding to waitlist? may not be an issue -@api_view(["POST"]) -def add(request, pk=None): +# already works as a student so the put doesn't actually work +@api_view(["PUT"]) +def add(request, pk=None): """ Endpoint: /api/waitlist//add pk= section id - POST: Add a new waitlist student to section. Pass in section id. Called by user + PUT: Add a new waitlist student to section. Pass in section id. Called by user + who wants to be waitlisted. NOT by coordinator on behalf of student. - 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 = get_object_or_error(Section.objects, pk=pk) + course = section.mentor.course + student = request.user + + # Checks that student is able to enroll in the course + if not student.can_enroll_in_course(course): + log_enroll_result( + False, + student, + 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.", + ) + + # If there is space in the section, attempt to enroll the student directly + if not section.is_section_full: + return add_student(section, student) + + # If the waitlist is full, throw an error + if section.is_waitlist_full: + log_enroll_result(False, student, section, reason="Waitlist is full") + raise PermissionDenied("There is no space available in this section.") + + # If user has waitlisted in the max number of waitlists allowed for the course + if not student.can_enroll_in_waitlist(course): + log_enroll_result( + False, + student, + section, + reason="User has waitlisted in max amount of waitlists for the course", + ) + raise PermissionDenied( + "You are waitlisted in the max amount of waitlists for this course." + ) + + # Check if the student is already enrolled in the waitlist for this section + waitlist_queryset = WaitlistedStudent.objects.filter( + active=True, section=section, user=student + ) + if waitlist_queryset.count() != 0: + log_enroll_result( + False, + student, + section, + reason="User is already waitlisted in this section", + ) + raise PermissionDenied("You are already waitlisted in this section.") + + # Check if the waitlist student has a position (only occurs when manually inserting a student) + specified_position = request.data.get("position", None) + + # Create the new waitlist student and save + waitlisted_student = WaitlistedStudent.objects.create( + user=student, section=section, course=course, position=specified_position + ) + waitlisted_student.save() + + log_enroll_result(True, request.user, section) + return Response(status=status.HTTP_201_CREATED) + +@api_view(["PUT"]) +def add_by_coord(request, pk=None): + """ + Endpoint: /api/waitlist//add + pk= section id + + PUT: Add student to waitlist by coordinator. + emails: Array<{ [email: string]: string }>; + actions: { + [action: string]: string; + }; + """ + + # TODO function not finished yet + section = get_object_or_error(Section.objects, pk=pk) course = section.mentor.course user = request.user @@ -53,35 +137,23 @@ def add(request, pk=None): ) if not is_coord: # check if it's a student - # Checks that student is able to enroll in the course - if not student.can_enroll_in_course(course): - log_enroll_result( - False, - student, - 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.", - ) - if is_coord: - data = request.data - email = data.get("email") - if not email: # singular student for now -- may need to adapt to a list - return Response( - {"error": "Must specify email of student to enroll"}, - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) - student_queryset = Student.objects.filter( - course=section.mentor.course, user__email=email + raise PermissionDenied( + "You must be a coord to perform this action.", ) + + data = request.data + email = data.get("email") + if not email: # singular student for now -- may need to adapt to a list + return Response( + {"error": "Must specify email of student to enroll"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + student_queryset = Student.objects.filter( + course=section.mentor.course, user__email=email + ) - student = student_queryset.first().user - print(student_queryset.count()) + student = student_queryset.first().user + print(student_queryset.count()) # user is either a coord or a student # If there is space in the section, attempt to enroll the student directly if not section.is_section_full: From ff874a1111000cba9c0898173533dda56262f83a Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 10 Nov 2025 19:37:25 -0800 Subject: [PATCH 18/28] basic view, can add to waitlist, fixed card bugs --- .../src/components/course/SectionCard.tsx | 19 ++- .../section/CoordinatorAddStudentModal.tsx | 17 +-- .../components/section/MentorSectionInfo.tsx | 15 +- .../frontend/src/utils/queries/sections.tsx | 76 ++++++++++- csm_web/scheduler/serializers.py | 9 +- csm_web/scheduler/urls.py | 1 + csm_web/scheduler/views/waitlistedStudent.py | 129 +++++++++--------- 7 files changed, 185 insertions(+), 81 deletions(-) diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx index 2f65f8b34..2e54aa80e 100644 --- a/csm_web/frontend/src/components/course/SectionCard.tsx +++ b/csm_web/frontend/src/components/course/SectionCard.tsx @@ -2,7 +2,11 @@ import React, { useState } from "react"; import { Link, Navigate } from "react-router-dom"; import { formatSpacetimeInterval } from "../../utils/datetime"; -import { EnrollUserMutationResponse, useEnrollUserMutation } from "../../utils/queries/sections"; +import { + EnrollUserMutationResponse, + useEnrollUserMutation, + useEnrollStudentToWaitlistMutation +} from "../../utils/queries/sections"; import { Mentor, Spacetime } from "../../utils/types"; import Modal, { ModalCloser } from "../Modal"; @@ -43,6 +47,10 @@ export const SectionCard = ({ * Mutation to enroll a student in the section. */ const enrollStudentMutation = useEnrollUserMutation(id); + /** + * Mutation to enroll a student in the section's waitlist. + */ + const enrollStudentWaitlistMutation = useEnrollStudentToWaitlistMutation(id); /** * Whether to show the modal (after an attempt to enroll). @@ -68,7 +76,12 @@ export const SectionCard = ({ return; } - enrollStudentMutation.mutate(undefined, { + // Determine if we should use waitlist mutation (enrolled capacity is full but waitlist is not full) + const isEnrolledFull = numStudentsEnrolled >= capacity; + const shouldUseWaitlist = isEnrolledFull && numStudentsWaitlisted < waitlistCapacity; + const mutation = shouldUseWaitlist ? enrollStudentWaitlistMutation : enrollStudentMutation; + + mutation.mutate(undefined, { onSuccess: () => { setEnrollmentSuccessful(true); setShowModal(true); @@ -194,7 +207,7 @@ export const SectionCard = ({ disabled={!courseOpen || isFull} onClick={isFull ? undefined : enroll} > - {isEnrolledFull ? "JOIN WAITLIST" : "ENROLL"} + {isFull ? "FULL" : isEnrolledFull ? "JOIN WAITLIST" : "ENROLL"} )} diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx index a2fdf2159..56e0838c0 100644 --- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx +++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; import { useUserEmails } from "../../utils/queries/base"; -import { useEnrollStudentMutation } from "../../utils/queries/sections"; +import { useEnrollStudentMutation, useCoordEnrollStudentToWaitlistMutation } from "../../utils/queries/sections"; import LoadingSpinner from "../LoadingSpinner"; import Modal from "../Modal"; @@ -23,6 +23,9 @@ interface CoordinatorAddStudentModalProps { closeModal: (arg0?: boolean) => void; sectionId: number; title: string; + mutation: ( + sectionId: number + ) => ReturnType | ReturnType; } interface RequestType { @@ -52,13 +55,11 @@ interface ActionType { export function CoordinatorAddStudentModal({ closeModal, sectionId, - title + title, + mutation }: CoordinatorAddStudentModalProps): React.ReactElement { const { data: userEmails, isSuccess: userEmailsLoaded } = useUserEmails(); - const endpoint = title.toLocaleLowerCase().includes("waitlist") - ? `waitlist/${sectionId}/add` - : `sections/${sectionId}/students`; - const enrollStudentMutation = useEnrollStudentMutation(sectionId, endpoint); + const enrollMutation = mutation(sectionId); const [emailsToAdd, setEmailsToAdd] = useState([""]); const [response, setResponse] = useState({} as ResponseType); @@ -132,8 +133,8 @@ export function CoordinatorAddStudentModal({ request.actions["capacity"] = responseActions.get("capacity") as string; } - enrollStudentMutation.mutate(request, { - onError: ({ status, json }) => { + enrollMutation.mutate(request, { + onError: ({ status, json }: { status: number; json: any }) => { if (status === 500) { // internal error setResponse({ diff --git a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx index 5e64743bd..d3ecddcee 100644 --- a/csm_web/frontend/src/components/section/MentorSectionInfo.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionInfo.tsx @@ -3,6 +3,8 @@ import React, { useState } from "react"; import { useSectionStudents, useSectionWaitlistedStudents, + useEnrollStudentMutation, + useCoordEnrollStudentToWaitlistMutation } from "../../utils/queries/sections"; import { Mentor, Spacetime, Student } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; @@ -44,10 +46,14 @@ interface SectionInfoProps { isCoordinator: boolean; sectionId: number; courseRestricted: boolean; + mutation: ( + sectionId: number + ) => ReturnType | ReturnType; } export function SectionInfo({ title, + mutation, students, studentsLoaded, studentsLoadError, @@ -109,7 +115,12 @@ export function SectionInfo({ {isCoordinator && isAddingStudent && ( - + )} ) : studentsLoadError ? ( @@ -154,6 +165,7 @@ export default function MentorSectionInfo({
=> { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async () => { + const response = await fetchWithMethod(`waitlist/${sectionId}/add`, HTTP_METHODS.PUT); + if (response.ok) { + return; + } else { + throw await response.json(); + } + }, + { + onSuccess: () => { + // invalidate all queries for the section + queryClient.invalidateQueries(["sections", sectionId]); + // invalidate profiles query for the user + queryClient.invalidateQueries(["profiles"]); + } + } + ); + + // handle error in component + return mutationResult; +}; + interface EnrollStudentMutationRequest { emails: Array<{ [email: string]: string }>; actions: { @@ -452,16 +487,49 @@ interface EnrollStudentMutationResponse { * Failure response body contains the JSON response along with the response status. * * Invalidates all queries associated with the section. + * This endpoint is used by BOTH coordinators and mentors. */ export const useEnrollStudentMutation = ( - sectionId: number, - endpoint: string + sectionId: number +): UseMutationResult => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async (body: EnrollStudentMutationRequest) => { + const response = await fetchWithMethod(`sections/${sectionId}/students`, HTTP_METHODS.PUT, body); + if (response.ok) { + return; + } else { + throw { status: response.status, json: await response.json() }; + } + }, + { + onSuccess: () => { + // invalidate all queries for the section + queryClient.invalidateQueries(["sections", sectionId]); + } + } + ); + + // handle error in component + return mutationResult; +}; + +/** + * Enroll a list of waitlisted students into a given section. + * + * On success, returns nothing; on failure, returns the response body. + * Failure response body contains the JSON response along with the response status. + * + * Invalidates all queries associated with the section. + * This endpoint is used by coordinators only. + */ +export const useCoordEnrollStudentToWaitlistMutation = ( + sectionId: number ): UseMutationResult => { const queryClient = useQueryClient(); const mutationResult = useMutation( async (body: EnrollStudentMutationRequest) => { - console.log("Enrolling students via endpoint:", endpoint); - const response = await fetchWithMethod(endpoint, HTTP_METHODS.PUT, body); + const response = await fetchWithMethod(`waitlist/${sectionId}/coordadd`, HTTP_METHODS.PUT, body); if (response.ok) { return; } else { diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index d66263f8a..ccd70e540 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -34,9 +34,12 @@ class Role(Enum): def get_profile_role(profile): """Return role (enum) depending on the profile type""" - for role, klass in zip(Role, (Coordinator, Student, Mentor)): - if isinstance(profile, klass): - return role.value + if isinstance(profile, Coordinator): + return Role.COORDINATOR.value + elif isinstance(profile, Student) or isinstance(profile, WaitlistedStudent): + return Role.STUDENT.value + elif isinstance(profile, Mentor): + return Role.MENTOR.value return None diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 4d6f9a354..ee4bc64e2 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -26,5 +26,6 @@ path("waitlist//add/", views.waitlistedStudent.add), path("waitlist//drop/", views.waitlistedStudent.drop), path("waitlist//", views.waitlistedStudent.view), + path("waitlist//coordadd/", views.waitlistedStudent.add_by_coord), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index 12956924c..53154651b 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -31,14 +31,14 @@ def view(request, pk=None): # CURRENT ISSUES: 61a and eecs16b don't allow adding to waitlist? may not be an issue -# already works as a student so the put doesn't actually work +# already works as a student so the put doesn't actually work @api_view(["PUT"]) -def add(request, pk=None): +def add(request, pk=None): """ Endpoint: /api/waitlist//add pk= section id - PUT: Add a new waitlist student to section. Pass in section id. Called by user + PUT: Add a new waitlist student to section. Pass in section id. Called by user who wants to be waitlisted. NOT by coordinator on behalf of student. - if user cannot enroll in section, deny permission - if user is already on waitlist for this section, deny @@ -65,8 +65,8 @@ def add(request, pk=None): "You are either mentoring for this course, already enrolled in a section, " "or the course is closed for enrollment.", ) - - # If there is space in the section, attempt to enroll the student directly + + # If there is space in the section, attempt to enroll the student directly in the section if not section.is_section_full: return add_student(section, student) @@ -112,13 +112,14 @@ def add(request, pk=None): log_enroll_result(True, request.user, section) return Response(status=status.HTTP_201_CREATED) + @api_view(["PUT"]) -def add_by_coord(request, pk=None): +def add_by_coord(request, pk=None): # get this to work with only emails and no actions """ Endpoint: /api/waitlist//add pk= section id - PUT: Add student to waitlist by coordinator. + PUT: Add student to waitlist by coordinator. emails: Array<{ [email: string]: string }>; actions: { [action: string]: string; @@ -126,7 +127,6 @@ def add_by_coord(request, pk=None): """ # TODO function not finished yet - section = get_object_or_error(Section.objects, pk=pk) course = section.mentor.course user = request.user @@ -140,63 +140,68 @@ def add_by_coord(request, pk=None): raise PermissionDenied( "You must be a coord to perform this action.", ) - - data = request.data - email = data.get("email") - if not email: # singular student for now -- may need to adapt to a list - return Response( - {"error": "Must specify email of student to enroll"}, - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) - student_queryset = Student.objects.filter( - course=section.mentor.course, user__email=email - ) - - student = student_queryset.first().user - print(student_queryset.count()) - # user is either a coord or a student - # If there is space in the section, attempt to enroll the student directly - if not section.is_section_full: - return add_student(section, student) - - # If the waitlist is full, throw an error - if section.is_waitlist_full: - log_enroll_result(False, student, section, reason="Waitlist is full") - raise PermissionDenied("There is no space available in this section.") - # If user has waitlisted in the max number of waitlists allowed for the course - if not student.can_enroll_in_waitlist(course): - log_enroll_result( - False, - student, - section, - reason="User has waitlisted in max amount of waitlists for the course", + print(request.data) + + for email in request.data.emails: + # data = request.data + # email = data.get("email") + if not email: # singular student for now -- may need to adapt to a list + return Response( + {"error": "Must specify email of student to enroll"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + student_queryset = Student.objects.filter( + course=section.mentor.course, user__email=email + ) + # course + + student = student_queryset.first().user + print(student_queryset.count()) + # user is either a coord or a student + # If there is space in the section, attempt to enroll the student directly + if not section.is_section_full: + return add_student(section, student) + + # If the waitlist is full, throw an error + if section.is_waitlist_full: + log_enroll_result(False, student, section, reason="Waitlist is full") + raise PermissionDenied("There is no space available in this section.") + + # If user has waitlisted in the max number of waitlists allowed for the course + if not student.can_enroll_in_waitlist(course): + log_enroll_result( + False, + student, + section, + reason="User has waitlisted in max amount of waitlists for the course", + ) + raise PermissionDenied( + "You are waitlisted in the max amount of waitlists for this course." + ) + + # Check if the student is already enrolled in the waitlist for this section + waitlist_queryset = WaitlistedStudent.objects.filter( + active=True, section=section, user=student ) - raise PermissionDenied( - "You are waitlisted in the max amount of waitlists for this course." + if waitlist_queryset.count() != 0: + log_enroll_result( + False, + student, + section, + reason="User is already waitlisted in this section", + ) + raise PermissionDenied("You are already waitlisted in this section.") + + # Check if the waitlist student has a position + # (only occurs when manually inserting a student) + specified_position = request.data.get("position", None) + + # Create the new waitlist student and save + waitlisted_student = WaitlistedStudent.objects.create( + user=student, section=section, course=course, position=specified_position ) - - # Check if the student is already enrolled in the waitlist for this section - waitlist_queryset = WaitlistedStudent.objects.filter( - active=True, section=section, user=student - ) - if waitlist_queryset.count() != 0: - log_enroll_result( - False, - student, - section, - reason="User is already waitlisted in this section", - ) - raise PermissionDenied("You are already waitlisted in this section.") - - # Check if the waitlist student has a position (only occurs when manually inserting a student) - specified_position = request.data.get("position", None) - - # Create the new waitlist student and save - waitlisted_student = WaitlistedStudent.objects.create( - user=student, section=section, course=course, position=specified_position - ) - waitlisted_student.save() + waitlisted_student.save() log_enroll_result(True, request.user, section) return Response(status=status.HTTP_201_CREATED) From bbe7a3e69ae01b226a06313139b0175fe223794c Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 17 Nov 2025 18:40:58 -0800 Subject: [PATCH 19/28] added waitlist section view --- .../src/components/section/Section.tsx | 6 ++-- .../src/components/section/StudentSection.tsx | 33 ++++++++++++++++++- csm_web/frontend/src/css/base/_variables.scss | 1 + .../src/css/base/colors-export.module.scss | 1 + csm_web/frontend/src/utils/types.tsx | 3 +- csm_web/scheduler/serializers.py | 8 ++++- csm_web/scheduler/views/section.py | 29 ++++++++++------ 7 files changed, 66 insertions(+), 15 deletions(-) diff --git a/csm_web/frontend/src/components/section/Section.tsx b/csm_web/frontend/src/components/section/Section.tsx index aff0d204c..88890420f 100644 --- a/csm_web/frontend/src/components/section/Section.tsx +++ b/csm_web/frontend/src/components/section/Section.tsx @@ -6,7 +6,7 @@ import { useSection } from "../../utils/queries/sections"; import { Override, Role, Spacetime } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import MentorSection from "./MentorSection"; -import StudentSection from "./StudentSection"; +import { WaitlistStudentSection, StudentSection } from "./StudentSection"; import scssColors from "../../css/base/colors-export.module.scss"; import "../../css/section.scss"; @@ -22,13 +22,15 @@ export default function Section(): React.ReactElement | null { } return ; } - + console.log(section); switch (section.userRole) { case Role.COORDINATOR: case Role.MENTOR: return ; case Role.STUDENT: return ; + case Role.WAITLIST: + return ; default: return null; } diff --git a/csm_web/frontend/src/components/section/StudentSection.tsx b/csm_web/frontend/src/components/section/StudentSection.tsx index 2bd994988..8e2770fd3 100644 --- a/csm_web/frontend/src/components/section/StudentSection.tsx +++ b/csm_web/frontend/src/components/section/StudentSection.tsx @@ -30,7 +30,38 @@ interface StudentSectionType { associatedProfileId: number; } -export default function StudentSection({ +export function WaitlistStudentSection({ + id, + course, + courseTitle, + mentor, + spacetimes, + override, + associatedProfileId +}: StudentSectionType) { + return ( + + + + +

Waitlist Number

+ + } + /> +
+
+ ); +} + +export function StudentSection({ id, course, courseTitle, diff --git a/csm_web/frontend/src/css/base/_variables.scss b/csm_web/frontend/src/css/base/_variables.scss index 88dc34b55..a4fe41783 100644 --- a/csm_web/frontend/src/css/base/_variables.scss +++ b/csm_web/frontend/src/css/base/_variables.scss @@ -15,6 +15,7 @@ $csm-theme-default: black; $csm-mentor: $csm-green; $csm-coordinator: #6517b3; $csm-student: #ffab2e; +$csm-waitlist: #1c81df; $csm-danger: #ff7272; $csm-danger-darkened: #eb6060; $csm-neutral: #c0c0c0; diff --git a/csm_web/frontend/src/css/base/colors-export.module.scss b/csm_web/frontend/src/css/base/colors-export.module.scss index c3ded4c39..fbe6ceeaf 100644 --- a/csm_web/frontend/src/css/base/colors-export.module.scss +++ b/csm_web/frontend/src/css/base/colors-export.module.scss @@ -13,6 +13,7 @@ student: $csm-student; mentor: $csm-mentor; coordinator: $csm-coordinator; + waitlist: $csm-waitlist; attendance-present: $csm-attendance-present; attendance-excused: $csm-attendance-excused; diff --git a/csm_web/frontend/src/utils/types.tsx b/csm_web/frontend/src/utils/types.tsx index 297d9bf76..6bd5d1306 100644 --- a/csm_web/frontend/src/utils/types.tsx +++ b/csm_web/frontend/src/utils/types.tsx @@ -3,7 +3,8 @@ import { DateTime } from "luxon"; export enum Role { COORDINATOR = "COORDINATOR", MENTOR = "MENTOR", - STUDENT = "STUDENT" + STUDENT = "STUDENT", + WAITLIST = "WAITLIST" } export interface Override { diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index ccd70e540..3c0548379 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -30,16 +30,19 @@ class Role(Enum): COORDINATOR = "COORDINATOR" STUDENT = "STUDENT" MENTOR = "MENTOR" + WAITLIST = "WAITLIST" def get_profile_role(profile): """Return role (enum) depending on the profile type""" if isinstance(profile, Coordinator): return Role.COORDINATOR.value - elif isinstance(profile, Student) or isinstance(profile, WaitlistedStudent): + elif isinstance(profile, Student): return Role.STUDENT.value elif isinstance(profile, Mentor): return Role.MENTOR.value + elif isinstance(profile, WaitlistedStudent): + return Role.WAITLIST.value return None @@ -296,6 +299,9 @@ def user_associated_profile(self, obj): try: return obj.students.get(user=user) except Student.DoesNotExist: + waitlisted_student = obj.waitlist_set.filter(user=user).first() + if waitlisted_student: + return waitlisted_student coordinator = obj.mentor.course.coordinator_set.filter(user=user).first() if coordinator: return coordinator diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index e160df566..d24578778 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -35,7 +35,7 @@ ) -def add_student(section, user): #make this endpoint for only adding as a student +def add_student(section, user): # make this endpoint for only adding as a student """ Helper Function: @@ -55,7 +55,7 @@ def add_student(section, user): #make this endpoint for only adding as a student status.HTTP_422_UNPROCESSABLE_ENTITY, ) - # Check that the section is not full, wouldn't we want that to allow that + # Check that the section is not full, wouldn't we want that to allow that if section.is_section_full: logger.warning( " User %s was unable to enroll in Section %s" @@ -83,6 +83,7 @@ def add_student(section, user): #make this endpoint for only adding as a student f" {student_queryset.all()}))", code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + if student_queryset.count() == 1: student = student_queryset.get() old_section = student.section @@ -104,17 +105,23 @@ def add_student(section, user): #make this endpoint for only adding as a student log_str(student.user), log_str(section), ) - student.save() logger.info( - " User %s swapped into Section %s from Section %s", + " User %s swapping 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) + else: + student = Student.objects.create( + user=user, section=section, course=section.mentor.course + ) + + student.save() - 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), ) # Removes all waitlists the student that added was a part of @@ -128,10 +135,11 @@ def add_student(section, user): #make this endpoint for only adding as a student waitlist.save() logger.info( - " User %s enrolled in Section %s", - log_str(student.user), - log_str(section), + " User %s removed from all Waitlists for Course %s", + log_str(user), + log_str(student.course), ) + return Response({"id": student.id}, status=status.HTTP_201_CREATED) @@ -201,6 +209,7 @@ def get_queryset(self): Q(mentor__user=self.request.user) | Q(students__user=self.request.user) | Q(mentor__course__coordinator__user=self.request.user) + | Q(waitlist_set__user=self.request.user) ) .distinct() ) From b1f3139041dd832362dace628cbce2e6acb65eaa Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Mon, 17 Nov 2025 18:50:50 -0800 Subject: [PATCH 20/28] fix resources --- csm_web/frontend/src/utils/user.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/csm_web/frontend/src/utils/user.tsx b/csm_web/frontend/src/utils/user.tsx index 2d2661aec..241305231 100644 --- a/csm_web/frontend/src/utils/user.tsx +++ b/csm_web/frontend/src/utils/user.tsx @@ -4,6 +4,7 @@ export interface Roles { [Role.STUDENT]: Set; [Role.MENTOR]: Set; [Role.COORDINATOR]: Set; + [Role.WAITLIST]: Set; } /** @@ -15,7 +16,8 @@ export function emptyRoles(): Roles { return { [Role.STUDENT]: new Set(), [Role.MENTOR]: new Set(), - [Role.COORDINATOR]: new Set() + [Role.COORDINATOR]: new Set(), + [Role.WAITLIST]: new Set() }; } From 46cbe03d5c36dc8977f28917b6bcaa89736e3aaa Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Tue, 2 Sep 2025 22:20:09 -0700 Subject: [PATCH 21/28] add migrations --- ...33_matcherslot_description_0033_mentor_family.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py diff --git a/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py b/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py new file mode 100644 index 000000000..79d869c3d --- /dev/null +++ b/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py @@ -0,0 +1,13 @@ +# Generated by Django 5.0.7 on 2025-09-03 05:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("scheduler", "0033_matcherslot_description"), + ("scheduler", "0033_mentor_family"), + ] + + operations = [] From 6e2c98191c2a19ea957e142473b2556841fc9618 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Thu, 29 Jan 2026 16:47:29 -0800 Subject: [PATCH 22/28] fix migration --- ...st_enroll_section_waitlist_capacity_and_more.py} | 5 ++--- ...33_matcherslot_description_0033_mentor_family.py | 13 ------------- 2 files changed, 2 insertions(+), 16 deletions(-) rename csm_web/scheduler/migrations/{0034_course_student_waitlist_capacity_and_more.py => 0034_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py} (95%) delete mode 100644 csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py diff --git a/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py b/csm_web/scheduler/migrations/0034_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py similarity index 95% rename from csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py rename to csm_web/scheduler/migrations/0034_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py index f4b4145a8..6158f6e79 100644 --- a/csm_web/scheduler/migrations/0034_course_student_waitlist_capacity_and_more.py +++ b/csm_web/scheduler/migrations/0034_course_max_waitlist_enroll_section_waitlist_capacity_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-09-30 01:35 +# Generated by Django 5.1.6 on 2026-01-30 00:29 import django.db.models.deletion from django.conf import settings @@ -46,8 +46,7 @@ class Migration(migrations.Migration): "position", models.PositiveIntegerField( blank=True, - help_text="Manual position on the waitlist. " - "Lower numbers have higher priority.", + help_text="Manual position on the waitlist. Lower numbers have higher priority.", null=True, ), ), diff --git a/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py b/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py deleted file mode 100644 index 79d869c3d..000000000 --- a/csm_web/scheduler/migrations/0034_merge_0033_matcherslot_description_0033_mentor_family.py +++ /dev/null @@ -1,13 +0,0 @@ -# Generated by Django 5.0.7 on 2025-09-03 05:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("scheduler", "0033_matcherslot_description"), - ("scheduler", "0033_mentor_family"), - ] - - operations = [] From 3bd1e5ce45a71141420b2c6b1399ad94cbc73205 Mon Sep 17 00:00:00 2001 From: gabrielhan23 <69331301+gabrielhan23@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:49:49 -0800 Subject: [PATCH 23/28] add numWaitlistedStudnets to section serializer (#531) Co-authored-by: maxmwang From 55691ff0b49543939abe8b0a2433f28aecab7b63 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Fri, 30 Jan 2026 04:33:54 -0800 Subject: [PATCH 24/28] fix waitlisting tests, allow cascade waitlist, add from coord, prevent race conditions --- .babelrc.json | 2 +- .../frontend/src/utils/queries/sections.tsx | 28 +- csm_web/pytest.ini | 1 + csm_web/scheduler/models.py | 15 + .../tests/models/test_waitlisted_student.py | 340 ++++++++++++++++-- csm_web/scheduler/urls.py | 4 + csm_web/scheduler/views/section.py | 107 ++++-- csm_web/scheduler/views/waitlistedStudent.py | 194 ++++------ 8 files changed, 505 insertions(+), 186 deletions(-) diff --git a/.babelrc.json b/.babelrc.json index 4dfe13cb0..986584ab0 100644 --- a/.babelrc.json +++ b/.babelrc.json @@ -1,4 +1,4 @@ { "presets": ["@babel/preset-react", "@babel/preset-typescript"], - "plugins": ["transform-class-properties", "lodash", "@babel/plugin-transform-runtime", "dynamic-import-node"] + "plugins": ["transform-class-properties", "@babel/plugin-transform-runtime", "dynamic-import-node"] } diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index 3e1788477..a78002073 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -480,6 +480,18 @@ interface EnrollStudentMutationResponse { }; } +function normalizeEnrollError(response: Response, payload: any) { + if (!payload) { + return { errors: { critical: `Request failed (${response.status}).` } }; + } + + if (payload.detail && !payload.errors && !payload.progress) { + return { errors: { critical: payload.detail } }; + } + + return payload; +} + /** * Enroll a list of students into a given section. * @@ -499,7 +511,13 @@ export const useEnrollStudentMutation = ( if (response.ok) { return; } else { - throw { status: response.status, json: await response.json() }; + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + throw { status: response.status, json: normalizeEnrollError(response, payload) }; } }, { @@ -533,7 +551,13 @@ export const useCoordEnrollStudentToWaitlistMutation = ( if (response.ok) { return; } else { - throw { status: response.status, json: await response.json() }; + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + throw { status: response.status, json: normalizeEnrollError(response, payload) }; } }, { diff --git a/csm_web/pytest.ini b/csm_web/pytest.ini index 2ec4d27ba..6ce72b703 100644 --- a/csm_web/pytest.ini +++ b/csm_web/pytest.ini @@ -1,3 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = csm_web.settings python_files = tests.py test_*.py *_tests.py +addopts = --reuse-db --nomigrations -p no:cacheprovider diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index f1ed48417..a82f59c3f 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -75,6 +75,21 @@ def can_enroll_in_course(self, course, bypass_enrollment_time=False): is_valid_enrollment_time = course.is_open() return is_valid_enrollment_time and not is_associated + def can_waitlist_in_course(self, course, bypass_enrollment_time=False): + """Determine whether this user is allowed to waitlist in the given course.""" + # check restricted first + if course.is_restricted and not self.is_whitelisted_for(course): + return False + + if bypass_enrollment_time: + return True + + if self.priority_enrollment: + now = timezone.now().astimezone(timezone.get_default_timezone()) + return self.priority_enrollment < now < course.enrollment_end + + return course.is_open() + def can_enroll_in_waitlist(self, course): """Determine whether this user is allowed to waitlist in the given course.""" return ( diff --git a/csm_web/scheduler/tests/models/test_waitlisted_student.py b/csm_web/scheduler/tests/models/test_waitlisted_student.py index 488c792e9..261bade2a 100644 --- a/csm_web/scheduler/tests/models/test_waitlisted_student.py +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -1,4 +1,7 @@ +from datetime import timedelta + import pytest +from django.utils import timezone from scheduler.factories import ( CoordinatorFactory, CourseFactory, @@ -17,7 +20,7 @@ def fixture_setup_waitlist(db): # pylint: disable=unused-argument mentor_user, student_user = UserFactory.create_batch(2) course = CourseFactory.create() mentor = MentorFactory.create(course=course, user=mentor_user) - section = SectionFactory.create(mentor=mentor) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) return mentor_user, student_user, course, section @@ -63,33 +66,27 @@ def test_user_cannot_enroll_in_course(setup_waitlist, client): mentor_user, user, _, section = setup_waitlist client.force_login(mentor_user) - response = client.post(f"/api/waitlist/{section.pk}/add/") + response = client.put(f"/api/waitlist/{section.pk}/add/") assert response.status_code == 403 assert ( response.data["detail"] - == "You are either mentoring for this course, already enrolled in a section, " - "or the course is closed for enrollment." + == "You are already either mentoring for this course or enrolled in a section, " + "or the course is closed for enrollment" ) assert WaitlistedStudent.objects.count() == 0 client.force_login(user) - response = client.post( - f"/api/waitlist/{section.pk}/add/" - ) # should auto enroll user + response = client.put(f"/api/waitlist/{section.pk}/add/") # should auto enroll user assert WaitlistedStudent.objects.count() == 0 assert Student.objects.count() == 1 client.force_login(user) - response = client.post( + response = client.put( f"/api/waitlist/{section.pk}/add/" ) # fails because user is in section assert response.status_code == 403 - assert ( - response.data["detail"] - == "You are either mentoring for this course, already enrolled in a section, " - "or the course is closed for enrollment." - ) + assert response.data["detail"] == "User is already enrolled in this section." assert WaitlistedStudent.objects.count() == 0 @@ -105,10 +102,10 @@ def test_user_can_waitlist_only_once(setup_waitlist, client): while not section.is_section_full: new_student = UserFactory.create_batch(1)[0] client.force_login(new_student) - response = client.post(f"/api/waitlist/{section.pk}/add/") + response = client.put(f"/api/waitlist/{section.pk}/add/") client.force_login(waitlisted_student_user) - response = client.post( + response = client.put( f"/api/waitlist/{section.pk}/add/", content_type="application/json" ) @@ -116,10 +113,10 @@ def test_user_can_waitlist_only_once(setup_waitlist, client): assert WaitlistedStudent.objects.count() == 1 client.force_login(waitlisted_student_user) - response = client.post(f"/api/waitlist/{section.pk}/add/") + response = client.put(f"/api/waitlist/{section.pk}/add/") assert response.status_code == 403 - assert response.data["detail"] == "You are already waitlisted in this section." + assert response.data["detail"] == "User is already waitlisted in this section." assert WaitlistedStudent.objects.count() == 1 @@ -135,15 +132,17 @@ def test_waitlist_is_full(setup_waitlist, client): while not section.is_waitlist_full: new_student = UserFactory.create_batch(1)[0] client.force_login(new_student) - response = client.post( + response = client.put( f"/api/waitlist/{section.pk}/add/", content_type="application/json" ) client.force_login(waitlisted_student_user) - response = client.post(f"/api/waitlist/{section.pk}/add/") + response = client.put( + f"/api/waitlist/{section.pk}/add/", content_type="application/json" + ) assert response.status_code == 403 - assert response.data["detail"] == "There is no space available in this section." + assert response.data["detail"] == "There is no space available in this waitlist." assert WaitlistedStudent.objects.count() == 3 @@ -160,15 +159,20 @@ def test_user_exceeds_max_waitlists_for_course(setup_waitlist, client): for _ in range(course.max_waitlist_enroll): mentor_user = UserFactory.create_batch(1)[0] mentor = MentorFactory.create(course=course, user=mentor_user) - section_test = SectionFactory.create(mentor=mentor) + section_test = SectionFactory.create( + mentor=mentor, capacity=1, waitlist_capacity=1 + ) while not section_test.is_section_full: new_student = UserFactory.create_batch(1)[0] client.force_login(new_student) - response = client.post(f"/api/waitlist/{section_test.pk}/add/") + response = client.put( + f"/api/waitlist/{section_test.pk}/add/", + content_type="application/json", + ) client.force_login(waitlisted_student_user) - response = client.post( + response = client.put( f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" ) # Verify max waitlists achieved @@ -177,13 +181,13 @@ def test_user_exceeds_max_waitlists_for_course(setup_waitlist, client): while not section.is_section_full: new_student = UserFactory.create_batch(1)[0] client.force_login(new_student) - response = client.post( + response = client.put( f"/api/waitlist/{section.pk}/add/", content_type="application/json" ) # Verify errors when attempting to add another waitlist client.force_login(waitlisted_student_user) - response = client.post( + response = client.put( f"/api/waitlist/{section.pk}/add/", content_type="application/json" ) assert response.status_code == 403 @@ -192,10 +196,10 @@ def test_user_exceeds_max_waitlists_for_course(setup_waitlist, client): # Check if user is dropped from all waitlists for a course when adding to a course section mentor_user = UserFactory.create_batch(1)[0] mentor = MentorFactory.create(course=course, user=mentor_user) - section_test = SectionFactory.create(mentor=mentor) + section_test = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=1) client.force_login(waitlisted_student_user) - response = client.post( + response = client.put( f"/api/waitlist/{section_test.pk}/add/", content_type="application/json" ) assert ( @@ -219,26 +223,22 @@ def test_user_enrolled_from_waitlist_and_dropped_from_others(setup_waitlist, cli # Set up a second section in the same course mentor_user = UserFactory.create_batch(1)[0] mentor = MentorFactory.create(course=course, user=mentor_user) - section2 = SectionFactory.create(mentor=mentor) + section2 = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) # Add user to both waitlists for _ in range(section1.capacity): test_user = UserFactory.create_batch(1)[0] client.force_login(test_user) - _ = client.post(f"/api/waitlist/{section1.pk}/add/") + _ = client.put(f"/api/waitlist/{section1.pk}/add/") for _ in range(section2.capacity): test_user = UserFactory.create_batch(1)[0] client.force_login(test_user) - _ = client.post(f"/api/waitlist/{section2.pk}/add/") + _ = client.put(f"/api/waitlist/{section2.pk}/add/") client.force_login(waitlisted_student_user) - _ = client.post( - f"/api/waitlist/{section1.pk}/add/", content_type="application/json" - ) - _ = client.post( - f"/api/waitlist/{section2.pk}/add/", content_type="application/json" - ) + _ = client.put(f"/api/waitlist/{section1.pk}/add/", content_type="application/json") + _ = client.put(f"/api/waitlist/{section2.pk}/add/", content_type="application/json") assert ( WaitlistedStudent.objects.filter( user=waitlisted_student_user, active=True @@ -258,6 +258,130 @@ def test_user_enrolled_from_waitlist_and_dropped_from_others(setup_waitlist, cli ) +@pytest.mark.django_db +def test_enrolled_student_can_waitlist_other_section(client): + """ + Given a student enrolled in a section, + When they waitlist for another section in the same course, + Then they are allowed to waitlist. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section2) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.filter( + user=enrolled_user, section=section2, active=True + ).exists() + + +@pytest.mark.django_db +def test_waitlist_promotion_swaps_section(client): + """ + Given a student is enrolled in section A and waitlisted in section B, + When a spot opens in section B, + Then the student is swapped into section B and removed from section A. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + other_user = UserFactory.create() + other_student = Student.objects.create( + user=other_user, course=course, section=section2 + ) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + assert response.status_code == 201 + + client.force_login(other_user) + _ = client.patch(f"/api/students/{other_student.pk}/drop/") + + active_student = Student.objects.filter(user=enrolled_user, active=True).first() + assert active_student is not None + assert active_student.section == section2 + assert ( + Student.objects.filter( + user=enrolled_user, active=True, section=section1 + ).count() + == 0 + ) + assert ( + WaitlistedStudent.objects.filter(user=enrolled_user, active=True).count() == 0 + ) + + +@pytest.mark.django_db +def test_waitlist_cascades_to_previous_section(client): + """ + Given a student swaps from section A to section B via waitlist, + When they leave section A, + Then section A's waitlist is promoted as well. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=1, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=1, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + waitlisted_user = UserFactory.create() + client.force_login(waitlisted_user) + _ = client.put( + f"/api/waitlist/{section1.pk}/add/", data={}, content_type="application/json" + ) + + other_user = UserFactory.create() + other_student = Student.objects.create( + user=other_user, course=course, section=section2 + ) + + client.force_login(enrolled_user) + _ = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + client.force_login(other_user) + _ = client.patch(f"/api/students/{other_student.pk}/drop/") + + promoted_student = Student.objects.filter( + user=waitlisted_user, active=True, section=section1 + ).first() + assert promoted_student is not None + + @pytest.mark.django_db def test_user_drops_themselves_successfully(setup_waitlist, client): """ @@ -357,4 +481,146 @@ def test_positions_update_properly(): When a coordinator adds a student on the waitlist at a certain position, The waitlist students have the correct order. """ - return True + assert True + + +@pytest.mark.django_db +def test_waitlist_respects_priority_enrollment(client): + """ + Given a course that is not open for enrollment yet, + When a user has priority enrollment in the window, + Then they can still waitlist. + """ + now = timezone.now() + course = CourseFactory.create( + enrollment_start=now + timedelta(days=7), + enrollment_end=now + timedelta(days=14), + section_start=(now + timedelta(days=8)).date(), + valid_until=(now + timedelta(days=30)).date(), + ) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + # Fill section to force waitlist + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + user.priority_enrollment = now - timedelta(days=1) + user.save() + + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 201 + assert WaitlistedStudent.objects.filter( + user=user, section=section, active=True + ).exists() + + +@pytest.mark.django_db +def test_waitlist_denied_outside_enrollment_window(client): + """ + Given a course that is not open for enrollment and no priority enrollment, + When a user attempts to waitlist, + Then they are denied. + """ + now = timezone.now() + course = CourseFactory.create( + enrollment_start=now + timedelta(days=7), + enrollment_end=now + timedelta(days=14), + section_start=(now + timedelta(days=8)).date(), + valid_until=(now + timedelta(days=30)).date(), + ) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 403 + assert response.data["detail"] == "User cannot waitlist in this course." + + +@pytest.mark.django_db +def test_waitlist_restricted_course_requires_whitelist(client): + """ + Given a restricted course without whitelist access, + When a user attempts to waitlist, + Then they are denied. + """ + course = CourseFactory.create(is_restricted=True) + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + other_user = UserFactory.create() + Student.objects.create(user=other_user, course=course, section=section) + + user = UserFactory.create() + client.force_login(user) + response = client.put( + f"/api/waitlist/{section.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 403 + assert response.data["detail"] == "User cannot waitlist in this course." + + +@pytest.mark.django_db +def test_coord_add_requires_coordinator(client): + """ + Given a non-coordinator user, + When they attempt to add a student to a waitlist via coord endpoint, + Then they are denied. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor) + + non_coord_user = UserFactory.create() + client.force_login(non_coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "test@berkeley.edu"}]}, + content_type="application/json", + ) + + assert response.status_code == 403 + assert response.data["detail"] == "You must be a coord to perform this action." + + +@pytest.mark.django_db +def test_waitlist_count_endpoint(client): + """ + Given a section with waitlisted students, + When the count endpoint is requested, + Then it returns the waitlist count. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor) + + user = UserFactory.create() + WaitlistedStudent.objects.create(user=user, course=course, section=section) + + client.force_login(user) + response = client.get( + f"/api/waitlist/{section.pk}/count_waitlist/", + HTTP_ACCEPT="application/json", + ) + + assert response.status_code == 200 + assert int(response.content.decode("utf-8")) == 1 diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index ee4bc64e2..484ce6550 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -27,5 +27,9 @@ path("waitlist//drop/", views.waitlistedStudent.drop), path("waitlist//", views.waitlistedStudent.view), path("waitlist//coordadd/", views.waitlistedStudent.add_by_coord), + path( + "waitlist//count_waitlist/", + views.waitlistedStudent.count_waitist, + ), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index d24578778..09313a6cc 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -143,6 +143,48 @@ def add_student(section, user): # make this endpoint for only adding as a stude return Response({"id": student.id}, status=status.HTTP_201_CREATED) +def swap_from_waitlist(section, user): + """ + Helper Function: + + Swaps a waitlisted student into a new section by dropping their current + section enrollment first if needed. + """ + active_student = user.student_set.filter( + active=True, course=section.mentor.course + ).first() + + old_section_id = None + if active_student is not None: + if active_student.section == section: + raise PermissionDenied("User is already enrolled in this section") + + # drop from current section + old_section = active_student.section + old_section_id = old_section.id + active_student.active = False + active_student.save() + + try: + add_student(section, user) + except PermissionDenied: + if active_student is not None: + active_student.active = True + active_student.save() + raise + + if active_student is not None: + now = timezone.now().astimezone(timezone.get_default_timezone()) + active_student.attendance_set.filter( + Q( + sectionOccurrence__date__gte=now.date(), + sectionOccurrence__section=old_section, + ) + ).delete() + + return old_section_id + + def add_from_waitlist(pk): """ Helper function for adding from waitlist. Called by drop user api @@ -156,29 +198,52 @@ def add_from_waitlist(pk): """ # Finds section and waitlist student, searches for position # (manually inserted student) then timestamp - section = Section.objects.get(pk=pk) - waitlisted_student = ( - WaitlistedStudent.objects.filter(active=True, section=section) - .order_by("position", "timestamp") - .first() - ) - - # Check if there are waitlisted students - if not waitlisted_student: - logger.info( - " No waitlist users for section %s", - log_str(section), + cascade_section_id = None + response = None + with transaction.atomic(): + section = Section.objects.select_for_update().get(pk=pk) + waitlisted_students = list( + WaitlistedStudent.objects.select_for_update() + .filter(active=True, section=section) + .order_by("position", "timestamp") ) - return Response(status=status.HTTP_204_NO_CONTENT) - # Adds the student - add_student(waitlisted_student.section, waitlisted_student.user) - 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) + # Check if there are waitlisted students + if not waitlisted_students: + logger.info( + " No waitlist users for section %s", + log_str(section), + ) + response = Response(status=status.HTTP_204_NO_CONTENT) + return response + + for waitlisted_student in waitlisted_students: + try: + cascade_section_id = swap_from_waitlist( + waitlisted_student.section, waitlisted_student.user + ) + except PermissionDenied: + continue + + logger.info( + " User %s removed from all Waitlists for Course %s", + log_str(waitlisted_student.user), + log_str(waitlisted_student.course), + ) + response = Response(status=status.HTTP_201_CREATED) + break + + if response is None: + logger.info( + " No eligible waitlist users for section %s", + log_str(section), + ) + response = Response(status=status.HTTP_204_NO_CONTENT) + + if cascade_section_id is not None: + add_from_waitlist(pk=cascade_section_id) + + return response class SectionViewSet(*viewset_with("retrieve", "partial_update", "create")): diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index 53154651b..2796b48b5 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -1,3 +1,4 @@ +from django.db import transaction from rest_framework import status from rest_framework.decorators import api_view from rest_framework.exceptions import NotFound, PermissionDenied @@ -5,7 +6,7 @@ from scheduler.serializers import WaitlistedStudentSerializer from scheduler.views.utils import get_object_or_error -from ..models import Section, Student, WaitlistedStudent +from ..models import Section, User, WaitlistedStudent from .section import add_student from .utils import logger @@ -46,68 +47,18 @@ def add(request, pk=None): - if section is not full, enroll instead. """ - section = get_object_or_error(Section.objects, pk=pk) - course = section.mentor.course - student = request.user - - # Checks that student is able to enroll in the course - if not student.can_enroll_in_course(course): - log_enroll_result( - False, - student, - 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.", - ) - - # If there is space in the section, attempt to enroll the student directly in the section - if not section.is_section_full: - return add_student(section, student) + with transaction.atomic(): + section = get_object_or_error(Section.objects, pk=pk) + section = Section.objects.select_for_update().get(pk=section.pk) + student = request.user - # If the waitlist is full, throw an error - if section.is_waitlist_full: - log_enroll_result(False, student, section, reason="Waitlist is full") - raise PermissionDenied("There is no space available in this section.") - - # If user has waitlisted in the max number of waitlists allowed for the course - if not student.can_enroll_in_waitlist(course): - log_enroll_result( - False, - student, + response = _add_to_waitlist_or_section( section, - reason="User has waitlisted in max amount of waitlists for the course", - ) - raise PermissionDenied( - "You are waitlisted in the max amount of waitlists for this course." - ) - - # Check if the student is already enrolled in the waitlist for this section - waitlist_queryset = WaitlistedStudent.objects.filter( - active=True, section=section, user=student - ) - if waitlist_queryset.count() != 0: - log_enroll_result( - False, student, - section, - reason="User is already waitlisted in this section", + bypass_enrollment_time=False, ) - raise PermissionDenied("You are already waitlisted in this section.") - - # Check if the waitlist student has a position (only occurs when manually inserting a student) - specified_position = request.data.get("position", None) - - # Create the new waitlist student and save - waitlisted_student = WaitlistedStudent.objects.create( - user=student, section=section, course=course, position=specified_position - ) - waitlisted_student.save() + if response is not None: + return response log_enroll_result(True, request.user, section) return Response(status=status.HTTP_201_CREATED) @@ -126,85 +77,78 @@ def add_by_coord(request, pk=None): # get this to work with only emails and no }; """ - # TODO function not finished yet - section = get_object_or_error(Section.objects, pk=pk) - course = section.mentor.course - user = request.user - student = user - - is_coord = bool( - section.mentor.course.coordinator_set.filter(user=request.user).count() - ) + with transaction.atomic(): + section = get_object_or_error(Section.objects, pk=pk) + section = Section.objects.select_for_update().get(pk=section.pk) - if not is_coord: # check if it's a student - raise PermissionDenied( - "You must be a coord to perform this action.", + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() ) - print(request.data) + if not is_coord: + raise PermissionDenied("You must be a coord to perform this action.") - for email in request.data.emails: - # data = request.data - # email = data.get("email") - if not email: # singular student for now -- may need to adapt to a list + data = request.data or {} + + if not data.get("emails"): return Response( - {"error": "Must specify email of student to enroll"}, + {"error": "Must specify emails of students to waitlist"}, status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - student_queryset = Student.objects.filter( - course=section.mentor.course, user__email=email - ) - # course - - student = student_queryset.first().user - print(student_queryset.count()) - # user is either a coord or a student - # If there is space in the section, attempt to enroll the student directly - if not section.is_section_full: - return add_student(section, student) - - # If the waitlist is full, throw an error - if section.is_waitlist_full: - log_enroll_result(False, student, section, reason="Waitlist is full") - raise PermissionDenied("There is no space available in this section.") - - # If user has waitlisted in the max number of waitlists allowed for the course - if not student.can_enroll_in_waitlist(course): - log_enroll_result( - False, - student, - section, - reason="User has waitlisted in max amount of waitlists for the course", - ) - raise PermissionDenied( - "You are waitlisted in the max amount of waitlists for this course." - ) - # Check if the student is already enrolled in the waitlist for this section - waitlist_queryset = WaitlistedStudent.objects.filter( - active=True, section=section, user=student - ) - if waitlist_queryset.count() != 0: - log_enroll_result( - False, - student, + for email_obj in data.get("emails"): + email = email_obj.get("email") if isinstance(email_obj, dict) else email_obj + if not email: + return Response( + {"error": "Must specify email of student to waitlist"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + user, _ = User.objects.get_or_create( + username=email.split("@")[0], email=email + ) + _add_to_waitlist_or_section( section, - reason="User is already waitlisted in this section", + user, + bypass_enrollment_time=True, ) - raise PermissionDenied("You are already waitlisted in this section.") - # Check if the waitlist student has a position - # (only occurs when manually inserting a student) - specified_position = request.data.get("position", None) + log_enroll_result(True, request.user, section) + return Response(status=status.HTTP_200_OK) + + +def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): + course = section.mentor.course + + if not user.can_waitlist_in_course( + course, bypass_enrollment_time=bypass_enrollment_time + ): + raise PermissionDenied("User cannot waitlist in this course.") + + if user.student_set.filter(active=True, section=section).exists(): + raise PermissionDenied("User is already enrolled in this section.") + + if not section.is_section_full: + return add_student(section, user) + + if section.is_waitlist_full: + raise PermissionDenied("There is no space available in this waitlist.") - # Create the new waitlist student and save - waitlisted_student = WaitlistedStudent.objects.create( - user=student, section=section, course=course, position=specified_position + if not user.can_enroll_in_waitlist(course): + raise PermissionDenied( + "User is waitlisted in the max amount of waitlists for this course." ) - waitlisted_student.save() - log_enroll_result(True, request.user, section) - return Response(status=status.HTTP_201_CREATED) + if WaitlistedStudent.objects.filter( + active=True, section=section, user=user + ).exists(): + raise PermissionDenied("User is already waitlisted in this section.") + + waitlisted_student = WaitlistedStudent.objects.create( + user=user, section=section, course=course + ) + waitlisted_student.save() + return None @api_view(["PATCH"]) @@ -266,4 +210,4 @@ def count_waitist(request, pk=None): pk= section id """ section = get_object_or_error(Section.objects, pk=pk) - return Response(section.current_waitlist_count()) + return Response(section.current_waitlist_count) From 2be41d99f46bba907edf8db532cb9c2d5c568ca2 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Tue, 3 Feb 2026 18:02:42 -0800 Subject: [PATCH 25/28] fix tests --- csm_web/pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csm_web/pytest.ini b/csm_web/pytest.ini index 6ce72b703..5cd26643d 100644 --- a/csm_web/pytest.ini +++ b/csm_web/pytest.ini @@ -1,4 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = csm_web.settings python_files = tests.py test_*.py *_tests.py -addopts = --reuse-db --nomigrations -p no:cacheprovider +addopts = --reuse-db -p no:cacheprovider From 5fc7cd0d90f2717fc828ca41b89dc5f812a43be2 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Wed, 25 Feb 2026 02:09:14 -0800 Subject: [PATCH 26/28] fix backend for section swap --- csm_web/scheduler/models.py | 8 +- .../tests/models/test_waitlisted_student.py | 84 +++++++++++- csm_web/scheduler/urls.py | 2 +- csm_web/scheduler/views/section.py | 31 ++++- csm_web/scheduler/views/waitlistedStudent.py | 120 +++++++++++------- 5 files changed, 183 insertions(+), 62 deletions(-) diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index a82f59c3f..fe1113461 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -294,13 +294,17 @@ def save(self, *args, **kwargs): if student.position <= previous_position: student.position += 1 previous_position = student.position - student.save() + WaitlistedStudent.objects.filter(pk=student.pk).update( + position=student.position + ) super().save(*args, **kwargs) # If position is not set, assign it based on timestamp if self.position is None: - waitlisted_students = WaitlistedStudent.objects.filter(section=self.section) + waitlisted_students = WaitlistedStudent.objects.filter( + section=self.section, active=True + ) # assigning a position based on timestamp if waitlisted_students.count() == 1: self.position = 1 diff --git a/csm_web/scheduler/tests/models/test_waitlisted_student.py b/csm_web/scheduler/tests/models/test_waitlisted_student.py index 261bade2a..a9b1d849b 100644 --- a/csm_web/scheduler/tests/models/test_waitlisted_student.py +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -69,11 +69,7 @@ def test_user_cannot_enroll_in_course(setup_waitlist, client): response = client.put(f"/api/waitlist/{section.pk}/add/") assert response.status_code == 403 - assert ( - response.data["detail"] - == "You are already either mentoring for this course or enrolled in a section, " - "or the course is closed for enrollment" - ) + assert response.data["detail"] == "Mentors cannot waitlist in a course they mentor." assert WaitlistedStudent.objects.count() == 0 client.force_login(user) @@ -258,6 +254,47 @@ def test_user_enrolled_from_waitlist_and_dropped_from_others(setup_waitlist, cli ) +@pytest.mark.django_db +def test_enrolled_student_swaps_to_open_section(client): + """ + Given a student enrolled in section A, + When they try to add to section B which has room, + Then they are swapped: dropped from A and enrolled in B. + """ + course = CourseFactory.create() + mentor_user1 = UserFactory.create() + mentor1 = MentorFactory.create(course=course, user=mentor_user1) + section1 = SectionFactory.create(mentor=mentor1, capacity=2, waitlist_capacity=3) + + mentor_user2 = UserFactory.create() + mentor2 = MentorFactory.create(course=course, user=mentor_user2) + section2 = SectionFactory.create(mentor=mentor2, capacity=2, waitlist_capacity=3) + + enrolled_user = UserFactory.create() + Student.objects.create(user=enrolled_user, course=course, section=section1) + + client.force_login(enrolled_user) + response = client.put( + f"/api/waitlist/{section2.pk}/add/", data={}, content_type="application/json" + ) + + assert response.status_code == 200 + # User should now be in section2, not section1 + active_student = Student.objects.filter(user=enrolled_user, active=True).first() + assert active_student is not None + assert active_student.section == section2 + assert ( + Student.objects.filter( + user=enrolled_user, active=True, section=section1 + ).count() + == 0 + ) + # No waitlist entries should exist + assert ( + WaitlistedStudent.objects.filter(user=enrolled_user, active=True).count() == 0 + ) + + @pytest.mark.django_db def test_enrolled_student_can_waitlist_other_section(client): """ @@ -481,7 +518,42 @@ def test_positions_update_properly(): When a coordinator adds a student on the waitlist at a certain position, The waitlist students have the correct order. """ - assert True + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=5, waitlist_capacity=5) + + # Create 3 waitlisted students (positions auto-assigned as 1, 2, 3) + user1 = UserFactory.create() + ws1 = WaitlistedStudent.objects.create(user=user1, course=course, section=section) + user2 = UserFactory.create() + ws2 = WaitlistedStudent.objects.create(user=user2, course=course, section=section) + user3 = UserFactory.create() + ws3 = WaitlistedStudent.objects.create(user=user3, course=course, section=section) + + ws1.refresh_from_db() + ws2.refresh_from_db() + ws3.refresh_from_db() + + assert ws1.position == 1 + assert ws2.position == 2 + assert ws3.position == 3 + + # Insert a new student at position 2, should shift ws2 -> 3 and ws3 -> 4 + user4 = UserFactory.create() + ws4 = WaitlistedStudent.objects.create( + user=user4, course=course, section=section, position=2 + ) + + ws1.refresh_from_db() + ws2.refresh_from_db() + ws3.refresh_from_db() + ws4.refresh_from_db() + + assert ws1.position == 1 + assert ws4.position == 2 + assert ws2.position == 3 + assert ws3.position == 4 @pytest.mark.django_db diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index 484ce6550..d46bff1f3 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -29,7 +29,7 @@ path("waitlist//coordadd/", views.waitlistedStudent.add_by_coord), path( "waitlist//count_waitlist/", - views.waitlistedStudent.count_waitist, + views.waitlistedStudent.count_waitlist, ), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index 09313a6cc..1bc875632 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -77,7 +77,7 @@ def add_student(section, user): # make this endpoint for only adding as a stude " database (Students %s)!", student_queryset.all(), ) - return PermissionDenied( + raise PermissionDenied( "An internal error occurred; email mentors@berkeley.edu" " immediately. (Duplicate students exist in the database (Students" f" {student_queryset.all()}))", @@ -143,12 +143,15 @@ def add_student(section, user): # make this endpoint for only adding as a stude return Response({"id": student.id}, status=status.HTTP_201_CREATED) -def swap_from_waitlist(section, user): +def swap_into_section(section, user): """ Helper Function: - Swaps a waitlisted student into a new section by dropping their current - section enrollment first if needed. + Swaps a user into a new section by dropping their current + section enrollment first if needed. Handles attendance cleanup. + + Returns the old section ID if the user was swapped, or None if + the user was not previously enrolled. """ active_student = user.student_set.filter( active=True, course=section.mentor.course @@ -219,7 +222,7 @@ def add_from_waitlist(pk): for waitlisted_student in waitlisted_students: try: - cascade_section_id = swap_from_waitlist( + cascade_section_id = swap_into_section( waitlisted_student.section, waitlisted_student.user ) except PermissionDenied: @@ -818,9 +821,23 @@ class RestrictedAction: def _student_add(self, request, section): """ - Adds a student to a section (initiated by a student) + Adds a student to a section (initiated by a student). + If the student is already enrolled in another section for the same + course, swaps them into this section instead. """ - return add_student(section, request.user) + course = section.mentor.course + user = request.user + + if user.student_set.filter(active=True, section=section).exists(): + raise PermissionDenied("You are already enrolled in this section.") + + if user.student_set.filter(active=True, course=course).exists(): + old_section_id = swap_into_section(section, user) + if old_section_id is not None: + add_from_waitlist(pk=old_section_id) + return Response(status=status.HTTP_200_OK) + + return add_student(section, user) @action(detail=True, methods=["get", "put"]) def wotd(self, request, pk=None): diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index 2796b48b5..4d8b03a76 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -7,7 +7,7 @@ from scheduler.views.utils import get_object_or_error from ..models import Section, User, WaitlistedStudent -from .section import add_student +from .section import add_from_waitlist, add_student, swap_into_section from .utils import logger @@ -20,6 +20,8 @@ def view(request, pk=None): GET: View all students on the waitlist for a section """ section = get_object_or_error(Section.objects, pk=pk) + if section.mentor is None: + raise NotFound("This section has no mentor assigned.") is_mentor = request.user == section.mentor.user is_coord = bool( section.mentor.course.coordinator_set.filter(user=request.user).count() @@ -31,8 +33,6 @@ def view(request, pk=None): return Response(WaitlistedStudentSerializer(waitlist_queryset, many=True).data) -# CURRENT ISSUES: 61a and eecs16b don't allow adding to waitlist? may not be an issue -# already works as a student so the put doesn't actually work @api_view(["PUT"]) def add(request, pk=None): """ @@ -58,66 +58,88 @@ def add(request, pk=None): bypass_enrollment_time=False, ) if response is not None: + # User was auto-enrolled (section had room), return the enrollment response return response + # User was added to the waitlist (not enrolled) log_enroll_result(True, request.user, section) return Response(status=status.HTTP_201_CREATED) @api_view(["PUT"]) -def add_by_coord(request, pk=None): # get this to work with only emails and no actions +def add_by_coord(request, pk=None): """ - Endpoint: /api/waitlist//add - pk= section id + Endpoint: /api/waitlist//coordadd + pk = section id - PUT: Add student to waitlist by coordinator. - emails: Array<{ [email: string]: string }>; - actions: { - [action: string]: string; - }; - """ + PUT: Add students to waitlist (or section if room) by coordinator. + Processes each email independently — failures for one email do not + prevent other emails from being processed. - with transaction.atomic(): - section = get_object_or_error(Section.objects, pk=pk) - section = Section.objects.select_for_update().get(pk=section.pk) + Request body: + emails: list of {"email": str} + """ - is_coord = bool( - section.mentor.course.coordinator_set.filter(user=request.user).count() - ) + section = get_object_or_error(Section.objects, pk=pk) - if not is_coord: - raise PermissionDenied("You must be a coord to perform this action.") + is_coord = bool( + section.mentor.course.coordinator_set.filter(user=request.user).count() + ) + if not is_coord: + raise PermissionDenied("You must be a coord to perform this action.") - data = request.data or {} + data = request.data or {} - if not data.get("emails"): - return Response( - {"error": "Must specify emails of students to waitlist"}, - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) + if not data.get("emails"): + return Response( + {"error": "Must specify emails of students to waitlist"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) - for email_obj in data.get("emails"): - email = email_obj.get("email") if isinstance(email_obj, dict) else email_obj - if not email: - return Response( - {"error": "Must specify email of student to waitlist"}, - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) + # Deduplicate and validate email list + email_set = set() + emails = [] + for obj in data.get("emails"): + email = obj.get("email") if isinstance(obj, dict) else obj + if email and email not in email_set: + emails.append(email) + email_set.add(email) + + if not emails: + return Response( + {"error": "Must specify email of student to waitlist"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + results = [] + for email in emails: + with transaction.atomic(): + section = Section.objects.select_for_update().get(pk=section.pk) user, _ = User.objects.get_or_create( username=email.split("@")[0], email=email ) - _add_to_waitlist_or_section( - section, - user, - bypass_enrollment_time=True, - ) + try: + _add_to_waitlist_or_section( + section, + user, + bypass_enrollment_time=True, + ) + results.append({"email": email, "status": "OK"}) + except PermissionDenied as exc: + results.append( + {"email": email, "status": "ERROR", "detail": str(exc.detail)} + ) log_enroll_result(True, request.user, section) - return Response(status=status.HTTP_200_OK) + return Response({"results": results}, status=status.HTTP_200_OK) def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): + """Add a user to a section or its waitlist. + + Returns a Response when the user was enrolled (or swapped) into the section, + or None when the user was added to the waitlist. + """ course = section.mentor.course if not user.can_waitlist_in_course( @@ -128,7 +150,16 @@ def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): if user.student_set.filter(active=True, section=section).exists(): raise PermissionDenied("User is already enrolled in this section.") + if user.mentor_set.filter(section__mentor__course=course).exists(): + raise PermissionDenied("Mentors cannot waitlist in a course they mentor.") + if not section.is_section_full: + # If user is already enrolled in another section, swap them + if user.student_set.filter(active=True, course=course).exists(): + old_section_id = swap_into_section(section, user) + if old_section_id is not None: + add_from_waitlist(pk=old_section_id) + return Response(status=status.HTTP_200_OK) return add_student(section, user) if section.is_waitlist_full: @@ -144,10 +175,7 @@ def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): ).exists(): raise PermissionDenied("User is already waitlisted in this section.") - waitlisted_student = WaitlistedStudent.objects.create( - user=user, section=section, course=course - ) - waitlisted_student.save() + WaitlistedStudent.objects.create(user=user, section=section, course=course) return None @@ -155,9 +183,9 @@ def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): def drop(request, pk=None): """ Endpoint: /api/waitlist//drop - pk= section id + pk = waitlisted student id - PATCH: Drop a student off the waitlist. Pass in waitlisted student ID + PATCH: Drop a student off the waitlist. - sets to inactive. Called by user or coordinator. """ @@ -204,7 +232,7 @@ def log_enroll_result(success, user, section, reason=None): @api_view(["GET"]) -def count_waitist(request, pk=None): +def count_waitlist(request, pk=None): """ Endpoint: /api/waitlist//count_waitlist pk= section id From 058a1580deac96444a9f32f37ba7e6bf1e1fe9e3 Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Wed, 25 Feb 2026 04:41:21 -0800 Subject: [PATCH 27/28] update frontend to show warning modals --- .../frontend/src/components/course/Course.tsx | 1 + .../src/components/course/SectionCard.tsx | 97 ++++++++++++++++--- .../section/CoordinatorAddStudentModal.tsx | 3 + .../src/components/section/StudentSection.tsx | 70 ++++++++++++- .../frontend/src/utils/queries/sections.tsx | 50 ++++++++++ csm_web/scheduler/models.py | 35 ++----- .../tests/models/test_waitlisted_student.py | 26 ++--- csm_web/scheduler/urls.py | 4 + csm_web/scheduler/views/waitlistedStudent.py | 56 ++++++++++- 9 files changed, 280 insertions(+), 62 deletions(-) diff --git a/csm_web/frontend/src/components/course/Course.tsx b/csm_web/frontend/src/components/course/Course.tsx index 4c5e8e4d9..5bab99999 100644 --- a/csm_web/frontend/src/components/course/Course.tsx +++ b/csm_web/frontend/src/components/course/Course.tsx @@ -205,6 +205,7 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): key={section.id} userIsCoordinator={userIsCoordinator} courseOpen={course.enrollmentOpen} + courseId={course.id} {...section} /> )) diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx index 2e54aa80e..49c26a643 100644 --- a/csm_web/frontend/src/components/course/SectionCard.tsx +++ b/csm_web/frontend/src/components/course/SectionCard.tsx @@ -2,12 +2,13 @@ import React, { useState } from "react"; import { Link, Navigate } from "react-router-dom"; import { formatSpacetimeInterval } from "../../utils/datetime"; +import { useProfiles } from "../../utils/queries/base"; import { EnrollUserMutationResponse, useEnrollUserMutation, useEnrollStudentToWaitlistMutation } from "../../utils/queries/sections"; -import { Mentor, Spacetime } from "../../utils/types"; +import { Mentor, Role, Spacetime } from "../../utils/types"; import Modal, { ModalCloser } from "../Modal"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; @@ -29,6 +30,7 @@ interface SectionCardProps { courseOpen: boolean; numStudentsWaitlisted: number; waitlistCapacity: number; + courseId: number; } export const SectionCard = ({ @@ -41,7 +43,8 @@ export const SectionCard = ({ userIsCoordinator, courseOpen, numStudentsWaitlisted, - waitlistCapacity + waitlistCapacity, + courseId }: SectionCardProps): React.ReactElement => { /** * Mutation to enroll a student in the section. @@ -52,6 +55,8 @@ export const SectionCard = ({ */ const enrollStudentWaitlistMutation = useEnrollStudentToWaitlistMutation(id); + const { data: profiles } = useProfiles(); + /** * Whether to show the modal (after an attempt to enroll). */ @@ -64,6 +69,37 @@ export const SectionCard = ({ * The error message if the enrollment failed. */ const [errorMessage, setErrorMessage] = useState(""); + /** + * Whether to show the swap/waitlist confirmation modal. + */ + const [showSwapConfirm, setShowSwapConfirm] = useState(false); + /** + * Whether the pending action is a waitlist join (vs direct enroll). + */ + const [pendingIsWaitlist, setPendingIsWaitlist] = useState(false); + + /** + * Check if the user is already enrolled in another section of this course. + */ + const isAlreadyEnrolled = profiles?.some(p => p.courseId === courseId && p.role === Role.STUDENT) ?? false; + + /** + * Perform the actual mutation (enroll or waitlist). + */ + const performEnroll = (useWaitlist: boolean) => { + const mutation = useWaitlist ? enrollStudentWaitlistMutation : enrollStudentMutation; + mutation.mutate(undefined, { + onSuccess: () => { + setEnrollmentSuccessful(true); + setShowModal(true); + }, + onError: ({ detail }: EnrollUserMutationResponse) => { + setEnrollmentSuccessful(false); + setErrorMessage(detail); + setShowModal(true); + } + }); + }; /** * Handle enrollment in the section. @@ -79,19 +115,15 @@ export const SectionCard = ({ // Determine if we should use waitlist mutation (enrolled capacity is full but waitlist is not full) const isEnrolledFull = numStudentsEnrolled >= capacity; const shouldUseWaitlist = isEnrolledFull && numStudentsWaitlisted < waitlistCapacity; - const mutation = shouldUseWaitlist ? enrollStudentWaitlistMutation : enrollStudentMutation; - mutation.mutate(undefined, { - onSuccess: () => { - setEnrollmentSuccessful(true); - setShowModal(true); - }, - onError: ({ detail }: EnrollUserMutationResponse) => { - setEnrollmentSuccessful(false); - setErrorMessage(detail); - setShowModal(true); - } - }); + // If user is already enrolled in another section, show a confirmation warning + if (isAlreadyEnrolled) { + setPendingIsWaitlist(shouldUseWaitlist); + setShowSwapConfirm(true); + return; + } + + performEnroll(shouldUseWaitlist); }; /** @@ -149,6 +181,43 @@ export const SectionCard = ({ return ( + {showSwapConfirm && ( + setShowSwapConfirm(false)}> +
+ {pendingIsWaitlist ? ( + <> +

Join waitlist?

+

+ You are currently enrolled in another section of this course. When a spot opens up on this waitlist, + you will be automatically dropped from your current section and enrolled in this one. +

+ + ) : ( + <> +

Switch sections?

+

+ You are currently enrolled in another section of this course. Enrolling here will{" "} + drop you from your current section and enroll you in this one. +

+ + )} +
+ + +
+
+
+ )} {showModal && {modalContents()}}
diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx index 56e0838c0..03857ed1b 100644 --- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx +++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx @@ -303,6 +303,9 @@ export function CoordinatorAddStudentModal({ conflictDetail = "User is already a coordinator for the course!"; } else if (email_obj.detail.reason === "mentor") { conflictDetail = "User is already a mentor for the course!"; + } else { + // display the reason string directly (e.g. from waitlist coord-add) + conflictDetail = email_obj.detail.reason; } drop_disabled = true; } else if (email_obj.detail.section.id == sectionId) { diff --git a/csm_web/frontend/src/components/section/StudentSection.tsx b/csm_web/frontend/src/components/section/StudentSection.tsx index 8e2770fd3..fa35bcd41 100644 --- a/csm_web/frontend/src/components/section/StudentSection.tsx +++ b/csm_web/frontend/src/components/section/StudentSection.tsx @@ -5,8 +5,10 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { DEFAULT_TIMEZONE } from "../../utils/datetime"; import { useDropUserMutation, + useDropWaitlistMutation, useStudentAttendances, - useStudentSubmitWordOfTheDayMutation + useStudentSubmitWordOfTheDayMutation, + useWaitlistPosition } from "../../utils/queries/sections"; import { AttendancePresence, Mentor, Override, Role, Spacetime } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; @@ -39,6 +41,8 @@ export function WaitlistStudentSection({ override, associatedProfileId }: StudentSectionType) { + const { data: positionData, isSuccess: positionLoaded } = useWaitlistPosition(id); + return ( @@ -51,8 +55,17 @@ export function WaitlistStudentSection({ spacetimes={spacetimes} override={override} associatedProfileId={associatedProfileId} + isWaitlisted /> -

Waitlist Number

+
+ + {positionLoaded ?
You are #{positionData.position} on the waitlist
: } +

+ You will be automatically enrolled when a spot opens up. +

+
+ +
} /> @@ -106,13 +119,14 @@ interface StudentSectionInfoProps { spacetimes: Spacetime[]; override?: Override; associatedProfileId: number; + isWaitlisted?: boolean; } // eslint-disable-next-line no-unused-vars -function StudentSectionInfo({ mentor, spacetimes, associatedProfileId }: StudentSectionInfoProps) { +function StudentSectionInfo({ mentor, spacetimes, associatedProfileId, isWaitlisted }: StudentSectionInfoProps) { return ( -

My Section

+

{isWaitlisted ? "My Waitlisted Section" : "My Section"}

{mentor && ( @@ -129,7 +143,7 @@ function StudentSectionInfo({ mentor, spacetimes, associatedProfileId }: Student override={override} /> ))} - + {!isWaitlisted && }
); @@ -185,6 +199,52 @@ function DropSection({ profileId }: DropSectionProps) { } } +enum DropWaitlistStage { + INITIAL = "INITIAL", + CONFIRM = "CONFIRM", + DROPPED = "DROPPED" +} + +function DropWaitlist({ profileId }: DropSectionProps) { + const waitlistDropMutation = useDropWaitlistMutation(profileId); + const [stage, setStage] = useState(DropWaitlistStage.INITIAL); + + const performDrop = () => { + waitlistDropMutation.mutate(undefined, { + onSuccess: () => { + setStage(DropWaitlistStage.DROPPED); + } + }); + }; + + switch (stage) { + case DropWaitlistStage.INITIAL: + return ( + +
Leave Waitlist
+ +
+ ); + case DropWaitlistStage.CONFIRM: + return ( + setStage(DropWaitlistStage.INITIAL)}> +
+
Are you sure you want to leave the waitlist?
+

You will lose your position and are not guaranteed a spot if you rejoin.

+ +
+
+ ); + case DropWaitlistStage.DROPPED: + return ; + } +} + interface StudentSectionAttendanceProps { associatedProfileId: number; id: number; diff --git a/csm_web/frontend/src/utils/queries/sections.tsx b/csm_web/frontend/src/utils/queries/sections.tsx index a78002073..71885cc1f 100644 --- a/csm_web/frontend/src/utils/queries/sections.tsx +++ b/csm_web/frontend/src/utils/queries/sections.tsx @@ -458,6 +458,56 @@ export const useEnrollStudentToWaitlistMutation = ( return mutationResult; }; +/** + * Hook to get the current user's waitlist position for a section. + * + * Returns { position: number } where position is 1-indexed rank. + */ +export const useWaitlistPosition = (sectionId: number): UseQueryResult<{ position: number }, ServerError> => { + const queryResult = useQuery<{ position: number }, Error>( + ["waitlist", sectionId, "position"], + async () => { + const response = await fetchNormalized(`/waitlist/${sectionId}/position`); + if (response.ok) { + return await response.json(); + } else { + handlePermissionsError(response.status); + throw new ServerError(`Failed to fetch waitlist position for section ${sectionId}`); + } + }, + { retry: handleRetry } + ); + + handleError(queryResult); + return queryResult; +}; + +/** + * Hook to drop the current user from a waitlist. + * + * Uses the waitlisted student profile ID (associatedProfileId when role is WAITLIST). + */ +export const useDropWaitlistMutation = (waitlistedStudentId: number) => { + const queryClient = useQueryClient(); + const mutationResult = useMutation( + async () => { + const response = await fetchWithMethod(`waitlist/${waitlistedStudentId}/drop`, HTTP_METHODS.PATCH); + if (!response.ok) { + throw new ServerError(`Failed to drop from waitlist`); + } + }, + { + onSuccess: () => { + queryClient.invalidateQueries(["sections"]); + queryClient.invalidateQueries(["waitlist"]); + queryClient.invalidateQueries(["profiles"]); + } + } + ); + + return mutationResult; +}; + interface EnrollStudentMutationRequest { emails: Array<{ [email: string]: string }>; actions: { diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index fe1113461..5dfe81b71 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -279,39 +279,18 @@ class Meta: ordering = ["position", "timestamp"] def save(self, *args, **kwargs): - # manually assigning a position to a student - if self.position is not None: - conflicting_students = ( - WaitlistedStudent.objects.filter( - section=self.section, position__gte=self.position - ) + if self.active and self.position is None: + max_pos = ( + WaitlistedStudent.objects.filter(section=self.section, active=True) .exclude(pk=self.pk) - .order_by("position") + .order_by("-position") + .values_list("position", flat=True) + .first() ) - # shifting over other student's positions - previous_position = self.position - for student in conflicting_students: - if student.position <= previous_position: - student.position += 1 - previous_position = student.position - WaitlistedStudent.objects.filter(pk=student.pk).update( - position=student.position - ) + self.position = (max_pos or 0) + 1 super().save(*args, **kwargs) - # If position is not set, assign it based on timestamp - if self.position is None: - waitlisted_students = WaitlistedStudent.objects.filter( - section=self.section, active=True - ) - # assigning a position based on timestamp - if waitlisted_students.count() == 1: - self.position = 1 - else: - self.position = waitlisted_students.count() - WaitlistedStudent.objects.filter(pk=self.pk).update(position=self.position) - class Student(Profile): """ diff --git a/csm_web/scheduler/tests/models/test_waitlisted_student.py b/csm_web/scheduler/tests/models/test_waitlisted_student.py index a9b1d849b..5f999e685 100644 --- a/csm_web/scheduler/tests/models/test_waitlisted_student.py +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -515,8 +515,9 @@ def test_user_drops_from_nonexistent_waitlisted_student(setup_waitlist, client): def test_positions_update_properly(): """ Given a waitlist with existing students, - When a coordinator adds a student on the waitlist at a certain position, - The waitlist students have the correct order. + When new students are added and dropped, + Positions are auto-assigned as max+1 and gaps are left after drops + (rank is computed at query time, not by compacting). """ course = CourseFactory.create() mentor_user = UserFactory.create() @@ -539,21 +540,22 @@ def test_positions_update_properly(): assert ws2.position == 2 assert ws3.position == 3 - # Insert a new student at position 2, should shift ws2 -> 3 and ws3 -> 4 - user4 = UserFactory.create() - ws4 = WaitlistedStudent.objects.create( - user=user4, course=course, section=section, position=2 - ) + # Drop ws2; positions are NOT compacted — ws1 stays 1, ws3 stays 3 + ws2.active = False + ws2.save() ws1.refresh_from_db() - ws2.refresh_from_db() ws3.refresh_from_db() - ws4.refresh_from_db() assert ws1.position == 1 - assert ws4.position == 2 - assert ws2.position == 3 - assert ws3.position == 4 + assert ws3.position == 3 + + # Add a new student; should get max(1,3) + 1 = 4 + user4 = UserFactory.create() + ws4 = WaitlistedStudent.objects.create(user=user4, course=course, section=section) + ws4.refresh_from_db() + + assert ws4.position == 4 @pytest.mark.django_db diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index d46bff1f3..bd4d7b9ba 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -31,5 +31,9 @@ "waitlist//count_waitlist/", views.waitlistedStudent.count_waitlist, ), + path( + "waitlist//position/", + views.waitlistedStudent.position, + ), path("export/", views.export_data), ] diff --git a/csm_web/scheduler/views/waitlistedStudent.py b/csm_web/scheduler/views/waitlistedStudent.py index 4d8b03a76..ec05f8699 100644 --- a/csm_web/scheduler/views/waitlistedStudent.py +++ b/csm_web/scheduler/views/waitlistedStudent.py @@ -112,6 +112,7 @@ def add_by_coord(request, pk=None): ) results = [] + any_errors = False for email in emails: with transaction.atomic(): section = Section.objects.select_for_update().get(pk=section.pk) @@ -125,13 +126,32 @@ def add_by_coord(request, pk=None): bypass_enrollment_time=True, ) results.append({"email": email, "status": "OK"}) + logger.info( + " User %s added to Waitlist for Section %s by coordinator %s", + user, + section, + request.user, + ) except PermissionDenied as exc: + any_errors = True results.append( - {"email": email, "status": "ERROR", "detail": str(exc.detail)} + { + "email": email, + "status": "CONFLICT", + "detail": {"reason": str(exc.detail)}, + } + ) + logger.warning( + " User %s not added to Waitlist for Section %s: %s", + user, + section, + exc.detail, ) - log_enroll_result(True, request.user, section) - return Response({"results": results}, status=status.HTTP_200_OK) + response_data = {"errors": {}, "progress": results} + if any_errors: + return Response(response_data, status=status.HTTP_422_UNPROCESSABLE_ENTITY) + return Response(status=status.HTTP_200_OK) def _add_to_waitlist_or_section(section, user, *, bypass_enrollment_time=False): @@ -239,3 +259,33 @@ def count_waitlist(request, pk=None): """ section = get_object_or_error(Section.objects, pk=pk) return Response(section.current_waitlist_count) + + +@api_view(["GET"]) +def position(request, pk=None): + """ + Endpoint: /api/waitlist//position + pk = section id + + GET: Get the current user's position on the waitlist for a section. + Returns {"position": } where position is 1-indexed rank among + active waitlisted students. + """ + section = get_object_or_error(Section.objects, pk=pk) + waitlisted_student = WaitlistedStudent.objects.filter( + active=True, section=section, user=request.user + ).first() + if waitlisted_student is None: + raise NotFound("You are not on the waitlist for this section.") + + # Count how many active waitlisted students have a higher-priority (lower) position + rank = ( + WaitlistedStudent.objects.filter( + active=True, + section=section, + position__lt=waitlisted_student.position, + ).count() + + 1 + ) + + return Response({"position": rank}) From 914a2827f552c67fa816d7a14efcb345afdd4f1e Mon Sep 17 00:00:00 2001 From: Gabriel Han Date: Wed, 25 Feb 2026 04:46:54 -0800 Subject: [PATCH 28/28] add tests --- .../tests/models/test_waitlisted_student.py | 186 +++++++++++++++++- 1 file changed, 183 insertions(+), 3 deletions(-) diff --git a/csm_web/scheduler/tests/models/test_waitlisted_student.py b/csm_web/scheduler/tests/models/test_waitlisted_student.py index 5f999e685..f11188635 100644 --- a/csm_web/scheduler/tests/models/test_waitlisted_student.py +++ b/csm_web/scheduler/tests/models/test_waitlisted_student.py @@ -89,8 +89,8 @@ def test_user_cannot_enroll_in_course(setup_waitlist, client): @pytest.mark.django_db def test_user_can_waitlist_only_once(setup_waitlist, client): """ - Given a section that is full, - When a user attempts to enroll directly, + Given a user already on the waitlist for a section, + When they attempt to join the same waitlist again, Then they are denied with an appropriate error. """ _, waitlisted_student_user, _, section = setup_waitlist @@ -436,7 +436,7 @@ def test_user_drops_themselves_successfully(setup_waitlist, client): client.force_login(waitlisted_student_user) response = client.patch(f"/api/waitlist/{waitlisted_student.pk}/drop/") - assert response.status_code == 204 # Unsure why 200 is returned + assert response.status_code == 204 waitlisted_student.refresh_from_db() assert waitlisted_student.active is False @@ -698,3 +698,183 @@ def test_waitlist_count_endpoint(client): assert response.status_code == 200 assert int(response.content.decode("utf-8")) == 1 + + +@pytest.mark.django_db +def test_position_endpoint_returns_rank(client): + """ + Given a waitlist with gaps in position numbers, + When a user requests their position, + Then the endpoint returns the 1-indexed rank (count of active students + with lower positions + 1), not the raw position value. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=5) + + # Fill the section so users are forced onto the waitlist + filler_user = UserFactory.create() + Student.objects.create(user=filler_user, course=course, section=section) + + # Create 3 waitlisted students (positions 1, 2, 3) + user1 = UserFactory.create() + WaitlistedStudent.objects.create(user=user1, course=course, section=section) + user2 = UserFactory.create() + ws2 = WaitlistedStudent.objects.create(user=user2, course=course, section=section) + user3 = UserFactory.create() + WaitlistedStudent.objects.create(user=user3, course=course, section=section) + + # Verify initial ranks + client.force_login(user1) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 1 + + client.force_login(user3) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 3 + + # Drop user2 — creates a gap (positions 1, _, 3) + ws2.active = False + ws2.save() + + # user3's rank should now be 2 (only user1 has a lower position) + client.force_login(user3) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 200 + assert response.data["position"] == 2 + + # A user not on the waitlist gets 404 + non_waitlisted = UserFactory.create() + client.force_login(non_waitlisted) + response = client.get(f"/api/waitlist/{section.pk}/position/") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_coord_add_success_and_mixed_results(client): + """ + Given a coordinator adding students by email, + When some emails succeed and some are already enrolled, + Then the response reports per-email status and returns 422 on errors. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=2, waitlist_capacity=3) + + coord_user = UserFactory.create() + CoordinatorFactory.create(user=coord_user, course=course) + + # Successful add — section has room, so user gets enrolled + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "new_student@berkeley.edu"}]}, + content_type="application/json", + ) + assert response.status_code == 200 + + # Add the same email again — should conflict + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": [{"email": "new_student@berkeley.edu"}]}, + content_type="application/json", + ) + assert response.status_code == 422 + assert response.data["progress"][0]["status"] == "CONFLICT" + + # Mixed batch — one new, one duplicate + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={ + "emails": [ + {"email": "another_student@berkeley.edu"}, + {"email": "new_student@berkeley.edu"}, + ] + }, + content_type="application/json", + ) + assert response.status_code == 422 + statuses = [r["status"] for r in response.data["progress"]] + assert statuses == ["OK", "CONFLICT"] + + # Empty emails returns 422 + client.force_login(coord_user) + response = client.put( + f"/api/waitlist/{section.pk}/coordadd/", + data={"emails": []}, + content_type="application/json", + ) + assert response.status_code == 422 + + +@pytest.mark.django_db +def test_view_waitlist_permissions(client): + """ + Given a section with a waitlist, + When different users request the waitlist view, + Then only the mentor and coordinators are allowed to see it. + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=1, waitlist_capacity=3) + + # Fill section so next user gets waitlisted + filler_user = UserFactory.create() + Student.objects.create(user=filler_user, course=course, section=section) + + waitlisted_user = UserFactory.create() + WaitlistedStudent.objects.create( + user=waitlisted_user, course=course, section=section + ) + + # Mentor can view + client.force_login(mentor_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 200 + assert len(response.data) == 1 + + # Coordinator can view + coord_user = UserFactory.create() + CoordinatorFactory.create(user=coord_user, course=course) + client.force_login(coord_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 200 + assert len(response.data) == 1 + + # Random user cannot view + random_user = UserFactory.create() + client.force_login(random_user) + response = client.get(f"/api/waitlist/{section.pk}/") + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_drop_preserves_position(): + """ + Given a waitlisted student with an assigned position, + When they are dropped, + Then the position value is preserved (not cleared to None). + """ + course = CourseFactory.create() + mentor_user = UserFactory.create() + mentor = MentorFactory.create(course=course, user=mentor_user) + section = SectionFactory.create(mentor=mentor, capacity=5, waitlist_capacity=5) + + user = UserFactory.create() + ws = WaitlistedStudent.objects.create(user=user, course=course, section=section) + ws.refresh_from_db() + assert ws.position == 1 + + ws.active = False + ws.save() + ws.refresh_from_db() + + assert ws.active is False + assert ws.position == 1 # position preserved, not cleared