diff --git a/backend/ohq/migrations/0021_occurrence.py b/backend/ohq/migrations/0021_occurrence.py new file mode 100644 index 00000000..abb2023f --- /dev/null +++ b/backend/ohq/migrations/0021_occurrence.py @@ -0,0 +1,62 @@ +# Generated by Django 5.0.3 on 2024-10-13 05:41 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0020_auto_20240326_0226"), + ("schedule", "0015_auto_20240825_0015"), + ] + + operations = [ + migrations.CreateModel( + name="Occurrence", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("title", models.CharField(blank=True, max_length=255, verbose_name="title")), + ("location", models.CharField(blank=True, max_length=255, verbose_name="location")), + ("description", models.TextField(blank=True, verbose_name="description")), + ("start", models.DateTimeField(db_index=True, verbose_name="start")), + ("end", models.DateTimeField(db_index=True, verbose_name="end")), + ("cancelled", models.BooleanField(default=False, verbose_name="cancelled")), + ("original_start", models.DateTimeField(verbose_name="original start")), + ("original_end", models.DateTimeField(verbose_name="original end")), + ("created_on", models.DateTimeField(auto_now_add=True, verbose_name="created on")), + ("updated_on", models.DateTimeField(auto_now=True, verbose_name="updated on")), + ( + "interval", + models.IntegerField( + blank=True, + validators=[ + django.core.validators.MinValueValidator(5), + django.core.validators.MaxValueValidator(60), + ], + verbose_name="interval", + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="test_occurrence", + to="schedule.event", + verbose_name="event", + ), + ), + ], + options={ + "verbose_name": "occurrence", + "verbose_name_plural": "occurrences", + "index_together": {("start", "end")}, + }, + ), + ] diff --git a/backend/ohq/migrations/0022_booking.py b/backend/ohq/migrations/0022_booking.py new file mode 100644 index 00000000..a7f6b298 --- /dev/null +++ b/backend/ohq/migrations/0022_booking.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.3 on 2024-10-13 17:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0021_occurrence"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Booking", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("start", models.DateTimeField(db_index=True, verbose_name="start")), + ("end", models.DateTimeField(db_index=True, verbose_name="end")), + ( + "occurrence", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="ohq.occurrence" + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/ohq/migrations/0023_alter_booking_end_alter_booking_start_and_more.py b/backend/ohq/migrations/0023_alter_booking_end_alter_booking_start_and_more.py new file mode 100644 index 00000000..3a755112 --- /dev/null +++ b/backend/ohq/migrations/0023_alter_booking_end_alter_booking_start_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.3 on 2024-10-18 05:00 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0022_booking"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="booking", + name="end", + field=models.DateTimeField(blank=True, db_index=True, verbose_name="end"), + ), + migrations.AlterField( + model_name="booking", + name="start", + field=models.DateTimeField(blank=True, db_index=True, verbose_name="start"), + ), + migrations.AddConstraint( + model_name="booking", + constraint=models.UniqueConstraint( + fields=("occurrence", "user"), name="unique_reservation" + ), + ), + ] diff --git a/backend/ohq/migrations/0024_alter_booking_options_and_more.py b/backend/ohq/migrations/0024_alter_booking_options_and_more.py new file mode 100644 index 00000000..79f758ea --- /dev/null +++ b/backend/ohq/migrations/0024_alter_booking_options_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.3 on 2024-10-18 06:32 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0023_alter_booking_end_alter_booking_start_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="booking", + options={ + "ordering": ["start"], + "verbose_name": "booking", + "verbose_name_plural": "bookings", + }, + ), + migrations.RemoveConstraint( + model_name="booking", + name="unique_reservation", + ), + migrations.AlterUniqueTogether( + name="booking", + unique_together={("occurrence", "user")}, + ), + migrations.AlterIndexTogether( + name="booking", + index_together={("start", "end")}, + ), + ] diff --git a/backend/ohq/migrations/0025_alter_booking_unique_together.py b/backend/ohq/migrations/0025_alter_booking_unique_together.py new file mode 100644 index 00000000..1f73344f --- /dev/null +++ b/backend/ohq/migrations/0025_alter_booking_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.3 on 2024-10-18 06:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0024_alter_booking_options_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="booking", + unique_together=set(), + ), + ] diff --git a/backend/ohq/models.py b/backend/ohq/models.py index 31b59e9a..31f78632 100644 --- a/backend/ohq/models.py +++ b/backend/ohq/models.py @@ -4,7 +4,7 @@ from django.dispatch import receiver from email_tools.emails import send_email from phonenumber_field.modelfields import PhoneNumberField - +from schedule.models import Event, Occurrence User = settings.AUTH_USER_MODEL @@ -447,3 +447,24 @@ class Meta: def __str__(self): return f"{self.user}: {self.metric}" + +class Booking(models.Model): + """ + Booking within an occurrence + """ + + occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) + start = models.DateTimeField("start", db_index=True) + end = models.DateTimeField("end", db_index=True) + + class Meta: + verbose_name = ("booking") + verbose_name_plural = ("bookings") + ordering = ["start"] + index_together = (("start", "end"),) + + def __str__(self): + start_str = self.start.strftime("%Y-%m-%d %H:%M:%S") + end_str = self.end.strftime("%Y-%m-%d %H:%M:%S") + return f"{start_str} to {end_str}" \ No newline at end of file diff --git a/backend/ohq/permissions.py b/backend/ohq/permissions.py index 58a1cce5..09d487fa 100644 --- a/backend/ohq/permissions.py +++ b/backend/ohq/permissions.py @@ -2,7 +2,7 @@ from rest_framework import permissions from schedule.models import Event, EventRelation, Occurrence -from ohq.models import Course, Membership, Question +from ohq.models import Course, Membership, Question, Booking # Hierarchy of permissions is usually: @@ -504,3 +504,66 @@ def has_permission(self, request, view): return True return True + +class BookingPermission(permissions.BasePermission): + def get_membership_from_event(self, request, event): + event_course_relation = EventRelation.objects.filter(event=event).first() + membership = Membership.objects.filter( + course_id=event_course_relation.object_id, user=request.user + ).first() + return membership + + def has_object_permission(self, request, view, obj): + if view.action in ["retrieve"]: + booking = Booking.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request=request, event=booking.occurrence.event) + return membership is not None + + if view.action in ["update", "partial_update"]: + booking = Booking.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request=request, event=booking.occurrence.event) + + if membership is not None: + if membership.is_ta: + return True + else: + updated_fields = request.data.keys() + restricted_student_fields = ["occurrence", "start", "end"] # allowed_student_field would be "user" + + for field in updated_fields: + if field in restricted_student_fields: + return False + if "user" in updated_fields: + return booking.user is None # Doesn’t allow a student to update user if someone already booked the slot + else: + return False + + return False + + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + # Anonymous users can't do anything + if view.action in ["create"]: + course_pk = request.data.get("course_id", None) + if course_pk is None: + return False + + course = Course.objects.get(pk=course_pk) + membership = Membership.objects.filter(course=course, user=request.user).first() + + if membership is None: + return False + return membership.is_ta + + if view.action in ["list"]: + # if any member of the course in the list is not accessible, return false + course_ids = request.GET.getlist("course") + for course in course_ids: + membership = Membership.objects.filter(course=course, user=request.user).first() + if membership is None: + return False + return True + + return True \ No newline at end of file diff --git a/backend/ohq/serializers.py b/backend/ohq/serializers.py index 1ae87c58..533700ed 100644 --- a/backend/ohq/serializers.py +++ b/backend/ohq/serializers.py @@ -22,6 +22,7 @@ QueueStatistic, Semester, Tag, + Booking, ) from ohq.sms import sendSMSVerification from ohq.tasks import sendUpNextNotificationTask @@ -592,4 +593,16 @@ class OccurrenceSerializer(serializers.ModelSerializer): class Meta: model = Occurrence - fields = ("id", "title", "description", "location", "start", "end", "cancelled", "event") + fields = ("id", "title", "description", "location", "start", "end", "cancelled", "event", "interval") + +class BookingSerializer(serializers.ModelSerializer): + """ + Serializer for booking + """ + + occurrence = OccurrenceSerializer(read_only=True) + + class Meta: + model = Booking + fields = ("id", "occurrence", "user", "start", "end") + diff --git a/backend/ohq/urls.py b/backend/ohq/urls.py index cfef9d39..1caa3930 100644 --- a/backend/ohq/urls.py +++ b/backend/ohq/urls.py @@ -19,6 +19,7 @@ SemesterViewSet, TagViewSet, UserView, + BookingViewSet, ) @@ -29,6 +30,7 @@ router.register("courses", CourseViewSet, basename="course") router.register("events", EventViewSet, basename="event") router.register("occurrences", OccurrenceViewSet, basename="occurrence") +router.register("bookings", BookingViewSet, basename="booking") course_router = routers.NestedSimpleRouter(router, "courses", lookup="course") course_router.register("queues", QueueViewSet, basename="queue") diff --git a/backend/ohq/views.py b/backend/ohq/views.py index 12a60572..a03c52ae 100644 --- a/backend/ohq/views.py +++ b/backend/ohq/views.py @@ -46,6 +46,7 @@ QueueStatistic, Semester, Tag, + Booking, ) from ohq.pagination import QuestionSearchPagination from ohq.permissions import ( @@ -63,6 +64,7 @@ QueuePermission, QueueStatisticPermission, TagPermission, + BookingPermission, ) from ohq.schemas import EventSchema, MassInviteSchema, OccurrenceSchema from ohq.serializers import ( @@ -81,6 +83,7 @@ SemesterSerializer, TagSerializer, UserPrivateSerializer, + BookingSerializer, ) from ohq.sms import sendSMSVerification @@ -741,7 +744,7 @@ class OccurrenceViewSet( You must specify all of the fields or use a patch request. partial_update: - Update certain fields in the Occurrece. + Update certain fields in the Occurrence. """ serializer_class = OccurrenceSerializer @@ -749,7 +752,7 @@ class OccurrenceViewSet( schema = OccurrenceSchema() def list(self, request, *args, **kwargs): - # ensure timezone consitency + # ensure timezone consistency course_ids = request.GET.getlist("course") filter_start = datetime.strptime( request.GET.get("filter_start"), "%Y-%m-%dT%H:%M:%SZ" @@ -772,6 +775,103 @@ def list(self, request, *args, **kwargs): serializer = OccurrenceSerializer(occurrences, many=True) return JsonResponse(serializer.data, safe=False) + + def update(self, request, *args, **kwargs): + occurrence = self.get_object() + old_start = occurrence.start + old_end = occurrence.end + occurrence.start = datetime.strptime(request.data.get("start"), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=utc + ) + occurrence.end = datetime.strptime(request.data.get("end"), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=utc + ) + start_delta = occurrence.start - old_start + end_delta = occurrence.end - old_end + occurrence.save() + + bookings = Booking.objects.filter(occurrence=occurrence).order_by("start") + + for booking in bookings: + booking.start += start_delta + booking.end += end_delta + booking.save() + + serializer = OccurrenceSerializer(occurrence) + return JsonResponse(serializer.data, safe=False) def get_queryset(self): return Occurrence.objects.filter(pk=self.kwargs["pk"]) + +class BookingViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + retrieve: + Return a Booking. + + list: + You should pass in an occurrence id, and all the bookings related to that occurrence will be returned to you. + Return a list of bookings. + + create: + Create a booking. + occurrenceId is required in body. + + update: + Update all fields in a Booking. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the Booking. + """ + + serializer_class = BookingSerializer + permission_classes = [BookingPermission | IsSuperuser] + + def create(self, request, *args, **kwargs): + occurrence_id = request.data.get("occurrence") + occurrence = Occurrence.objects.get(id=occurrence_id) + user = request.user + existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start") + + if existing_bookings.exists(): + last_booking = existing_bookings.last() + start = last_booking.end + else: + start = occurrence.start + + end = start + occurrence.interval + + if start < occurrence.start or end > occurrence.end: + raise ValidationError(_("Booking times must be within the occurrence's time range.")) + + booking = Booking( + occurrence=occurrence, + user=user, + start=start, + end=end, + ) + + booking.save() + + serializer = BookingSerializer(booking) + return JsonResponse(serializer.data, safe=False) + + def list(self,request, *args, **kwargs): + occurrence_id = request.GET.get("occurrence") + if occurrence_id is None: + raise ValidationError(_(f"Occurrence id is required.")) + + occurrence = Occurrence.objects.get(id=occurrence_id) + existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start") + + serializer = BookingSerializer(existing_bookings, many=True) + return JsonResponse(serializer.data, safe=False) + + def get_queryset(self): + return Booking.objects.filter(pk=self.kwargs["pk"]) \ No newline at end of file