Skip to content

Commit 47ee309

Browse files
committed
Finalized backend for bookable office hours
1 parent 1ccc4c7 commit 47ee309

File tree

7 files changed

+363
-89
lines changed

7 files changed

+363
-89
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Generated by Django 5.0.3 on 2025-01-26 19:14
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("ohq", "0021_queue_question_timer_enabled_and_more"),
10+
("ohq", "0025_alter_booking_unique_together"),
11+
]
12+
13+
operations = []

backend/ohq/models.py

+159-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from django.dispatch import receiver
55
from email_tools.emails import send_email
66
from phonenumber_field.modelfields import PhoneNumberField
7-
from schedule.models import Event, Occurrence
7+
from schedule.models import Event
8+
from django.urls import reverse
9+
from datetime import timedelta
810

911
User = settings.AUTH_USER_MODEL
1012

@@ -447,13 +449,168 @@ class Meta:
447449

448450
def __str__(self):
449451
return f"{self.user}: {self.metric}"
452+
453+
class Occurrence(models.Model):
454+
event = models.ForeignKey(Event, on_delete=models.CASCADE, verbose_name=("event"), related_name="occurrences")
455+
title = models.CharField(("title"), max_length=255, blank=True)
456+
location = models.CharField(("location"), max_length=255, blank=True)
457+
description = models.TextField(("description"), blank=True)
458+
start = models.DateTimeField(("start"), db_index=True)
459+
end = models.DateTimeField(("end"), db_index=True)
460+
cancelled = models.BooleanField(("cancelled"), default=False)
461+
original_start = models.DateTimeField(("original start"))
462+
original_end = models.DateTimeField(("original end"))
463+
created_on = models.DateTimeField(("created on"), auto_now_add=True)
464+
updated_on = models.DateTimeField(("updated on"), auto_now=True)
465+
interval = models.IntegerField(("interval"), blank=True, validators=[MinValueValidator(5), MaxValueValidator(60)])
466+
467+
class Meta:
468+
verbose_name = ("occurrence")
469+
verbose_name_plural = ("occurrences")
470+
index_together = (("start", "end"),)
471+
472+
def __init__(self, *args, **kwargs):
473+
super().__init__(*args, **kwargs)
474+
event = kwargs.get("event", None)
475+
if not self.title and event:
476+
self.title = event.title
477+
if not self.description and event:
478+
self.description = event.description
479+
if not self.location and event:
480+
self.location = event.location
481+
482+
def save(self, *args, **kwargs):
483+
super().save(*args, **kwargs)
484+
485+
if self.pk: # If save is called on object update, not creation
486+
self.bookings.all().delete()
487+
488+
delta = self.end - self.start
489+
delta_minutes = delta.total_seconds() / 60
490+
booking_count = int(delta_minutes // self.interval)
491+
for i in range(booking_count):
492+
booking_start = self.start + timedelta(minutes=i * self.interval)
493+
booking_end = booking_start + timedelta(minutes=self.interval)
494+
Booking.objects.create(
495+
occurrence=self,
496+
user=None,
497+
start=booking_start,
498+
end = booking_end,
499+
)
500+
501+
def moved(self):
502+
return self.original_start != self.start or self.original_end != self.end
503+
504+
moved = property(moved)
505+
506+
def move(self, new_start, new_end):
507+
self.start = new_start
508+
self.end = new_end
509+
self.save()
510+
511+
def cancel(self):
512+
self.cancelled = True
513+
self.save()
514+
515+
def uncancel(self):
516+
self.cancelled = False
517+
self.save()
518+
519+
@property
520+
def seconds(self):
521+
return (self.end - self.start).total_seconds()
522+
523+
@property
524+
def minutes(self):
525+
return float(self.seconds) / 60
526+
527+
@property
528+
def hours(self):
529+
return float(self.seconds) / 3600
530+
531+
def get_absolute_url(self):
532+
if self.pk is not None:
533+
return reverse(
534+
"occurrence",
535+
kwargs={"occurrence_id": self.pk, "event_id": self.event_id},
536+
)
537+
return reverse(
538+
"occurrence_by_date",
539+
kwargs={
540+
"event_id": self.event_id,
541+
"year": self.start.year,
542+
"month": self.start.month,
543+
"day": self.start.day,
544+
"hour": self.start.hour,
545+
"minute": self.start.minute,
546+
"second": self.start.second,
547+
},
548+
)
549+
550+
def get_cancel_url(self):
551+
if self.pk is not None:
552+
return reverse(
553+
"cancel_occurrence",
554+
kwargs={"occurrence_id": self.pk, "event_id": self.event_id},
555+
)
556+
return reverse(
557+
"cancel_occurrence_by_date",
558+
kwargs={
559+
"event_id": self.event_id,
560+
"year": self.start.year,
561+
"month": self.start.month,
562+
"day": self.start.day,
563+
"hour": self.start.hour,
564+
"minute": self.start.minute,
565+
"second": self.start.second,
566+
},
567+
)
568+
569+
def get_edit_url(self):
570+
if self.pk is not None:
571+
return reverse(
572+
"edit_occurrence",
573+
kwargs={"occurrence_id": self.pk, "event_id": self.event_id},
574+
)
575+
return reverse(
576+
"edit_occurrence_by_date",
577+
kwargs={
578+
"event_id": self.event_id,
579+
"year": self.start.year,
580+
"month": self.start.month,
581+
"day": self.start.day,
582+
"hour": self.start.hour,
583+
"minute": self.start.minute,
584+
"second": self.start.second,
585+
},
586+
)
587+
588+
def __str__(self):
589+
start_str = self.start.strftime("%Y-%m-%d %H:%M:%S")
590+
end_str = self.end.strftime("%Y-%m-%d %H:%M:%S")
591+
return f"{start_str} to {end_str}"
592+
593+
def __lt__(self, other):
594+
return self.end < other.end
595+
596+
def __hash__(self):
597+
if not self.pk:
598+
raise TypeError("Model instances without primary key value are unhashable")
599+
return hash(self.pk)
600+
601+
def __eq__(self, other):
602+
return (
603+
isinstance(other, Occurrence)
604+
and self.original_start == other.original_start
605+
and self.original_end == other.original_end
606+
)
450607

451608
class Booking(models.Model):
452609
"""
453610
Booking within an occurrence
454611
"""
455612

456-
occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE)
613+
occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE, related_name="bookings")
457614
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
458615
start = models.DateTimeField("start", db_index=True)
459616
end = models.DateTimeField("end", db_index=True)

