Skip to content

Commit 9de1a71

Browse files
committed
Add backend for bookable office hours
- Created new Booking model, serializer, permission, urls, and viewset
1 parent 524282d commit 9de1a71

File tree

5 files changed

+214
-5
lines changed

5 files changed

+214
-5
lines changed

backend/ohq/models.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from django.dispatch import receiver
55
from email_tools.emails import send_email
66
from phonenumber_field.modelfields import PhoneNumberField
7-
7+
from schedule.models import Event, Occurrence
8+
from django.utils.translation import gettext, gettext_lazy as _
9+
from django.template.defaultfilters import date
10+
from django.conf import settings as django_settings
11+
from django.urls import reverse
812

913
User = settings.AUTH_USER_MODEL
1014

@@ -447,3 +451,25 @@ class Meta:
447451

448452
def __str__(self):
449453
return f"{self.user}: {self.metric}"
454+
455+
class Booking(models.Model):
456+
"""
457+
Booking within an occurrence
458+
"""
459+
460+
occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE)
461+
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
462+
start = models.DateTimeField(_("start"), db_index=True, blank=True)
463+
end = models.DateTimeField(_("end"), db_index=True, blank=True)
464+
465+
class Meta:
466+
verbose_name = _("booking")
467+
verbose_name_plural = _("bookings")
468+
ordering = ["start"]
469+
index_together = (("start", "end"),)
470+
471+
def __str__(self):
472+
return gettext("%(start)s to %(end)s") % {
473+
"start": date(self.start, django_settings.DATE_FORMAT),
474+
"end": date(self.end, django_settings.DATE_FORMAT),
475+
}

backend/ohq/permissions.py

+67-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from rest_framework import permissions
33
from schedule.models import Event, EventRelation, Occurrence
44

5-
from ohq.models import Course, Membership, Question
5+
from ohq.models import Course, Membership, Question, Booking
66

77

88
# Hierarchy of permissions is usually:
@@ -504,3 +504,69 @@ def has_permission(self, request, view):
504504
return True
505505

506506
return True
507+
508+
class BookingPermission(permissions.BasePermission):
509+
def get_membership_from_event(self, request, event):
510+
event_course_relation = EventRelation.objects.filter(event=event).first()
511+
membership = Membership.objects.filter(
512+
course_id=event_course_relation.object_id, user=request.user
513+
).first()
514+
return membership
515+
516+
def has_object_permission(self, request, view, obj):
517+
if view.action in ["retrieve"]:
518+
booking = Booking.objects.filter(pk=view.kwargs["pk"]).first()
519+
membership = self.get_membership_from_event(request=request, event=booking.occurrence.event)
520+
return membership is not None
521+
522+
if view.action in ["update", "partial_update"]:
523+
booking = Booking.objects.filter(pk=view.kwargs["pk"]).first()
524+
membership = self.get_membership_from_event(request=request, event=booking.occurrence.event)
525+
526+
if membership is not None:
527+
if membership.is_ta:
528+
return True
529+
else:
530+
updated_fields = request.data.keys()
531+
restricted_student_fields = ["occurrence", "start", "end"] # allowed_student_field would be "user"
532+
533+
for field in updated_fields:
534+
if field in restricted_student_fields:
535+
return False
536+
if "user" in updated_fields:
537+
if booking.user is not None: # Doesn’t allow a student to update user if someone already booked the slot
538+
return False
539+
else:
540+
return True
541+
else:
542+
return False
543+
544+
return False
545+
546+
def has_permission(self, request, view):
547+
if not request.user.is_authenticated:
548+
return False
549+
550+
# Anonymous users can't do anything
551+
if view.action in ["create"]:
552+
course_pk = request.data.get("course_id", None)
553+
if course_pk is None:
554+
return False
555+
556+
course = Course.objects.get(pk=course_pk)
557+
membership = Membership.objects.filter(course=course, user=request.user).first()
558+
559+
if membership is None:
560+
return False
561+
return membership.is_ta
562+
563+
if view.action in ["list"]:
564+
# if any member of the course in the list is not accessible, return false
565+
course_ids = request.GET.getlist("course")
566+
for course in course_ids:
567+
membership = Membership.objects.filter(course=course, user=request.user).first()
568+
if membership is None:
569+
return False
570+
return True
571+
572+
return True

backend/ohq/serializers.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
QueueStatistic,
2323
Semester,
2424
Tag,
25+
Booking,
2526
)
2627
from ohq.sms import sendSMSVerification
2728
from ohq.tasks import sendUpNextNotificationTask
@@ -592,4 +593,16 @@ class OccurrenceSerializer(serializers.ModelSerializer):
592593

593594
class Meta:
594595
model = Occurrence
595-
fields = ("id", "title", "description", "location", "start", "end", "cancelled", "event")
596+
fields = ("id", "title", "description", "location", "start", "end", "cancelled", "event", "interval")
597+
598+
class BookingSerializer(serializers.ModelSerializer):
599+
"""
600+
Serializer for booking
601+
"""
602+
603+
occurrence = OccurrenceSerializer(read_only=True)
604+
605+
class Meta:
606+
model = Booking
607+
fields = ("id", "occurrence", "user", "start", "end")
608+

backend/ohq/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SemesterViewSet,
2020
TagViewSet,
2121
UserView,
22+
BookingViewSet,
2223
)
2324

2425

@@ -29,6 +30,7 @@
2930
router.register("courses", CourseViewSet, basename="course")
3031
router.register("events", EventViewSet, basename="event")
3132
router.register("occurrences", OccurrenceViewSet, basename="occurrence")
33+
router.register("bookings", BookingViewSet, basename="booking")
3234

