Skip to content

Commit 9b0d500

Browse files
authored
Merge pull request #332 from pennlabs/feature/office-hour-reservation
Add backend for bookable office hours
2 parents 58667fa + 4772190 commit 9b0d500

11 files changed

Lines changed: 478 additions & 25 deletions

File tree

backend/Pipfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ channels = "==3.0.5"
4141
channels-redis = "*"
4242
uvicorn = {extras = ["standard"],version = "*"}
4343
gunicorn = "*"
44-
django-schedules-ohq = "*"
4544
typing-extensions = "*"
4645
drf-excel = "*"
4746
pytz = "*"
4847
inflection = "*"
48+
django-scheduler = {git = "https://github.com/llazzaro/django-scheduler.git", ref = "8aa6f877f17e5b05f17d7c39e93d8e73625b0a65"}
4949

5050
[requires]
5151
python_version = "3.11"

backend/Pipfile.lock

Lines changed: 3 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/ohq/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Semester,
1414
Tag,
1515
UserStatistic,
16+
Booking,
1617
)
1718

1819

@@ -28,3 +29,4 @@
2829
admin.site.register(Announcement)
2930
admin.site.register(Tag)
3031
admin.site.register(UserStatistic)
32+
admin.site.register(Booking)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 5.0.3 on 2025-03-30 19:21
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("ohq", "0021_queue_question_timer_enabled_and_more"),
12+
("schedule", "0031_occurrence_location"),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Booking",
19+
fields=[
20+
(
21+
"id",
22+
models.AutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
("start", models.DateTimeField(db_index=True, verbose_name="start")),
30+
("end", models.DateTimeField(db_index=True, verbose_name="end")),
31+
(
32+
"occurrence",
33+
models.ForeignKey(
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="bookings",
36+
to="schedule.occurrence",
37+
),
38+
),
39+
(
40+
"user",
41+
models.ForeignKey(
42+
blank=True,
43+
null=True,
44+
on_delete=django.db.models.deletion.CASCADE,
45+
to=settings.AUTH_USER_MODEL,
46+
),
47+
),
48+
],
49+
options={
50+
"verbose_name": "booking",
51+
"verbose_name_plural": "bookings",
52+
"ordering": ["start"],
53+
"index_together": {("start", "end")},
54+
},
55+
),
56+
]

backend/ohq/models.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from django.conf import settings
22
from django.core.validators import MaxValueValidator, MinValueValidator
3+
from django.core.exceptions import ValidationError
34
from django.db import models
45
from django.dispatch import receiver
56
from email_tools.emails import send_email
67
from phonenumber_field.modelfields import PhoneNumberField
7-
8+
from schedule.models import Event, Occurrence
89

910
User = settings.AUTH_USER_MODEL
1011

@@ -447,3 +448,71 @@ class Meta:
447448

448449
def __str__(self):
449450
return f"{self.user}: {self.metric}"
451+
452+
class Booking(models.Model):
453+
"""
454+
Booking within an occurrence.
455+
Bookings can only be created with start times of 5-minute intervals.
456+
"""
457+
458+
occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE, related_name="bookings")
459+
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
460+
start = models.DateTimeField("start", db_index=True)
461+
end = models.DateTimeField("end", db_index=True)
462+
463+
class Meta:
464+
verbose_name = ("booking")
465+
verbose_name_plural = ("bookings")
466+
ordering = ["start"]
467+
index_together = (("start", "end"),)
468+
469+
def clean(self):
470+
if self.start >= self.end:
471+
raise ValidationError('Start time must be before end time.')
472+
473+
if self.start.minute % 5 != 0:
474+
raise ValidationError('Start time must be on a 5-minute interval (e.g., :00, :05, :10, :15, etc).')
475+
476+
if self.start < self.occurrence.start or self.end > self.occurrence.end:
477+
raise ValidationError('Booking times must be within the occurrence\'s start and end times.')
478+
479+
duration = self.end - self.start
480+
duration_minutes = duration.total_seconds() / 60
481+
482+
if duration_minutes != self.occurrence.interval:
483+
raise ValidationError(f'Booking duration must be {self.occurrence.interval} minutes.')
484+
485+
overlapping_bookings = Booking.objects.filter(
486+
occurrence=self.occurrence,
487+
start__lt=self.end,
488+
end__gt=self.start
489+
).exclude(id=self.id)
490+
if overlapping_bookings.exists():
491+
raise ValidationError('Booking times cannot overlap with existing bookings.')
492+
493+
super().clean()
494+
495+
def save(self, *args, **kwargs):
496+
self.clean()
497+
super().save(*args, **kwargs)
498+
499+
def __str__(self):
500+
start_str = self.start.strftime("%Y-%m-%d %H:%M:%S")
501+
end_str = self.end.strftime("%Y-%m-%d %H:%M:%S")
502+
return f"{start_str} to {end_str}"
503+
504+
Event.add_to_class('location', models.CharField(max_length=255, blank=True))
505+
Occurrence.add_to_class('location', models.CharField(max_length=255, blank=True))
506+
Occurrence.add_to_class('interval', models.IntegerField(blank=True, null=True))
507+
508+
def new_occurrence_init(self, *args, **kwargs):
509+
super(Occurrence, self).__init__(*args, **kwargs)
510+
event = kwargs.get("event", None)
511+
if not self.title and event:
512+
self.title = event.title
513+
if not self.description and event:
514+
self.description = event.description
515+
if not self.location and event:
516+
self.location = event.location
517+
518+
Occurrence.__init__ = new_occurrence_init