backend/ohq/permissions.py

+13-25
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from django.db.models import Q
22
from rest_framework import permissions
3-
from schedule.models import Event, EventRelation, Occurrence
4-
5-
from ohq.models import Course, Membership, Question, Booking
6-
3+
from schedule.models import Event, EventRelation
4+
from ohq.models import (
5+
Course,
6+
Membership,
7+
Question,
8+
Occurrence,
9+
Booking,
10+
)
711

812
# Hierarchy of permissions is usually:
913
# Professor > Head TA > TA > Student > User
@@ -543,27 +547,11 @@ def has_object_permission(self, request, view, obj):
543547
def has_permission(self, request, view):
544548
if not request.user.is_authenticated:
545549
return False
546-
547-
# Anonymous users can't do anything
548-
if view.action in ["create"]:
549-
course_pk = request.data.get("course_id", None)
550-
if course_pk is None:
551-
return False
552-
553-
course = Course.objects.get(pk=course_pk)
554-
membership = Membership.objects.filter(course=course, user=request.user).first()
555-
556-
if membership is None:
557-
return False
558-
return membership.is_ta
559-
550+
560551
if view.action in ["list"]:
561-
# if any member of the course in the list is not accessible, return false
562-
course_ids = request.GET.getlist("course")
563-
for course in course_ids:
564-
membership = Membership.objects.filter(course=course, user=request.user).first()
565-
if membership is None:
566-
return False
567-
return True
552+
occurrence_id = request.GET.get("occurrence")
553+
occurrence = Occurrence.objects.filter(id=occurrence_id).first()
554+
membership = self.get_membership_from_event(request, occurrence.event)
555+
return membership is not None
568556

