diff --git a/backend/courses/migrations/0066_meeting_associated_break_alter_meeting_section.py b/backend/courses/migrations/0066_meeting_associated_break_alter_meeting_section.py new file mode 100644 index 000000000..b9f030514 --- /dev/null +++ b/backend/courses/migrations/0066_meeting_associated_break_alter_meeting_section.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.2 on 2025-01-26 19:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("courses", "0065_topic_historical_probabilities_fall_and_more"), + ("plan", "0010_break"), + ] + + operations = [ + migrations.AddField( + model_name="meeting", + name="associated_break", + field=models.ForeignKey( + help_text="The Section object to which this class meeting belongs.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="meetings", + to="plan.break", + ), + ), + migrations.AlterField( + model_name="meeting", + name="section", + field=models.ForeignKey( + help_text="The Section object to which this class meeting belongs.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="meetings", + to="courses.section", + ), + ), + ] diff --git a/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py b/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py new file mode 100644 index 000000000..cd8f70e67 --- /dev/null +++ b/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.2 on 2025-02-16 23:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("courses", "0066_meeting_associated_break_alter_meeting_section"), + ("plan", "0014_break_unique_break_meeting_times_per_person"), + ] + + operations = [ + migrations.AlterField( + model_name="meeting", + name="associated_break", + field=models.ForeignKey( + help_text="The Break object to which this meeting object belongs.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="meetings", + to="plan.break", + ), + ), + migrations.AddConstraint( + model_name="meeting", + constraint=models.UniqueConstraint( + condition=models.Q( + ("section__isnull", True), ("associated_break__isnull", True), _connector="OR" + ), + fields=("section", "associated_break"), + name="unique_meeting_either_section_or_break", + ), + ), + migrations.AddConstraint( + model_name="meeting", + constraint=models.UniqueConstraint( + condition=models.Q( + ("section__isnull", True), ("associated_break__isnull", True), _negated=True + ), + fields=("section", "associated_break"), + name="meeting_must_have_section_or_break", + ), + ), + ] diff --git a/backend/courses/models.py b/backend/courses/models.py index 32f79f1c6..2e4245ab1 100644 --- a/backend/courses/models.py +++ b/backend/courses/models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction -from django.db.models import OuterRef, Q, Subquery +from django.db.models import OuterRef, Q, Subquery, UniqueConstraint from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -1126,10 +1126,20 @@ class Meeting(models.Model): section = models.ForeignKey( Section, + null=True, on_delete=models.CASCADE, related_name="meetings", help_text="The Section object to which this class meeting belongs.", ) + + associated_break = models.ForeignKey( + "plan.Break", + null=True, + on_delete=models.CASCADE, + related_name="meetings", + help_text="The Break object to which this meeting object belongs.", + ) + day = models.CharField( max_length=1, help_text="The single day on which the meeting takes place (one of M, T, W, R, or F).", @@ -1180,8 +1190,26 @@ class Meeting(models.Model): ), ) + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + class Meta: unique_together = (("section", "day", "start", "end", "room"),) + constraints = [ + # Ensure that a meeting has either a section or an associated_break, but not both + UniqueConstraint( + fields=["section", "associated_break"], + condition=Q(section__isnull=True) | Q(associated_break__isnull=True), + name="unique_meeting_either_section_or_break", + ), + # Ensure that a meeting must have at least one (not both null) + UniqueConstraint( + fields=["section", "associated_break"], + condition=~(Q(section__isnull=True) & Q(associated_break__isnull=True)), + name="meeting_must_have_section_or_break", + ), + ] @staticmethod def int_to_time(time): diff --git a/backend/courses/util.py b/backend/courses/util.py index 77f06f612..c413d5a52 100644 --- a/backend/courses/util.py +++ b/backend/courses/util.py @@ -29,6 +29,7 @@ StatusUpdate, User, ) +from plan.models import Break from review.management.commands.mergeinstructors import resolve_duplicates @@ -465,7 +466,7 @@ def clean_meetings(meetings): }.values() -def set_meetings(section, meetings): +def set_meetings(obj, meetings): meetings = clean_meetings(meetings) for meeting in meetings: @@ -473,9 +474,9 @@ def set_meetings(section, meetings): meeting_times = [ f"{meeting['days']} {meeting['begin_time']} - {meeting['end_time']}" for meeting in meetings ] - section.meeting_times = json.dumps(meeting_times) + obj.meeting_times = json.dumps(meeting_times) - section.meetings.all().delete() + obj.meetings.all().delete() for meeting in meetings: online = ( not meeting["building_code"] @@ -492,8 +493,10 @@ def set_meetings(section, meetings): start_date = extract_date(meeting.get("start_date")) end_date = extract_date(meeting.get("end_date")) for day in list(meeting["days"]): + meeting = Meeting.objects.update_or_create( - section=section, + section=obj if isinstance(obj, Section) else None, + associated_break=obj if isinstance(obj, Break) else None, day=day, start=start_time, end=end_time, diff --git a/backend/plan/migrations/0010_break.py b/backend/plan/migrations/0010_break.py new file mode 100644 index 000000000..03659ca99 --- /dev/null +++ b/backend/plan/migrations/0010_break.py @@ -0,0 +1,60 @@ +# Generated by Django 5.0.2 on 2025-01-26 19:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0009_alter_schedule_sections"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Break", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "location_string", + models.CharField( + help_text="\nThis represents the location that the user can input themselves. \nWill use a building object from a drop down or have it validated or something so it can interact with map.\nDidn't want to run into issue of users creating arbitrary Room objects, so just using a char field\n", + max_length=80, + null=True, + ), + ), + ( + "name", + models.CharField( + help_text="\nThe user's name for the break. No two breaks can match in all of the fields\n`[name, person]`\n", + max_length=255, + ), + ), + ( + "meeting_times", + models.TextField( + blank=True, + help_text='\nA JSON-stringified list of meeting times of the form\n`{days code} {start time} - {end time}`, e.g.\n`["MWF 09:00 AM - 10:00 AM","F 11:00 AM - 12:00 PM","T 05:00 PM - 06:00 PM"]` for\nPHYS-151-001 (2020A). Each letter of the days code is of the form M, T, W, R, F for each\nday of the work week, respectively (and multiple days are combined with concatenation).\nTo access the Meeting objects for this section, the related field `meetings` can be used.\n', + ), + ), + ( + "person", + models.ForeignKey( + help_text="The person (user) who created this break.", + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("name", "person")}, + }, + ), + ] diff --git a/backend/plan/migrations/0011_alter_break_unique_together_and_more.py b/backend/plan/migrations/0011_alter_break_unique_together_and_more.py new file mode 100644 index 000000000..d953287e0 --- /dev/null +++ b/backend/plan/migrations/0011_alter_break_unique_together_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2025-02-16 17:38 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0010_break"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="break", + unique_together={("person",)}, + ), + migrations.AlterField( + model_name="break", + name="location_string", + field=models.CharField( + help_text="\nThis represents the location that the user can input themselves.\nWill use a building object from a drop down or have it validated\nor something so it can interact with map.\nDidn't want to run into issue of users creating arbitrary\nRoom objects, so just using a char field\n", + max_length=80, + null=True, + ), + ), + ] diff --git a/backend/plan/migrations/0012_alter_break_unique_together.py b/backend/plan/migrations/0012_alter_break_unique_together.py new file mode 100644 index 000000000..5b36721a7 --- /dev/null +++ b/backend/plan/migrations/0012_alter_break_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.2 on 2025-02-16 18:01 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0011_alter_break_unique_together_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="break", + unique_together={("person", "meeting_times")}, + ), + ] diff --git a/backend/plan/migrations/0013_alter_break_unique_together.py b/backend/plan/migrations/0013_alter_break_unique_together.py new file mode 100644 index 000000000..4c98a8c6c --- /dev/null +++ b/backend/plan/migrations/0013_alter_break_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.2 on 2025-02-16 18:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0012_alter_break_unique_together"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="break", + unique_together=set(), + ), + ] diff --git a/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py b/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py new file mode 100644 index 000000000..1bd74eb9a --- /dev/null +++ b/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.2 on 2025-02-16 18:10 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0013_alter_break_unique_together"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name="break", + constraint=models.UniqueConstraint( + condition=models.Q( + ("meeting_times__isnull", False), models.Q(("meeting_times", ""), _negated=True) + ), + fields=("person", "meeting_times"), + name="unique_break_meeting_times_per_person", + ), + ), + ] diff --git a/backend/plan/migrations/0015_schedule_breaks.py b/backend/plan/migrations/0015_schedule_breaks.py new file mode 100644 index 000000000..1f0632b7d --- /dev/null +++ b/backend/plan/migrations/0015_schedule_breaks.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.2 on 2025-03-10 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plan", "0014_break_unique_break_meeting_times_per_person"), + ] + + operations = [ + migrations.AddField( + model_name="schedule", + name="breaks", + field=models.ManyToManyField( + blank=True, + help_text="\nThe breaks which comprise the schedule. The semester of each of these breaks is assumed to\nmatch the semester defined by the semester field below.\n", + to="plan.break", + ), + ), + ] diff --git a/backend/plan/models.py b/backend/plan/models.py index 72345460d..7ff3e274c 100644 --- a/backend/plan/models.py +++ b/backend/plan/models.py @@ -2,10 +2,73 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Q, UniqueConstraint from courses.models import Section +class Break(models.Model): + """ + Holds break objects created by users on PCP. + """ + + person = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + help_text="The person (user) who created this break.", + ) + + location_string = models.CharField( + max_length=80, + null=True, + help_text=dedent( + """ + This represents the location that the user can input themselves. + Will use a building object from a drop down or have it validated + or something so it can interact with map. + Didn't want to run into issue of users creating arbitrary + Room objects, so just using a char field + """ + ), # TODO: Don't know how I want to do buildings yet. + ) + + name = models.CharField( + max_length=255, + help_text=dedent( + """ + The user's name for the break. No two breaks can match in all of the fields + `[name, person]` + """ + ), + ) + + meeting_times = models.TextField( + blank=True, + help_text=dedent( + """ + A JSON-stringified list of meeting times of the form + `{days code} {start time} - {end time}`, e.g. + `["MWF 09:00 AM - 10:00 AM","F 11:00 AM - 12:00 PM","T 05:00 PM - 06:00 PM"]` for + PHYS-151-001 (2020A). Each letter of the days code is of the form M, T, W, R, F for each + day of the work week, respectively (and multiple days are combined with concatenation). + To access the Meeting objects for this section, the related field `meetings` can be used. + """ + ), + ) + + class Meta: + constraints = [ + UniqueConstraint( + fields=["person", "meeting_times"], + condition=Q(meeting_times__isnull=False) & ~Q(meeting_times=""), + name="unique_break_meeting_times_per_person", + ) + ] + + def __str__(self): + return "User: %s, Break ID: %s" % (self.person, self.id) + + class Schedule(models.Model): """ Used to save schedules created by users on PCP @@ -28,6 +91,17 @@ class Schedule(models.Model): ), ) + breaks = models.ManyToManyField( + Break, + blank=True, + help_text=dedent( + """ + The breaks which comprise the schedule. The semester of each of these breaks is assumed to + match the semester defined by the semester field below. + """ + ), + ) + semester = models.CharField( max_length=5, help_text=dedent( diff --git a/backend/plan/serializers.py b/backend/plan/serializers.py index 3ac31a7e2..ff696a7c9 100644 --- a/backend/plan/serializers.py +++ b/backend/plan/serializers.py @@ -1,13 +1,52 @@ +from textwrap import dedent + from rest_framework import serializers -from courses.serializers import PublicUserSerializer, SectionDetailSerializer -from plan.models import PrimarySchedule, Schedule +from courses.serializers import ( + MeetingSerializer, + MeetingWithBuildingSerializer, + PublicUserSerializer, + SectionDetailSerializer, +) +from plan.models import Break, PrimarySchedule, Schedule + + +class BreakSerializer(serializers.ModelSerializer): + + meetings = serializers.SerializerMethodField( + read_only=True, + help_text=dedent( + """ + A list of the meetings of this section (each meeting is a continuous span of time + during which a section would meet). + """ + ), + ) + id = serializers.IntegerField( + read_only=False, required=False, help_text="The id of the schedule." + ) + + class Meta: + model = Break + exclude = ["person"] + + def get_meetings(self, obj): + include_location = self.context.get("include_location", False) + if include_location: + meetings_serializer = MeetingWithBuildingSerializer(obj.meetings, many=True) + else: + meetings_serializer = MeetingSerializer(obj.meetings, many=True) + + return meetings_serializer.data class ScheduleSerializer(serializers.ModelSerializer): sections = SectionDetailSerializer( many=True, read_only=False, help_text="The sections in the schedule.", required=True ) + breaks = BreakSerializer( + many=True, read_only=False, help_text="The breaks in the schedule.", required=False + ) id = serializers.IntegerField( read_only=False, required=False, help_text="The id of the schedule." ) diff --git a/backend/plan/urls.py b/backend/plan/urls.py index 0876e5a8e..b0e6dbdc8 100644 --- a/backend/plan/urls.py +++ b/backend/plan/urls.py @@ -3,6 +3,7 @@ from rest_framework_nested import routers from plan.views import ( + BreakViewSet, CalendarAPIView, PrimaryScheduleViewSet, ScheduleViewSet, @@ -13,6 +14,7 @@ router = routers.DefaultRouter() router.register(r"schedules", ScheduleViewSet, basename="schedules") router.register(r"primary-schedules", PrimaryScheduleViewSet, basename="primary-schedules") +router.register(r"breaks", BreakViewSet, basename="breaks") urlpatterns = [ path("/calendar/", CalendarAPIView.as_view(), name="calendar-view"), diff --git a/backend/plan/views.py b/backend/plan/views.py index 846bebef2..94b7df21f 100644 --- a/backend/plan/views.py +++ b/backend/plan/views.py @@ -20,7 +20,12 @@ from courses.models import Course, Meeting, Section from courses.serializers import CourseListSerializer -from courses.util import get_course_and_section, get_current_semester, normalize_semester +from courses.util import ( + get_course_and_section, + get_current_semester, + normalize_semester, + set_meetings, +) from courses.views import get_accepted_friends from PennCourses.docs_settings import PcxAutoSchema from PennCourses.settings.base import PATH_REGISTRATION_SCHEDULE_NAME @@ -31,8 +36,8 @@ vectorize_user, vectorize_user_by_courses, ) -from plan.models import PrimarySchedule, Schedule -from plan.serializers import PrimaryScheduleSerializer, ScheduleSerializer +from plan.models import Break, PrimarySchedule, Schedule +from plan.serializers import BreakSerializer, PrimaryScheduleSerializer, ScheduleSerializer @api_view(["POST"]) @@ -487,6 +492,18 @@ def get_sections(data, semester, skip_missing=False): raise e return sections + @staticmethod + def get_breaks(data): + raw_breaks = data.get("breaks", []) + breaks = [] + for b in raw_breaks: + break_id = b.get("id") + if break_id: + break_candidate = Break.objects.filter(id=break_id).first() + if break_candidate: + breaks.append(break_candidate) + return breaks + def validate_name(self, request, existing_schedule=None, allow_path=False): name = request.data.get("name") if PATH_REGISTRATION_SCHEDULE_NAME in [ @@ -539,12 +556,21 @@ def update(self, request, pk=None): status=status.HTTP_400_BAD_REQUEST, ) + try: + breaks = self.get_breaks(request.data) + except ObjectDoesNotExist: + return Response( + {"detail": "One or more breaks not found in database."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: schedule.person = request.user schedule.semester = semester schedule.name = PATH_REGISTRATION_SCHEDULE_NAME if from_path else name schedule.save() schedule.sections.set(sections) + schedule.breaks.set(breaks) return Response({"message": "success", "id": schedule.id}, status=status.HTTP_200_OK) except IntegrityError as e: return Response( @@ -618,6 +644,134 @@ def get_queryset(self, semester=None): return queryset +class BreakViewSet(AutoPrefetchViewSetMixin, viewsets.ModelViewSet): + + serializer_class = BreakSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_context(self): + context = super().get_serializer_context() + # include_location_str = "False" + # TODO: figure out how we want to do locations. + # context.update({"include_location": eval(include_location_str)}) + return context + + def update(self, request): + break_id = request.data.get("id") + if not break_id: + return Response( + {"detail": "Break id is required for update."}, status=status.HTTP_400_BAD_REQUEST + ) + + try: + current_break = self.get_queryset().get(id=break_id) + except Break.DoesNotExist: + return Response({"detail": "Break not found."}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response( + {"detail": "Error retrieving break: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + name = request.data.get("name") + if not name: + return Response( + {"detail": "Break name is required."}, status=status.HTTP_400_BAD_REQUEST + ) + location_string = request.data.get("location_string") + + current_break.name = name + current_break.location_string = location_string + + meetings = request.data.get("meetings") + try: + meetings_with_codes = [ + { + **m, + "building_code": None, + "room_code": None, + } + for m in meetings + ] + set_meetings(current_break, meetings_with_codes) + except Exception as e: + return Response( + {"detail": "Error setting meetings: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + try: + current_break.save() + except Exception as e: + return Response( + {"detail": "Error saving break: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + return Response({"message": "success", "id": current_break.id}, status=status.HTTP_200_OK) + + def create(self, request, *args, **kwargs): + break_id = request.data.get("id") + if break_id and Break.objects.filter(id=break_id).exists(): + return self.update(request) + + name = request.data.get("name") + if not name: + return Response( + {"detail": "Break name is required."}, status=status.HTTP_400_BAD_REQUEST + ) + location_string = request.data.get("location_string") + + try: + if break_id: + new_break = self.get_queryset().create( + person=request.user, + name=name, + location_string=location_string, + id=break_id, + ) + else: + new_break = self.get_queryset().create( + person=request.user, + name=name, + location_string=location_string, + ) + except IntegrityError as e: + return Response( + { + "detail": "IntegrityError encountered while trying to create: " + + str(e.__cause__) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response( + {"detail": "Error creating break: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + meetings = request.data.get("meetings") + meetings_with_codes = [ + { + **m, + "building_code": None, + "room_code": None, + } + for m in meetings + ] + try: + set_meetings(new_break, meetings_with_codes) + except Exception as e: + return Response( + {"detail": "Error setting meetings: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + return Response({"message": "success", "id": new_break.id}, status=status.HTTP_201_CREATED) + + def get_queryset(self): + return Break.objects.filter(person=self.request.user).prefetch_related( + "meetings", # Prefetch the related meetings + "meetings__room", # Prefetch the related rooms for each meeting + ) + + class CalendarAPIView(APIView): schema = PcxAutoSchema( custom_path_parameter_desc={ diff --git a/backend/tests/plan/test_schedule.py b/backend/tests/plan/test_schedule.py index 2d3bd1a03..6338d8154 100644 --- a/backend/tests/plan/test_schedule.py +++ b/backend/tests/plan/test_schedule.py @@ -4,13 +4,15 @@ from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.test import TestCase +from django.urls import reverse from options.models import Option +from rest_framework import status from rest_framework.test import APIClient from alert.models import AddDropPeriod from courses.util import get_average_reviews, invalidate_current_semester_cache from PennCourses.settings.base import PATH_REGISTRATION_SCHEDULE_NAME -from plan.models import Schedule +from plan.models import Break, Schedule from tests.courses.util import create_mock_data_with_reviews @@ -1224,3 +1226,127 @@ def test_update_from_path_nonexistent_sections(self, mock_request): path_schedule = schedule self.assertEqual(len(path_schedule["sections"]), 1) self.assertEqual(path_schedule["sections"][0]["id"], "CIS-120-001") + + +class BreakViewSetTests(TestCase): + def setUp(self): + # Create and authenticate a test user + self.user = User.objects.create_user(username="testuser", password="testpassword") + self.client = APIClient() + self.client.login(username="testuser", password="testpassword") + + # Create an initial Break instance to test update operations + self.break_obj = Break.objects.create( + person=self.user, name="Morning Break", location_string="Cafeteria" + ) + self.break_obj.save() + + # Use Django's reverse to dynamically get the correct endpoint + self.break_list_url = reverse("breaks-list") # /api/plan/breaks/ + self.break_detail_url = reverse( + "breaks-detail", kwargs={"pk": self.break_obj.id} + ) # /api/plan/breaks/{id}/ + + @patch("plan.views.set_meetings") + def test_create_break(self, mock_set_meetings): + """ + Ensure that posting a new break (without an "id") creates the break + and returns a 201 response. + """ + data = { + "id": 2, + "name": "Afternoon Break", + "location_string": "Lobby", + "meetings": [], # No meetings provided + } + response = self.client.post(self.break_list_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Check that a new Break was created with the proper fields. + new_break_id = response.data.get("id") + self.assertIsNotNone(new_break_id) + new_break = Break.objects.get(id=new_break_id) + self.assertEqual(new_break.name, "Afternoon Break") + self.assertEqual(new_break.location_string, "Lobby") + + # Verify that set_meetings was called with the new break and meetings list. + mock_set_meetings.assert_called_once_with(new_break, []) + self.assertEqual(response.data.get("message"), "success") + + @patch("plan.views.set_meetings") + def test_update_break_existing(self, mock_set_meetings): + """ + If a break with the provided "id" exists, sending a POST + request with that id should update it. + """ + data = { + "id": self.break_obj.id, + "name": "Updated Break", + "location_string": "New Location", + "meetings": [ + { + "days": "MT", + "begin_time_24": 900, + "begin_time": "9:00 AM", + "end_time_24": 1000, + "end_time": "10:00 AM", + }, + ], + } + response = self.client.post(self.break_list_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Refresh the object from the database to confirm changes. + self.break_obj.refresh_from_db() + self.assertEqual(self.break_obj.name, "Updated Break") + self.assertEqual(self.break_obj.location_string, "New Location") + + self.assertEqual(response.data.get("message"), "success") + self.assertEqual(response.data.get("id"), self.break_obj.id) + + def test_update_break_nonexistent(self): + """ + Posting a break update with an id that does not exist should create a break with that ID. + """ + data = { + "id": 9999, # An id that does not exist for this user + "name": "Nonexistent Break", + "location_string": "Nowhere", + "meetings": [], + } + response = self.client.post(self.break_list_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data.get("message"), "success") + + new_break = Break.objects.get(id=response.data.get("id")) + + self.assertEqual(new_break.name, "Nonexistent Break") + self.assertEqual(new_break.location_string, "Nowhere") + + def test_list_breaks(self): + """ + Ensure that a GET request to the break-list endpoint returns the list + of breaks for the authenticated user. + """ + response = self.client.get(self.break_list_url, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check that our initial break ("Morning Break") is in the returned list. + break_names = [item.get("name") for item in response.data] + self.assertIn("Morning Break", break_names) + + def test_get_break_by_id(self): + """ + Ensure that retrieving a specific break by its id works. + """ + response = self.client.get(self.break_detail_url, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], self.break_obj.name) + self.assertEqual(response.data["location_string"], self.break_obj.location_string) + + def test_delete_break(self): + """ + Ensure that deleting a break works. + """ + response = self.client.delete(self.break_detail_url, format="json") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Break.objects.filter(id=self.break_obj.id).exists()) diff --git a/frontend/plan/actions/index.js b/frontend/plan/actions/index.js index b3b7ecf3f..69a9b3368 100644 --- a/frontend/plan/actions/index.js +++ b/frontend/plan/actions/index.js @@ -110,8 +110,9 @@ export const addCartItem = (section) => ({ section, }); -export const removeSchedItem = (id) => ({ +export const removeSchedItem = (id, type) => ({ type: REMOVE_SCHED_ITEM, + itemType: type, id, }); diff --git a/frontend/plan/components/schedule/Block.tsx b/frontend/plan/components/schedule/Block.tsx index 8f423c3d4..be8e7eb55 100644 --- a/frontend/plan/components/schedule/Block.tsx +++ b/frontend/plan/components/schedule/Block.tsx @@ -137,7 +137,6 @@ export default function Block(props: BlockProps) { /> )} {false && !coreqFulfilled && } - {id.replace(/-/g, " ")} diff --git a/frontend/plan/components/schedule/Schedule.tsx b/frontend/plan/components/schedule/Schedule.tsx index 1e5334366..a1e7114f9 100644 --- a/frontend/plan/components/schedule/Schedule.tsx +++ b/frontend/plan/components/schedule/Schedule.tsx @@ -140,7 +140,7 @@ const mapStateToProps = (state: any) => { }; const mapDispatchToProps = (dispatch: ThunkDispatch) => ({ - removeSection: (idDashed: string) => dispatch(removeSchedItem(idDashed)), + removeSection: (idDashed: string, type: string) => dispatch(removeSchedItem(idDashed, type)), focusSection: (id: string) => dispatch(fetchCourseDetails(id)), changeMySchedule: (scheduleName: string) => dispatch(changeMySchedule(scheduleName)), diff --git a/frontend/plan/components/schedule/ScheduleDisplay.tsx b/frontend/plan/components/schedule/ScheduleDisplay.tsx index baed5fdf5..fd71f28fa 100644 --- a/frontend/plan/components/schedule/ScheduleDisplay.tsx +++ b/frontend/plan/components/schedule/ScheduleDisplay.tsx @@ -137,6 +137,10 @@ const ScheduleDisplay = ({ schedData.sections || []; + let breaks; + + breaks = friendshipState.activeFriendSchedule?.breaks || schedData.breaks || []; + const notEmpty = sections.length > 0; let startHour = 10.5; @@ -168,6 +172,7 @@ const ScheduleDisplay = ({ day: m.day as Day, start: transformTime(m.start), end: transformTime(m.end), + type: "section", course: { color: s.color, id: s.id, @@ -185,6 +190,30 @@ const ScheduleDisplay = ({ ); } }); + + breaks.forEach((b) => { + if (b.meetings) { + meetings.push( + ...b.meetings.map((m) => ({ + day: m.day as Day, + start: transformTime(m.start), + end: transformTime(m.end), + type: "break", + course: { + color: b.color, + id: b.name, + coreqFulfilled: true + }, + style: { + width: "100%", + left: "0", + }, + })) + ); + } + }); + + startHour = Math.floor( Math.min(startHour, ...meetings.map((m) => m.start)) @@ -239,15 +268,15 @@ const ScheduleDisplay = ({ col: colOffset, }} readOnly={readOnly} - remove={() => removeSection(meeting.course.id)} + remove={() => removeSection(meeting.course.id, meeting.type)} key={`${meeting.course.id}-${meeting.day}`} - focusSection={() => { + focusSection={meeting.type == "section" ? () => { if (isMobileOnly && setTab) { setTab(0); } const split = meeting.course.id.split("-"); focusSection(`${split[0]}-${split[1]}`); - }} + } : () => {}} /> ))} {!notEmpty && !readOnly && } diff --git a/frontend/plan/reducers/schedule.js b/frontend/plan/reducers/schedule.js index 1b80d6390..df8cbb760 100644 --- a/frontend/plan/reducers/schedule.js +++ b/frontend/plan/reducers/schedule.js @@ -185,6 +185,10 @@ const handleUpdateSchedulesOnFrontend = (state, schedulesFromBackend) => { color: getColor(section.id), }) ), + breaks: scheduleFromBackend.breaks.map((br) => ({ + ...br, + color: getColor(br.id), + })), id: scheduleFromBackend.id, pushedToBackend: true, updated_at: Date.now(), @@ -420,20 +424,37 @@ export const schedule = (state = initialState, action) => { case REMOVE_SCHED_ITEM: if (!state.readOnly) { - return { - ...state, - schedules: { - ...state.schedules, - [state.scheduleSelected]: { - ...state.schedules[state.scheduleSelected], - updated_at: Date.now(), - pushedToBackend: false, - sections: state.schedules[ - state.scheduleSelected - ].sections.filter((m) => m.id !== action.id), + if (action.itemType === "section") { + return { + ...state, + schedules: { + ...state.schedules, + [state.scheduleSelected]: { + ...state.schedules[state.scheduleSelected], + updated_at: Date.now(), + pushedToBackend: false, + sections: state.schedules[ + state.scheduleSelected + ].sections.filter((m) => m.id !== action.id), + }, }, - }, - }; + }; + } else { + return { + ...state, + schedules: { + ...state.schedules, + [state.scheduleSelected]: { + ...state.schedules[state.scheduleSelected], + updated_at: Date.now(), + pushedToBackend: false, + breaks: state.schedules[ + state.scheduleSelected + ].breaks.filter((br) => br.name !== action.id), + }, + }, + }; + } } showToast("Cannot remove courses from a friend's schedule!", true);