backend/ohq/permissions.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from django.db.models import Q
22
from rest_framework import permissions
33
from schedule.models import Event, EventRelation, Occurrence
4-
5-
from ohq.models import Course, Membership, Question
6-
4+
from ohq.models import (
5+
Course,
6+
Membership,
7+
Question,
8+
Booking,
9+
)
710

811
# Hierarchy of permissions is usually:
912
# Professor > Head TA > TA > Student > User
@@ -504,3 +507,44 @@ def has_permission(self, request, view):
504507
return True
505508

506509
return True
510+
511+
class BookingPermission(permissions.BasePermission):
512+
@staticmethod
513+
def get_membership_from_occurrence(request, occurrence):
514+
event_course_relation = EventRelation.objects.filter(event=occurrence.event).first()
515+
membership = Membership.objects.filter(
516+
course_id=event_course_relation.object_id, user=request.user
517+
).first()
518+
return membership
519+
520+
def has_object_permission(self, request, view, obj):
521+
if view.action in ["retrieve"]:
522+
booking = Booking.objects.filter(pk=view.kwargs["pk"]).first()
523+
membership = self.get_membership_from_occurrence(request=request, occurrence=booking.occurrence)
524+
return membership is not None
525+
526+
if view.action in ["update", "partial_update", "destroy"]:
527+
booking = Booking.objects.filter(pk=view.kwargs["pk"]).first()
528+
membership = self.get_membership_from_occurrence(request=request, occurrence=booking.occurrence)
529+
530+
if membership is None:
531+
return False
532+
533+
if membership.is_ta:
534+
return True
535+
536+
return booking.user == request.user
537+
538+
return False
539+
540+
def has_permission(self, request, view):
541+
if not request.user.is_authenticated:
542+
return False
543+
544+
if view.action in ["list", "create"]:
545+
occurrence_id = view.kwargs.get("occurrence_pk")
546+
occurrence = Occurrence.objects.filter(id=occurrence_id).first()
547+
membership = self.get_membership_from_occurrence(request=request, occurrence=occurrence)
548+
return membership is not None
549+
550+
return True

backend/ohq/serializers.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
from phonenumber_field.serializerfields import PhoneNumberField
88
from rest_framework import serializers
99
from rest_live.signals import save_handler
10-
from schedule.models import Calendar, Event, EventRelation, EventRelationManager, Rule
11-
from schedule.models.events import Occurrence
10+
from schedule.models import Calendar, Event, Occurrence, EventRelation, EventRelationManager, Rule
1211

1312
from ohq.models import (
1413
Announcement,
@@ -22,6 +21,7 @@
2221
QueueStatistic,
2322
Semester,
2423
Tag,
24+
Booking,
2525
)
2626
from ohq.sms import sendSMSVerification
2727
from ohq.tasks import sendUpNextNotificationTask
@@ -542,7 +542,7 @@ def update(self, instance, validated_data):
542542
else:
543543
rule, _ = Rule.objects.get_or_create(
544544
frequency=validated_data["rule"]["frequency"],
545-
params=validated_data["rule"]["params"],
545+
params=validated_data["rule"].get("params", ""),
546546
)
547547
validated_data.pop("rule")
548548

@@ -564,7 +564,7 @@ def create(self, validated_data):
564564
course = Course.objects.get(pk=validated_data["course_id"])
565565
rule = None
566566
if "rule" in validated_data and validated_data["rule"] is not None:
567-
rule, _ = Rule.objects.get_or_create(frequency=validated_data["rule"]["frequency"], params = validated_data["rule"]["params"])
567+
rule, _ = Rule.objects.get_or_create(frequency=validated_data["rule"]["frequency"], params = validated_data["rule"].get("params", ""))
568568
validated_data.pop("rule")
569569

570570
validated_data.pop("course_id")
@@ -592,4 +592,16 @@ class OccurrenceSerializer(serializers.ModelSerializer):
592592

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

backend/ohq/urls.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
SemesterViewSet,
2020
TagViewSet,
2121
UserView,
22+
BookingDetailViewSet,
23+
BookingListCreateViewSet,
2224
HealthView,
2325
)
2426

@@ -30,6 +32,10 @@
3032
router.register("courses", CourseViewSet, basename="course")
3133
router.register("events", EventViewSet, basename="event")
3234
router.register("occurrences", OccurrenceViewSet, basename="occurrence")
35+
router.register("bookings", BookingDetailViewSet, basename="booking")
36+
37+
occurrence_router = routers.NestedSimpleRouter(router, "occurrences", lookup="occurrence")
38+
occurrence_router.register("bookings", BookingListCreateViewSet, basename="booking-create")
3339

3440
course_router = routers.NestedSimpleRouter(router, "courses", lookup="course")
3541
course_router.register("queues", QueueViewSet, basename="queue")
@@ -65,4 +71,4 @@
6571
),
6672
]
6773

68-
urlpatterns = router.urls + course_router.urls + queue_router.urls + additional_urls
74+
urlpatterns = router.urls + occurrence_router.urls + course_router.urls + queue_router.urls + additional_urls

0 commit comments

Comments
 (0)