diff --git a/csm_web/scheduler/migrations/0033_swap.py b/csm_web/scheduler/migrations/0033_swap.py new file mode 100644 index 00000000..70996237 --- /dev/null +++ b/csm_web/scheduler/migrations/0033_swap.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.16 on 2023-04-11 01:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('scheduler', '0032_word_of_the_day'), + ] + + operations = [ + migrations.CreateModel( + name='Swap', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='receiver', to='scheduler.student')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='sender', to='scheduler.student')), + ], + ), + ] diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 828a50fa..9737efc7 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -240,6 +240,15 @@ class Meta: unique_together = ("user", "section") +class Swap(models.Model): + """ + Represents a given "instance" of a swap. Every time a swap is requested between two users, + a Swap instance is initialized containing references to the respective students. + """ + sender = models.ForeignKey(Student, related_name="sender", on_delete=models.CASCADE) + receiver = models.ForeignKey(Student, related_name="receiver", on_delete=models.CASCADE) + + class Mentor(Profile): """ Represents a given "instance" of a mentor. Every section a mentor teaches in every course should diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index bcb1636e..c8c25979 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from enum import Enum from django.utils import timezone -from .models import Attendance, Course, Link, SectionOccurrence, User, Student, Section, Mentor, Override, Spacetime, Coordinator, DayOfWeekField, Resource, Worksheet, Matcher, MatcherSlot, MatcherPreference +from .models import Attendance, Swap, Course, Link, SectionOccurrence, User, Student, Section, Mentor, Override, Spacetime, Coordinator, DayOfWeekField, Resource, Worksheet, Matcher, MatcherSlot, MatcherPreference class Role(Enum): @@ -58,6 +58,10 @@ class Meta: fields = ("spacetime", "date") read_only_fields = ("spacetime", "date") +class SwapSerializer(serializers.ModelSerializer): + class Meta: + model = Swap + fields = ("id", "sender", "receiver") class SpacetimeSerializer(serializers.ModelSerializer): diff --git a/csm_web/scheduler/tests/models/test_swap.py b/csm_web/scheduler/tests/models/test_swap.py new file mode 100644 index 00000000..fa51367b --- /dev/null +++ b/csm_web/scheduler/tests/models/test_swap.py @@ -0,0 +1,234 @@ +import pytest +import json + +from django.urls import reverse +from scheduler.models import Swap +from scheduler.factories import ( + UserFactory, + CourseFactory, + SectionFactory, + StudentFactory, + MentorFactory +) + + +@pytest.fixture +def setup_scheduler(db): + """ + Creates a pair of sections within the same course, each with a mentor and student. + """ + # Setup course + course = CourseFactory.create() + # Setup sections + section_one = create_section(course) + section_two = create_section(course) + # Setup students + section_one_students = create_students(course, section_one, 3) + section_two_students = create_students(course, section_two, 3) + + return course, section_one, section_two, section_one_students, section_two_students + + +@pytest.mark.django_db +def test_basic_swap_request_success(client, setup_scheduler): + """ + Tests that a student can successfully request a swap. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender_student, reciever_student = section_one_students[0], section_two_students[0] + # Create a swap request + create_swap_request(client, sender_student, reciever_student) + # Make sure that the swap request was created + assert Swap.objects.filter(receiver=reciever_student).exists() + + +@pytest.mark.django_db +def test_empty_swap_list(client, setup_scheduler): + """ + Test getting an empty list of swaps for a student. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender_student = section_one_students[0] + # Get the list of swaps for the sender + swaps = get_swap_requests(client, sender_student) + # Decode get response to UTF-8 + swaps = json.loads(swaps.content.decode("utf-8")) + # Make sure that the list of swaps is empty + assert len(swaps["sender"]) == 0 + + +@pytest.mark.django_db +def test_non_empty_swap_list(client, setup_scheduler): + """ + Test getting a non-empty list of swaps for a student. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender_student, receiver_student = section_one_students[0], section_two_students[0] + # Create a swap request + create_swap_request(client, sender_student, receiver_student) + # Get the list of swaps for the sender + swaps = get_swap_requests(client, sender_student) + # Decode get response to UTF-8 + swaps = json.loads(swaps.content.decode("utf-8")) + # Make sure that the swap request was created + assert Swap.objects.filter(receiver=receiver_student).exists() + # Make sure that the swap request is in the list of swaps + assert len(swaps["sender"]) != 0 and swaps["sender"][0]["receiver"] == receiver_student.id + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ["email"], + [ + # empty email + (" ",), + # invalid email + ("invalid",), + # non-existent email + ("abcd@example.io",), + # valid email + (setup_scheduler,) + ], + ids=["empty email", "invalid email", "non-existent email", "valid email"], +) +def test_request_swap_invalid_email(client, setup_scheduler, email, request): + """ + Tests that a student cannot request a swap with an invalid email. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender, receiver = section_one_students[0], section_two_students[0] + receiver_email = email + if (type(receiver_email) != str): + receiver_email = receiver.user.email + receiver.user.email = receiver_email + response = create_swap_request(client, sender, receiver) + if type(email) == str: + assert response.status_code == 404 + else: + assert response.status_code == 201 + # Check that the swap request was created + assert Swap.objects.filter(receiver=receiver).exists() + + +@pytest.mark.django_db +def test_accept_swap_request(client, setup_scheduler): + """ + Tests that a student can accept a swap request. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender, receiver = section_one_students[0], section_two_students[0] + create_swap_request(client, sender, receiver) + # Get the swap_id + swap_id = json.loads(get_swap_requests(client, sender).content.decode("utf-8"))["sender"][0]["id"] + accept_swap_request(client, receiver, swap_id) + # Record old section ids + old_sender_section_id, old_receiver_section_id = sender.section.id, receiver.section.id + # Get the new section ids + sender.refresh_from_db() + receiver.refresh_from_db() + # Make sure that the swap was accepted + assert sender.section.id == old_receiver_section_id + assert receiver.section.id == old_sender_section_id + # Make sure that all swaps for both students are cleared + assert len(json.loads(get_swap_requests(client, sender).content.decode("utf-8"))["sender"]) == 0 + assert len(json.loads(get_swap_requests(client, receiver).content.decode("utf-8"))["sender"]) == 0 + + +@pytest.mark.django_db +def test_reject_swap_request(client, setup_scheduler): + """ + Tests that a student can reject a swap request. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender, receiver = section_one_students[0], section_two_students[0] + create_swap_request(client, sender, receiver) + # Get the swap_id + swap_id = json.loads(get_swap_requests(client, sender).content.decode("utf-8"))["sender"][0]["id"] + reject_swap_request(client, receiver, swap_id) + # Make sure that the swap was rejected + assert len(json.loads(get_swap_requests(client, receiver).content.decode("utf-8"))["sender"]) == 0 + + +@pytest.mark.django_db +def test_swap_conflict_clear(client, setup_scheduler): + """ + Test for when a student has multiple swap requests pending and one of them + is accepted. In this case, all other swap requests from that user should be + invalidated and cleared. + """ + section_one_students, section_two_students = setup_scheduler[3:] + sender = section_one_students[0] + receiver_one, receiver_two = section_two_students[0], section_two_students[1] + create_swap_request(client, sender, receiver_one) + create_swap_request(client, sender, receiver_two) + # Get first swap_id + swap_id_one = json.loads(get_swap_requests(client, sender).content.decode("utf-8"))["sender"][0]["id"] + # Accept first swap request + accept_swap_request(client, receiver_one, swap_id_one) + # Make sure that the second swap request was cleared + assert len(json.loads(get_swap_requests(client, receiver_two).content.decode("utf-8"))["receiver"]) == 0 + + +def create_students(course, section, quantity): + """ + Creates a given number of students for a given section. + """ + student_users = UserFactory.create_batch(quantity) + students = [] + for student_user in student_users: + student = StudentFactory.create(user=student_user, course=course, section=section) + students.append(student) + return students + + +def create_section(course): + """ + Creates a section for a given course. + """ + mentor_user = UserFactory.create() + mentor = MentorFactory.create(user=mentor_user, course=course) + section = SectionFactory.create(mentor=mentor) + return section + + +def create_swap_request(client, sender, reciever): + """ + Creates a swap request between two students. + """ + client.force_login(sender.user) + post_url = reverse("section-swap-no-id", args=[sender.section.id]) + data = json.dumps({"receiver_email": reciever.user.email, "student_id": sender.id}) + response = client.post(post_url, data=data, content_type="application/json") + return response + + +def get_swap_requests(client, sender): + """ + Gets a list of swap requests for a given student. + """ + client.force_login(sender.user) + get_url = reverse("section-swap-no-id", args=[sender.section.id]) + body = {"student_id": sender.id} + response = client.get(get_url, body, content_type="application/json") + return response + + +def accept_swap_request(client, receiver, swap_id): + """ + Accepts a swap request between two students. + """ + client.force_login(receiver.user) + post_url = reverse("section-swap-with-id", kwargs={"pk": receiver.section.id, "swap_id": swap_id}) + data = json.dumps({"student_id": receiver.id}) + response = client.post(post_url, data, content_type="application/json") + return response + + +def reject_swap_request(client, receiver, swap_id): + """ + Receiver rejects a swap request from a sender. + """ + client.force_login(receiver.user) + delete_url = reverse("section-swap-with-id", kwargs={"pk": receiver.section.id, "swap_id": swap_id}) + response = client.delete(delete_url) + return response diff --git a/csm_web/scheduler/views/section.py b/csm_web/scheduler/views/section.py index c79ccde0..45784a28 100644 --- a/csm_web/scheduler/views/section.py +++ b/csm_web/scheduler/views/section.py @@ -1,12 +1,14 @@ import datetime import re -from django.db import transaction +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.db import transaction, IntegrityError, DatabaseError from django.db.models import Prefetch, Q from django.utils import timezone from rest_framework import status +from rest_framework import permissions from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, NotFound, ValidationError as BadRequestError from rest_framework.response import Response from scheduler.models import ( Attendance, @@ -17,12 +19,14 @@ Spacetime, Student, User, + Swap, ) from scheduler.serializers import ( SectionOccurrenceSerializer, SectionSerializer, SpacetimeSerializer, StudentSerializer, + SwapSerializer, ) from .utils import get_object_or_error, log_str, logger, viewset_with @@ -779,3 +783,198 @@ def wotd(self, request, pk=None): return Response({}, status=status.HTTP_200_OK) raise PermissionDenied() + + + @action(detail=True, methods=["GET", "POST"], url_path='swap') + def swap_no_id(self, request, pk=None, swap_id=None): + """ + GET: Returns all relevant Swap objects for current user. + Request format: + { student_id: int } + Response format: + { + receiver: [`List of Swap objects`], + sender: [`List of Swap objects`] + } + Where each Swap object has the following format: + { + swap_id: int, + name: string, + mentor_name: string, + time: DateTime, + } + + POST: Handles creating a new Swap instance when a Swap is requested or + processes an existing Swap request. + Request format: + { email: string } + `email` is the email of the student who will be receiving the swap request. + Response format: + {} + With status code of 201 if successful, 400 if not. + """ + section = get_object_or_error(Section.objects, pk=pk) + if request.method != "GET": + student_id = request.data.get("student_id", -1) + else: + student_id = request.query_params.get("student_id", -1) + + # check if student_id is specified in request + if student_id == -1: + raise BadRequestError("The `student_id` field in the request cannot be empty," + "please specify student.") + + # check if student exists + try: + student = get_object_or_error(Student.objects, id=student_id) + except ObjectDoesNotExist as e: + logger.info(e) + raise NotFound("No student found with id: " + str(student_id)) + if request.method == "GET": + if section.mentor.user == request.user: + raise BadRequestError("Cannot `GET` swaps for a mentor.") + outgoing_swaps = Swap.objects.filter(sender=student) + incoming_swaps = Swap.objects.filter(receiver=student) + if outgoing_swaps is None or incoming_swaps is None: + raise NotFound("No swaps found for student with id: " + str(student_id)) + + student_swaps = { + "sender": SwapSerializer(outgoing_swaps, many=True).data, + "receiver": SwapSerializer(incoming_swaps, many=True).data, + } + return Response(student_swaps, status=status.HTTP_200_OK) + + if request.method == "POST": + if section.mentor.user == request.user: + logger.error("Cannot `POST` swaps for a mentor.") + raise BadRequestError("Cannot `POST` swaps for a mentor.") + + '''Create a new Swap object between the the current student + and the student with the email specified in the request. This is essentially + creating a swap invite.''' + receiver_email = request.data.get("receiver_email", None) + + # Check if receiver_email is empty + if receiver_email is None: + raise BadRequestError("The `receiver_email` field in the request cannot be empty," + "please specify a receiver.") + + # Check if receiver_email is valid + try: + receiver = get_object_or_error(Student.objects, user__email=receiver_email) + except ObjectDoesNotExist as e: + raise NotFound("Invalid email provided.") + + # Check if receiver is the sender + receiver_is_sender = receiver == student + if receiver_is_sender: + raise BadRequestError("Cannot send a swap request to yourself.") + + # Check if receiver has already sent a swap request to the sender + incoming_swaps = Swap.objects.filter(receiver=student) + if incoming_swaps is None: + logger.error("Could not fetch incoming swaps for student with id: %s", student_id) + raise NotFound("Could not fetch incoming swaps for student with id: " + + str(student_id)) + for swap in incoming_swaps: + if swap.sender == receiver: + raise NotFound("Cannot send a swap request to someone who has already" + "sent you a swap request.") + # Create Swap request + try: + swap = Swap.objects.create(sender=student, receiver=receiver) + except (IntegrityError, ValidationError, DatabaseError) as e: + logger.info(e) + logger.error( + " User %s requested a swap with %s", + log_str(student.user), + log_str(receiver.user) + ) + raise NotFound("Could not create swap for student with id: " + str(student_id)) + # Successfuly created swap + logger.info( + " User %s requested a swap with %s", + log_str(student.user), + log_str(receiver.user), + ) + return Response({}, status=status.HTTP_201_CREATED) + + + @action(detail=True, methods=["POST", "DELETE"], url_path='swap/(?P[^/.]+)') + def swap_with_id(self, request, pk=None, swap_id=None): + '''If swap_id is not None, initiate the swap between the two students. This should + be done in one atomic transaction. If either or both of the swaps are already + deleted, then return a 404 error.''' + student_id = request.data.get("student_id", -1) + if student_id == -1: + raise BadRequestError("The `student_id` field in the request cannot be empty," + "please specify student.") + try: + student = get_object_or_error(Student.objects, id=student_id) + except ObjectDoesNotExist as e: + raise NotFound("No student found with id: " + str(student_id)) + if request.method == "POST": + with transaction.atomic(): + # Check if swap object with given id exists + try: + target_swap = get_object_or_error(Swap.objects.select_for_update(), id=swap_id) + except ObjectDoesNotExist as e: + logger.info(e) + logger.error( + " User %s could not complete swap. " + "Swap does not exist.", + log_str(student.user), + ) + raise NotFound("Swap with id: " + str(swap_id) + " does not exist.") + + # Execute atomic transaction, and swap sections + try: + sender = get_object_or_error(Student.objects.select_for_update(), + id=target_swap.sender.id) + receiver = get_object_or_error(Student.objects.select_for_update(), + id=target_swap.receiver.id) + except ObjectDoesNotExist as e: + logger.error( + " User %s could not swap with %s", + log_str(student.user), + log_str(receiver.user), + ) + raise NotFound("Could not find swaps for student.") + sender.section = target_swap.receiver.section + # Get outgoing and incoming swaps for sender + outgoing_swaps = Swap.objects.filter(sender=sender).select_for_update() + outgoing_swaps.delete() + incoming_swaps = Swap.objects.filter(receiver=sender).select_for_update() + incoming_swaps.delete() + sender.save() + receiver.section = target_swap.sender.section + # Get outgoing and incoming swaps for receiver + outgoing_swaps = Swap.objects.filter(sender=receiver).select_for_update() + outgoing_swaps.delete() + incoming_swaps = Swap.objects.filter(receiver=receiver).select_for_update() + incoming_swaps.delete() + receiver.save() + # Finally, delete the swap object + target_swap.delete() + logger.info( + " User %s swapped with %s", + log_str(sender.user), + log_str(receiver.user), + ) + return Response({}, status=status.HTTP_200_OK) + + if request.method == "DELETE": + swap = get_object_or_error(Swap, pk=swap_id) + if student_id != swap.reciever.id and student_id != swap.sender.id: + logger.error( + f" Could not delete swap, " + f"user {log_str(request.user)} does not have proper permissions" + ) + raise PermissionDenied("You must be a student or a coordinator" + "to delete this spacetime override!") + swap.delete() + + logger.info( + f" Deleted swap {log_str(swap)}" + ) + return Response(status=status.HTTP_200_OK)