Skip to content

Add backend for bookable office hours #332

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ channels = "==3.0.5"
channels-redis = "*"
uvicorn = {extras = ["standard"],version = "*"}
gunicorn = "*"
django-schedules-ohq = "*"
typing-extensions = "*"
drf-excel = "*"
pytz = "*"
inflection = "*"
django-scheduler = {git = "https://github.com/llazzaro/django-scheduler.git", ref = "8aa6f877f17e5b05f17d7c39e93d8e73625b0a65"}

[requires]
python_version = "3.11"
10 changes: 3 additions & 7 deletions backend/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/ohq/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Semester,
Tag,
UserStatistic,
Booking,
)


Expand All @@ -28,3 +29,4 @@
admin.site.register(Announcement)
admin.site.register(Tag)
admin.site.register(UserStatistic)
admin.site.register(Booking)
56 changes: 56 additions & 0 deletions backend/ohq/migrations/0022_booking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 5.0.3 on 2025-03-30 19:21

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("ohq", "0021_queue_question_timer_enabled_and_more"),
("schedule", "0031_occurrence_location"),
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,
related_name="bookings",
to="schedule.occurrence",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "booking",
"verbose_name_plural": "bookings",
"ordering": ["start"],
"index_together": {("start", "end")},
},
),
]
71 changes: 70 additions & 1 deletion backend/ohq/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ValidationError
from django.db import models
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

Expand Down Expand Up @@ -447,3 +448,71 @@ class Meta:

def __str__(self):
return f"{self.user}: {self.metric}"

class Booking(models.Model):
"""
Booking within an occurrence.
Bookings can only be created with start times of 5-minute intervals.
"""

occurrence = models.ForeignKey(Occurrence, on_delete=models.CASCADE, related_name="bookings")
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 clean(self):
if self.start >= self.end:
raise ValidationError('Start time must be before end time.')

if self.start.minute % 5 != 0:
raise ValidationError('Start time must be on a 5-minute interval (e.g., :00, :05, :10, :15, etc).')

if self.start < self.occurrence.start or self.end > self.occurrence.end:
raise ValidationError('Booking times must be within the occurrence\'s start and end times.')

duration = self.end - self.start
duration_minutes = duration.total_seconds() / 60

if duration_minutes != self.occurrence.interval:
raise ValidationError(f'Booking duration must be {self.occurrence.interval} minutes.')

overlapping_bookings = Booking.objects.filter(
occurrence=self.occurrence,
start__lt=self.end,
end__gt=self.start
).exclude(id=self.id)
if overlapping_bookings.exists():
raise ValidationError('Booking times cannot overlap with existing bookings.')

super().clean()

def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)

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}"

Event.add_to_class('location', models.CharField(max_length=255, blank=True))
Occurrence.add_to_class('location', models.CharField(max_length=255, blank=True))
Occurrence.add_to_class('interval', models.IntegerField(blank=True, null=True))

def new_occurrence_init(self, *args, **kwargs):
super(Occurrence, self).__init__(*args, **kwargs)
event = kwargs.get("event", None)
if not self.title and event:
self.title = event.title
if not self.description and event:
self.description = event.description
if not self.location and event:
self.location = event.location

Occurrence.__init__ = new_occurrence_init
50 changes: 47 additions & 3 deletions backend/ohq/permissions.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from django.db.models import Q
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:
# Professor > Head TA > TA > Student > User
Expand Down Expand Up @@ -504,3 +507,44 @@ def has_permission(self, request, view):
return True

return True

class BookingPermission(permissions.BasePermission):
@staticmethod
def get_membership_from_occurrence(request, occurrence):
event_course_relation = EventRelation.objects.filter(event=occurrence.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_occurrence(request=request, occurrence=booking.occurrence)
return membership is not None

if view.action in ["update", "partial_update", "destroy"]:
booking = Booking.objects.filter(pk=view.kwargs["pk"]).first()
membership = self.get_membership_from_occurrence(request=request, occurrence=booking.occurrence)

if membership is None:
return False

if membership.is_ta:
return True

return booking.user == request.user

return False

def has_permission(self, request, view):
if not request.user.is_authenticated:
return False

if view.action in ["list", "create"]:
occurrence_id = view.kwargs.get("occurrence_pk")
occurrence = Occurrence.objects.filter(id=occurrence_id).first()
membership = self.get_membership_from_occurrence(request=request, occurrence=occurrence)
return membership is not None

return True
22 changes: 17 additions & 5 deletions backend/ohq/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from phonenumber_field.serializerfields import PhoneNumberField
from rest_framework import serializers
from rest_live.signals import save_handler
from schedule.models import Calendar, Event, EventRelation, EventRelationManager, Rule
from schedule.models.events import Occurrence
from schedule.models import Calendar, Event, Occurrence, EventRelation, EventRelationManager, Rule

from ohq.models import (
Announcement,
Expand All @@ -22,6 +21,7 @@
QueueStatistic,
Semester,
Tag,
Booking,
)
from ohq.sms import sendSMSVerification
from ohq.tasks import sendUpNextNotificationTask
Expand Down Expand Up @@ -542,7 +542,7 @@ def update(self, instance, validated_data):
else:
rule, _ = Rule.objects.get_or_create(
frequency=validated_data["rule"]["frequency"],
params=validated_data["rule"]["params"],
params=validated_data["rule"].get("params", ""),
)
validated_data.pop("rule")

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

validated_data.pop("course_id")
Expand Down Expand Up @@ -592,4 +592,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")

8 changes: 7 additions & 1 deletion backend/ohq/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
SemesterViewSet,
TagViewSet,
UserView,
BookingDetailViewSet,
BookingListCreateViewSet,
HealthView,
)

Expand All @@ -30,6 +32,10 @@
router.register("courses", CourseViewSet, basename="course")
router.register("events", EventViewSet, basename="event")
router.register("occurrences", OccurrenceViewSet, basename="occurrence")
router.register("bookings", BookingDetailViewSet, basename="booking")

occurrence_router = routers.NestedSimpleRouter(router, "occurrences", lookup="occurrence")
occurrence_router.register("bookings", BookingListCreateViewSet, basename="booking-create")

course_router = routers.NestedSimpleRouter(router, "courses", lookup="course")
course_router.register("queues", QueueViewSet, basename="queue")
Expand Down Expand Up @@ -65,4 +71,4 @@
),
]

urlpatterns = router.urls + course_router.urls + queue_router.urls + additional_urls
urlpatterns = router.urls + occurrence_router.urls + course_router.urls + queue_router.urls + additional_urls
Loading
Loading