Skip to content

Commit

Permalink
✨ Add endpoint /courses/:start/:end
Browse files Browse the repository at this point in the history
  • Loading branch information
gwennlbh committed Feb 13, 2020
1 parent a419e8e commit c9045ad
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 6 deletions.
19 changes: 18 additions & 1 deletion backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from common.views import *
from reports.views import *
from django.contrib import admin
from django.urls import path, include
from django.urls import path, include, register_converter
from django.shortcuts import redirect
from rest_framework.routers import DefaultRouter
from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token
Expand All @@ -46,6 +46,22 @@
# ---------------------- Reports ------------------------
api.register(r'reports', ReportsViewSet, 'reports')

# Custom ISO 8601 path param type converter
class ISO8601Converter:
import dateutil
regex = r'^(?:[1-9]\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|[+-][01]\d:[0-5]\d))?$' # Yes, this is monstruous.

def to_python(self, value):
fmt = 'datetime' if 'T' in value else 'date'
value = dateutil.parser.isoparse(value)
return (fmt, value)

def to_url(self, value):
fmt, dt = value
dt.isoformat(value)

register_converter(ISO8601Converter, 'datetime')

# Add to urlpatterns
urlpatterns = [
path('api/users/self/', CurrentUserViewSet.as_view({'get': 'retrieve'})),
Expand All @@ -54,6 +70,7 @@
path('api/auth/verify/', verify_jwt_token),
path('api/auth/logout/', lambda req: HttpResponse('')),
path('api/password-reset/', include('django_rest_passwordreset.urls', namespace='password_reset')),
path('api/courses/<start>/<end>/', EventsViewSet.as_view({"get": "courses"})),
path('api/notes/convert/<slug:uuid_or_in_format>/<slug:out_format>/', NotesViewSet.as_view({"post": "convert", "get": "convert"})),
path('api/', include(api.urls)),
path('admin/', admin.site.urls),
Expand Down
27 changes: 25 additions & 2 deletions common/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import timedelta, datetime
def auto_list_display(Model, add=None, exclude=None):
from django.db.models.fields.related import ManyToManyField

Expand All @@ -9,14 +10,36 @@ def auto_list_display(Model, add=None, exclude=None):
and field.name not in exclude] + add # Add additional fields, remove excluded fields


def daterange(start, end):
def daterange(start, end) -> datetime:
""" Returns a range of datetime objects between start and end
"""
#TODO: configurable precision (years, months, weeks, days, hours, minutes, secs.)
from datetime import timedelta
for n in range(int((end - start).days) + 1):
yield start + timedelta(n)

def date_in_range(date: datetime, start: datetime, end: datetime) -> bool:
""" Check whether `date` is within the range going from `start` to `end`.
"""
return date >= start or date <= end

def dateranges_overlap(small_range, large_range) -> bool:
""" Check whether the `small_range` is contained in the `large_range`
"""
s_start, s_end = small_range
l_start, l_end = large_range

if type(s_start) is str:
s_start = datetime.fromisoformat(s_start)
if type(s_end) is str:
s_end = datetime.fromisoformat(s_end)
if type(l_start) is str:
l_start = datetime.fromisoformat(l_start)
if type(l_end) is str:
l_end = datetime.fromisoformat(l_end)

return s_start >= l_start and s_end <= l_end


def hyperlinked_field_method(prop, prop2='uuid', name=None):
if name is None: name = prop+'s'
def hyperlinked_field(self, obj):
Expand Down
15 changes: 15 additions & 0 deletions schedule/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from rest_framework.serializers import *
from rest_framework import serializers
from common.models import Subject
from common.serializers import *
from .models import *
import schedule.models



class EventSerializer(ModelSerializer):
Expand Down Expand Up @@ -46,3 +49,15 @@ class MutationReadSerializer(ModelSerializer):
class Meta:
model = Mutation
fields = '__all__'

class CourseReadSerializer(Serializer):
subject = SubjectSerializer()
uuid = serializers.UUIDField()
room = serializers.CharField(max_length=300)
week_type = serializers.ChoiceField(choices=schedule.models.WEEK_TYPES)
day = serializers.ChoiceField(choices=schedule.models.WEEK_DAYS)
mutation = MutationReadSerializer()

# This is the difference w/ Event:
start = serializers.DateTimeField()
end = serializers.DateTimeField()
179 changes: 176 additions & 3 deletions schedule/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from django.db.models import Q
from rest_framework.viewsets import ModelViewSet, ViewSet
from rest_framework.generics import ListAPIView
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import *
from common.models import Subject, Setting
from common.utils import daterange
from common.utils import daterange, date_in_range, dateranges_overlap
from schedule.models import WEEK_DAYS
from .serializers import *
import datetime
import dateutil

class EventsViewSet(ModelViewSet):
lookup_field = 'uuid'
Expand All @@ -17,7 +21,176 @@ def get_serializer_class(self):
def get_queryset(self):
user = self.request.user
return Event.objects.filter(subject__user__id=user.id)


