From c9045adc16870bf22456358a06eec3f08c2ca069 Mon Sep 17 00:00:00 2001 From: Ewen Le Bihan Date: Thu, 13 Feb 2020 16:29:32 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20endpoint=20/courses/:start/:e?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/urls.py | 19 ++++- common/utils.py | 27 +++++- schedule/serializers.py | 15 ++++ schedule/views.py | 179 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 234 insertions(+), 6 deletions(-) diff --git a/backend/urls.py b/backend/urls.py index 3caa786..5424cb5 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -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 @@ -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'})), @@ -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///', EventsViewSet.as_view({"get": "courses"})), path('api/notes/convert///', NotesViewSet.as_view({"post": "convert", "get": "convert"})), path('api/', include(api.urls)), path('admin/', admin.site.urls), diff --git a/common/utils.py b/common/utils.py index 599de9d..bb3bffa 100644 --- a/common/utils.py +++ b/common/utils.py @@ -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 @@ -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): diff --git a/schedule/serializers.py b/schedule/serializers.py index 1532552..6bbaf04 100644 --- a/schedule/serializers.py +++ b/schedule/serializers.py @@ -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): @@ -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() diff --git a/schedule/views.py b/schedule/views.py index ad016bb..eaf813e 100644 --- a/schedule/views.py +++ b/schedule/views.py @@ -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' @@ -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' @@ -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) \ No newline at end of file + return Mutation.objects.filter(event__subject__user__id=user.id)