569557
return True

backend/ohq/serializers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from rest_framework import serializers
99
from rest_live.signals import save_handler
1010
from schedule.models import Calendar, Event, EventRelation, EventRelationManager, Rule
11-
from schedule.models.events import Occurrence
1211

1312
from ohq.models import (
1413
Announcement,
@@ -22,6 +21,7 @@
2221
QueueStatistic,
2322
Semester,
2423
Tag,
24+
Occurrence,
2525
Booking,
2626
)
2727
from ohq.sms import sendSMSVerification

backend/ohq/views.py

+22-61
Original file line numberDiff line numberDiff line change
@@ -775,38 +775,13 @@ def list(self, request, *args, **kwargs):
775775

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

803779
def get_queryset(self):
804780
return Occurrence.objects.filter(pk=self.kwargs["pk"])
805781

806782
class BookingViewSet(
807783
mixins.ListModelMixin,
808784
mixins.RetrieveModelMixin,
809-
mixins.CreateModelMixin,
810785
mixins.UpdateModelMixin,
811786
viewsets.GenericViewSet,
812787
):
@@ -818,10 +793,6 @@ class BookingViewSet(
818793
You should pass in an occurrence id, and all the bookings related to that occurrence will be returned to you.
819794
Return a list of bookings.
820795
821-
create:
822-
Create a booking.
823-
occurrenceId is required in body.
824-
825796
update:
826797
Update all fields in a Booking.
827798
You must specify all of the fields or use a patch request.
@@ -832,46 +803,36 @@ class BookingViewSet(
832803

833804
serializer_class = BookingSerializer
834805
permission_classes = [BookingPermission | IsSuperuser]
835-
836-
def create(self, request, *args, **kwargs):
837-
occurrence_id = request.data.get("occurrence")
838-
occurrence = Occurrence.objects.get(id=occurrence_id)
839-
user = request.user
840-
existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start")
841-
842-
if existing_bookings.exists():
843-
last_booking = existing_bookings.last()
844-
start = last_booking.end
845-
else:
846-
start = occurrence.start
847-
848-
end = start + occurrence.interval
849-
850-
if start < occurrence.start or end > occurrence.end:
851-
raise ValidationError(_("Booking times must be within the occurrence's time range."))
852-
853-
booking = Booking(
854-
occurrence=occurrence,
855-
user=user,
856-
start=start,
857-
end=end,
858-
)
859-
860-
booking.save()
861-
862-
serializer = BookingSerializer(booking)
863-
return JsonResponse(serializer.data, safe=False)
864806

865807
def list(self,request, *args, **kwargs):
866808
occurrence_id = request.GET.get("occurrence")
867809
if occurrence_id is None:
868-
raise ValidationError(_(f"Occurrence id is required."))
869-
870-
occurrence = Occurrence.objects.get(id=occurrence_id)
810+
raise ValidationError((f"Occurrence id is required."))
811+
812+
try:
813+
occurrence = Occurrence.objects.filter(id=occurrence_id).first()
814+
except Occurrence.DoesNotExist:
815+
return JsonResponse({"detail": "Occurrence not found."}, status=404)
816+
871817
existing_bookings = Booking.objects.filter(occurrence=occurrence).order_by("start")
872818

873819
serializer = BookingSerializer(existing_bookings, many=True)
874820
return JsonResponse(serializer.data, safe=False)
875821

822+
def update(self, request, *args, **kwargs):
823+
partial = kwargs.pop('partial', False)
824+
instance = self.get_object()
825+
serializer = self.get_serializer(instance, data=request.data, partial=partial)
826+
serializer.is_valid(raise_exception=True)
827+
self.perform_update(serializer)
828+
829+
# Only the user field should be able to update
830+
user = self.request.user
831+
# Based on BookingPermission, you technically shouldn't be allowed to override another student if you're a student
832+
self.user = user
833+
834+
return JsonResponse(serializer.data, safe=False)
835+
836+
876837
def get_queryset(self):
877838
return Booking.objects.filter(pk=self.kwargs["pk"])

0 commit comments

Comments
 (0)