@action(methods=['get'], detail=False)
def courses(self, request, start, end):
"""
/courses/:start/:end
QUERY PARAMS
------------
include: ','-separated list of 'deleted', 'added', 'rescheduled', 'offdays'
Choose what types of mutation-affected course to show.
Unaffected courses are always included.
Courses are marked as such with the `mutation` attribute
Default: added,rescheduled,deleted
week-type: ','-separated list of 'Q1', 'Q2' | 'auto'
Choose which week types to include.
'auto': only shows the current week type.
Default: Q1
TODO(beta-1.1.0): handle /courses/:start
TODO(beta-1.1.0): handle ?week-type=auto
MAYBE(beta-1.1.0): use ?start & ?end instead of url fragments
"""
user = request.user
#
# Query params processing
#

# Get the date format and parse accordingly
def parse_iso8601(value):
fmt = 'datetime' if 'T' in value else 'date'
if '/' in value:
value = datetime.datetime.strptime(value, '%d/%m/%Y')
else:
value = datetime.datetime.fromisoformat(value)
return (fmt, value)

# Processing start & end
start_fmt, start = parse_iso8601(start)
end_fmt, end = parse_iso8601(end)
if start_fmt != end_fmt:
return Response({
'error': 'Date range bounds must have the same format',
})

using_time = start_fmt == 'datetime' # We could have picked end_fmt since start_fmt == end_fmt

# Processing include
include = request.query_params.get('include', 'added,rescheduled,deleted')
include = [ i.strip() for i in include.split(',') ]
# Translating query parameter's mutation types into internal ones used by .models.Mutation
mutation_types_map = {
'deleted': 'DEL',
'rescheduled': 'RES',
'added': 'ADD'
}
include = [ mutation_types_map.get(i, i) for i in include ]

# Processing week-type
week_types = request.query_params.get('week-type', 'Q1')
week_types = [ w.strip() for w in week_types.split(',') ]

#
# Additionnal data processing
#

# Getting offdays as date ranges
# Get the raw setting
try:
offdays = Setting.objects.get(setting__key='offdays').value
except Setting.DoesNotExist:
offdays = []
else:
# Each line is a new daterange/date
offdays = offdays.split('\n')
# Split to get start & end
offdays = [ o.split(' - ') for o in offdays ]
# For simple dates, set start & end to the same date
offdays = [ [d[0], d[0]] for d in offdays if len(d) == 1 ]
# Parse the offdays' start & end dates into datetime.date objects
offdays = [
[ parse_iso8601(d.strip())[1] for d in o ]
for o in offdays
]

#
# Collecting events
#

# Init the variable containing all the courses
courses = []

# Loop over each day of the range
for day in daterange(start, end):
# TODO: handle ADD mutations
# Get the relevant events
events = Event.objects.filter(
Q(week_type__in=week_types) | Q(week_type='BOTH'),
Q(day=int(day.strftime('%u'))),
Q(subject__user__id=user.id)
)
# Set the date part of each event, instead of a HH:MM time. (for start & end)
for event in events:
# Combine the date parts and the time parts
event.start = datetime.datetime.combine(
day.date(), # The loop's day (date part)
event.start # The event's start time (time part)
)
# Do the same for the end
event.end = datetime.datetime.combine(
day.date(),
event.end
)

# Check if the event is in offdays
event.is_offday = False
# For each offday daterange
for offday in offdays:
offday_start, offday_end = offday
# Check if the event is in it
# If its already an offday because of another offday daterange, no need to check.
if not event.is_offday:
event.is_offday = dateranges_overlap(
(event.start, event.end),
(offday_start, offday_end)
)

# Don't add the course to the list if the conditions aren't met
if event.is_offday and 'offdays' not in include:
continue

# Check if the event matches a mutation
# (for EDT/RES/DEL mutations)
# for ADD mutations, the mutation isn't bounded to an event.
# We treat this separetly, and add them to the courses list outside of the events loop
event.mutation = None
for mutation in event.mutations.all():
# Determine if the mutation's date ranges matches the event
deleted_matches = False
added_matches = False
# Mutation types: see models.Mutation
if mutation.type in ('DEL', 'RES'):
deleted_matches = dateranges_overlap(
(mutation.deleted_start, mutation.deleted_end),
(event.start, event.end)
)
elif mutation.type == 'RES':
added_matches = dateranges_overlap(
(mutation.added_start, mutation.added_end),
(event.start, event.end)
)
# There should be at most one mutation relating to a single _course_ (not event).
# We set the mutation without further checking.
event.mutation = mutation

event.original_room = event.room
if event.mutation is not None:
# Process EDT/RES mutations that could modify the room
if event.mutation.type in ('EDT', 'RES') and event.mutation.room is not None:
event.room = event.mutation.room

# Don't append courses whose mutation's type is not in include
if event.mutation.type not in include:
continue
# Append the course to the list
courses.append(event)

# Return the response
return Response([ CourseReadSerializer(course).data for course in courses ])

class MutationsViewSet(ModelViewSet):
lookup_field = 'uuid'

Expand All @@ -27,4 +200,4 @@ def get_serializer_class(self):
return MutationSerializer
def get_queryset(self):
user = self.request.user
return Mutation.objects.filter(event__subject__user__id=user.id)
return Mutation.objects.filter(event__subject__user__id=user.id)

0 comments on commit c9045ad

Please sign in to comment.