3335
course_router = routers.NestedSimpleRouter(router, "courses", lookup="course")
3436
course_router.register("queues", QueueViewSet, basename="queue")

backend/ohq/views.py

+104-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from django.http import HttpResponseBadRequest, JsonResponse
2020
from django.utils import timezone
2121
from django.utils.crypto import get_random_string
22+
from django.utils.translation import gettext_lazy as _
23+
from django.conf import settings as django_settings
2224
from django_auto_prefetching import prefetch
2325
from django_filters.rest_framework import DjangoFilterBackend
2426
from drf_excel.mixins import XLSXFileMixin
@@ -46,6 +48,7 @@
4648
QueueStatistic,
4749
Semester,
4850
Tag,
51+
Booking,
4952
)
5053
from ohq.pagination import QuestionSearchPagination
5154
from ohq.permissions import (
@@ -63,6 +66,7 @@
6366
QueuePermission,
6467
QueueStatisticPermission,
6568
TagPermission,
69+
BookingPermission,
6670
)
6771
from ohq.schemas import EventSchema, MassInviteSchema, OccurrenceSchema
6872
from ohq.serializers import (
@@ -81,6 +85,7 @@
8185
SemesterSerializer,
8286
TagSerializer,
8387
UserPrivateSerializer,
88+
BookingSerializer,
8489
)
8590
from ohq.sms import sendSMSVerification
8691

@@ -741,15 +746,15 @@ class OccurrenceViewSet(
741746
You must specify all of the fields or use a patch request.
742747
743748
partial_update:
744-
Update certain fields in the Occurrece.
749+
Update certain fields in the Occurrence.
745750
"""
746751

747752
serializer_class = OccurrenceSerializer
748753
permission_classes = [OccurrencePermission | IsSuperuser]
749754
schema = OccurrenceSchema()
750755

751756
def list(self, request, *args, **kwargs):
752-
# ensure timezone consitency
757+
# ensure timezone consistency
753758
course_ids = request.GET.getlist("course")
754759
filter_start = datetime.strptime(
755760
request.GET.get("filter_start"), "%Y-%m-%dT%H:%M:%SZ"
@@ -772,6 +777,103 @@ def list(self, request, *args, **kwargs):
772777

773778
serializer = OccurrenceSerializer(occurrences, many=True)
774779
return JsonResponse(serializer.data, safe=False)
780+
781+
def update(self, request, *args, **kwargs):
782+
occurrence = self.get_object()
783+
old_start = occurrence.start
784+
old_end = occurrence.end
785+
occurrence.start = datetime.strptime(request.data.get("start"), "%Y-%m-%dT%H:%M:%SZ").replace(
786+
tzinfo=utc
787+
)
788+
occurrence.end = datetime.strptime(request.data.get("end"), "%Y-%m-%dT%H:%M:%SZ").replace(
789+
tzinfo=utc
790+
)
791+
start_delta = occurrence.start - old_start
792+
end_delta = occurrence.end - old_end
793+
occurrence.save()
794+
795+
bookings = Booking.objects.filter(occurrence=occurrence).order_by("start")
796+
797+
for booking in bookings:
798+
booking.start += start_delta
799+
booking.end += end_delta
800+
booking.save()
801+
802+
serializer = OccurrenceSerializer(occurrence)
803+
return JsonResponse(serializer.data, safe=False)
775804

776805
def get_queryset(self):
777806
return Occurrence.objects.filter(pk=self.kwargs["pk"])
807+
808+
class BookingViewSet(
809+
mixins.ListModelMixin,
810+
mixins.RetrieveModelMixin,
811+
mixins.CreateModelMixin,
812+
mixins.UpdateModelMixin,
813+
viewsets.GenericViewSet,
814+
):
815+
"""
816+
retrieve:
817+
Return a Booking.
818+
819+
list:
820+
You should pass in an occurrence id, and all the bookings related to that occurrence will be returned to you.
821+
Return a list of bookings.
822+
823+
create:
824+
Create a booking.
825+
occurrenceId is required in body.
826+
827+
update:
828+
Update all fields in a Booking.
829+
You must specify all of the fields or use a patch request.
830+
831+
partial_update:
832+
Update certain fields in the Booking.
833+
"""
834+
835+
serializer_class = BookingSerializer
836+
permission_classes = [BookingPermission | IsSuperuser]
837+
838+
def create(self, request, *args, **kwargs):
839+
occurrence_id = request.data.get("occurrence")
840+
occurrence = Occurrence.objects.get(id=occurrence_id)
841+
user = request.user
842+
existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start")
843+
844+
if existing_bookings.exists():
845+
last_booking = existing_bookings.last()
846+
start = last_booking.end
847+
else:
848+
start = occurrence.start
849+
850+
end = start + occurrence.interval
851+
852+
if start < occurrence.start or end > occurrence.end:
853+
raise ValidationError(_("Booking times must be within the occurrence's time range."))
854+
855+
booking = Booking(
856+
occurrence=occurrence,
857+
user=user,
858+
start=start,
859+
end=end,
860+
)
861+
862+
booking.save()
863+
864+
serializer = BookingSerializer(booking)
865+
return JsonResponse(serializer.data, safe=False)
866+
867+
def list(self,request, *args, **kwargs):
868+
occurrence_id = request.GET.get("occurrence")
869+
if occurrence_id is None:
870+
raise ValidationError(_(f"Occurrence id is required."))
871+
872+
occurrence = Occurrence.objects.get(id=occurrence_id)
873+
existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start")
874+
875+
serializer = BookingSerializer(existing_bookings, many=True)
876+
return JsonResponse(serializer.data, safe=False)
877+
878+
def get_queryset(self):
879+
return Booking.objects.filter(pk=self.kwargs["pk"])

0 commit comments

Comments
 (0)