From 49df717aa0bbbc63115cd37bffcddce117ec8ea3 Mon Sep 17 00:00:00 2001
From: ingo <ingo.leindecker@fro.at>
Date: Wed, 10 Jan 2018 22:49:41 +0100
Subject: [PATCH] Better conflict resolution

See #8 #10 #20 #22

Documentation: https://gitlab.servus.at/autoradio/meta/blob/master/conflict-resolution.md
---
 program/models.py | 483 +++++++++++++++++++++++++++++++++++++++++++++-
 program/views.py  | 170 +++++++++-------
 pv/settings.py    |   8 +
 pv/urls.py        |   1 -
 4 files changed, 580 insertions(+), 82 deletions(-)

diff --git a/program/models.py b/program/models.py
index 468f88cb..fa980520 100644
--- a/program/models.py
+++ b/program/models.py
@@ -3,9 +3,11 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError, Multiple
 from django.urls import reverse
 from django.db import models
 from django.db.models import Q
+from django.forms.models import model_to_dict
 from django.utils.translation import ugettext_lazy as _
 from versatileimagefield.fields import VersatileImageField, PPOIField
 from django.conf import settings
+import hashlib
 
 from tinymce import models as tinymce_models
 
@@ -13,9 +15,10 @@ from datetime import date, datetime, time, timedelta
 from dateutil.relativedelta import relativedelta
 from dateutil.rrule import rrule
 
-
 from .utils import get_automation_id_choices
 
+from pv.settings import SECRET_KEY, AUTO_SET_UNTIL_DATE_TO_END_OF_YEAR, AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE
+
 
 class Type(models.Model):
     type = models.CharField(_("Type"), max_length=32)
@@ -407,10 +410,6 @@ class Schedule(models.Model):
 
     class Meta:
         ordering = ('dstart', 'tstart')
-        # DEPRECATED
-        # Produces error when adding several schedules at the same time
-        # get_collisions() covers this case and checks for interfering times too
-        # unique_together = ('rrule', 'byweekday', 'dstart', 'tstart')
         verbose_name = _("Schedule")
         verbose_name_plural = _("Schedules")
 
@@ -428,6 +427,50 @@ class Schedule(models.Model):
         else:
             return '%s, %s, %s - %s' % (weekday, self.rrule, tstart, tend)
 
+    def instantiate_upcoming(sdl, show_pk, pk=None):
+        """Returns a schedule instance for conflict resolution"""
+
+        pk = int(show_pk) if pk != None else None
+        rrule = RRule.objects.get(pk=int(sdl['rrule']))
+        show = Show.objects.get(pk=int(show_pk))
+
+        is_repetition = True if 'is_repetition' in sdl and sdl['is_repetition'] == 'true' else False
+        fallback_id = int(sdl['fallback_id']) if sdl['fallback_id'] else None
+        automation_id = int(sdl['automation_id']) if sdl['automation_id'] else None
+
+        dstart = datetime.strptime(str(sdl['dstart']), '%Y-%m-%d').date()
+        if dstart < datetime.today().date(): # Schedule mustn't start in the past
+            dstart = datetime.today().date()
+
+        tstart = sdl['tstart'] + ':00' if len(str(sdl['tstart'])) == 5 else sdl['tstart']
+        tend = sdl['tend'] + ':00' if len(str(sdl['tend'])) == 5 else sdl['tend']
+
+        tstart = datetime.strptime(str(tstart), '%H:%M:%S').time()
+        tend = datetime.strptime(str(tend), '%H:%M:%S').time()
+
+        if sdl['until']:
+            until = datetime.strptime(str(sdl['until']), '%Y-%m-%d').date()
+        else:
+            # If no until date was set
+            # Set it to the end of the year
+            # Or add x days
+            if AUTO_SET_UNTIL_DATE_TO_END_OF_YEAR:
+                now = datetime.now()
+                until = datetime.strptime(str(now.year) + '-12-31', '%Y-%m-%d').date()
+            else:
+                until = dstart + timedelta(days=+AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE)
+
+
+        schedule = Schedule(pk=pk, byweekday=sdl['byweekday'], rrule=rrule,
+                            dstart=dstart,
+                            tstart=tstart,
+                            tend=tend,
+                            until=until, is_repetition=is_repetition,
+                            fallback_id=fallback_id, show=show)
+
+        return schedule
+
+
     def generate_timeslots(schedule):
         """
         Returns a list of timeslot objects based on a schedule and its rrule
@@ -509,7 +552,6 @@ class Schedule(models.Model):
 
         for k in range(min(len(starts), len(ends))):
             timeslots.append(TimeSlot(schedule=schedule, start=starts[k], end=ends[k]).generate())
-            print(str(starts[k]) + ' - ' + str(ends[k]))
 
         return timeslots
 
@@ -540,15 +582,431 @@ class Schedule(models.Model):
         return collisions
 
 
+    def generate_conflicts(timeslots):
+        """
+        Tests a list of timeslot objects for colliding timeslots in the database
+        Returns a list of conflicts containing dicts of projected timeslots, collisions and solutions
+        """
+
+        conflicts = []
+
+        # Cycle each timeslot
+        for ts in timeslots:
+
+            # Contains one conflict: a projected timeslot, collisions and solutions
+            conflict = {}
+
+            # The projected timeslot
+            projected = {}
+
+            # Contains collisions
+            collisions = []
+
+            # Contains solutions
+            solutions = set()
+
+            # Get collisions for each timeslot
+            collision_list = list(TimeSlot.objects.filter(
+                           ( Q(start__lt=ts.end) & Q(end__gte=ts.end) ) |
+                           ( Q(end__gt=ts.start) & Q(end__lte=ts.end) ) |
+                           ( Q(start__gte=ts.start) & Q(end__lte=ts.end) ) |
+                           ( Q(start__lte=ts.start) & Q(end__gte=ts.end) )
+                        ).order_by('start'))
+
+
+
+            for c in collision_list:
+
+                # Add the collision
+                collision = {}
+                collision['id'] = c.id
+                collision['start'] = str(c.start)
+                collision['end'] = str(c.end)
+                collision['playlist_id'] = c.playlist_id
+                collision['show'] = c.show.id
+                is_repetition = ' (' + str(_('REP')) + ')' if c.is_repetition else ''
+                collision['show_name'] = c.show.name + is_repetition
+
+                # Get note
+                try:
+                    note = Note.objects.get(timeslot=c.id).values_list('id', flat=True)
+                    collision['note_id'] = note
+                except ObjectDoesNotExist:
+                    pass
+
+                collisions.append(collision)
+
+
+                '''Determine acceptable solutions'''
+
+                if len(collision_list) > 1:
+                    # If there is more than one collision: Only these two are supported at the moment
+                    solutions.add('theirs')
+                    solutions.add('ours')
+                else:
+                    # These two are always possible: Either keep theirs and remove ours or vice versa
+                    solutions.add('theirs')
+                    solutions.add('ours')
+
+                    # Partly overlapping: projected starts earlier than existing and ends earlier
+                    #
+                    #  ex.   pr.
+                    #       +--+
+                    #       |  |
+                    # +--+  |  |
+                    # |  |  +--+
+                    # |  |
+                    # +--+
+                    if ts.start < c.start and ts.end > c.start and ts.end <= c.end:
+                        solutions.add('theirs-end')
+                        solutions.add('ours-end')
+
+                    # Partly overlapping: projected start later than existing and ends later
+                    #
+                    #  ex.   pr.
+                    # +--+
+                    # |  |
+                    # |  |  +--+
+                    # +--+  |  |
+                    #       |  |
+                    #       +--+
+                    if ts.start >= c.start and ts.start < c.end and ts.end > c.end:
+                        solutions.add('theirs-start')
+                        solutions.add('ours-start')
+
+                    # Fully overlapping: projected starts earlier and ends later than existing
+                    #
+                    #  ex.   pr.
+                    #       +--+
+                    # +--+  |  |
+                    # |  |  |  |
+                    # +--+  |  |
+                    #       +--+
+                    if ts.start < c.start and ts.end > c.end:
+                        solutions.add('theirs-end')
+                        solutions.add('theirs-start')
+                        solutions.add('theirs-both')
+
+                    # Fully overlapping: projected starts later and ends earlier than existing
+                    #
+                    #  ex.   pr.
+                    # +--+
+                    # |  |  +--+
+                    # |  |  |  |
+                    # |  |  +--+
+                    # +--+
+                    if ts.start > c.start and ts.end < c.end:
+                        solutions.add('ours-end')
+                        solutions.add('ours-start')
+                        solutions.add('ours-both')
+
+
+            # Add the projected timeslot
+            projected['hash'] = ts.hash
+            projected['start'] = str(ts.start)
+            projected['end'] = str(ts.end)
+            projected['playlist_id'] = ts.playlist_id
+            projected['show'] = ts.show.id
+            projected['show_name'] = ts.show.name
+
+            # Put the conflict together
+            conflict['projected'] = projected
+            conflict['collisions'] = collisions
+            if solutions:
+                conflict['solutions'] = solutions
+                conflict['resolution'] = 'theirs'
+
+            conflicts.append(conflict)
+
+        return conflicts
+
+
+    def make_conflicts(sdl, schedule_pk, show_pk):
+        """
+        Retrieves POST vars
+        Generates a schedule
+        Generates conflicts: Returns timeslots, collisions, solutions as JSON
+        Returns conflicts dict
+        """
+
+        # Generate schedule
+        schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
+
+        # Generate timeslots
+        timeslots = Schedule.generate_timeslots(schedule)
+
+        # Generate conflicts
+        conflicts = Schedule.generate_conflicts(timeslots)
+
+        # Prepend schedule data
+        conflicts.insert(0, model_to_dict(schedule))
+
+        return conflicts
+
+
+    def resolve_conflicts(resolved, schedule_pk, show_pk):
+        """
+        Resolves conflicts
+        Expects JSON POST data from /shows/1/schedules/
+
+        Returns an array of objects if errors were found
+        Returns nothing if resolution was successful
+        """
+
+        # Get schedule data
+        sdl = resolved.pop(0)
+
+        schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
+        show = schedule.show
+        conflicts = Schedule.make_conflicts(sdl, schedule.pk, show.pk)
+
+        # Get rid of schedule data, we don't need it here
+        del(conflicts[0])
+
+        if schedule.rrule.freq > 0 and schedule.dstart == schedule.until:
+            return {'detail': _("Start and until times mustn't be the same")}
+
+        if len(resolved) != len(conflicts):
+            return {'detail': _("Numbers of conflicts and resolutions don't match.")}
+
+        # Projected timeslots to create
+        create = []
+
+        # Existing timeslots to update
+        update = []
+
+        # Existing timeslots to delete
+        delete = []
+
+        # Error messages
+        errors = []
+
+        for index, ts in enumerate(conflicts):
+
+            # Check hash and skip in case it differs
+            if ts['projected']['hash'] != resolved[index]['projected']['hash']:
+                errors.append({ts['projected']['hash']: _("Hash corrupted.")})
+                continue
+
+            # Ignore past dates
+            if datetime.strptime(ts['projected']['start'], "%Y-%m-%d %H:%M:%S") <= datetime.today():
+                continue
+
+            # If no solution necessary
+            #
+            #     - Create the projected timeslot and skip
+            #
+            if not 'solutions' in ts or len(ts['collisions']) < 1:
+                projected = TimeSlot.objects.instantiate(ts['projected']['start'], ts['projected']['end'], schedule, show)
+                create.append(projected)
+                continue
+
+            # If no resolution given
+            #
+            #     - Skip
+            #
+            if not 'resolution' in resolved[index] or resolved[index]['resolution'] == '':
+                errors.append({ts['projected']['hash']: _("No resolution given.")})
+                continue
+
+            # If resolution is not accepted for this conflict
+            #
+            #     - Skip
+            #
+            if not resolved[index]['resolution'] in ts['solutions']:
+                errors.append({ts['projected']['hash']: _("Given resolution is not accepted for this conflict.")})
+                continue
+
+
+            '''Conflict resolution'''
+
+            # TODO: Really retrieve by index?
+            projected = ts['projected']
+            existing = resolved[index]['collisions'][0]
+            solutions = ts['solutions']
+            resolution = resolved[index]['resolution']
+
+            # theirs
+            #
+            #     - Discard the projected timeslot
+            #     - Keep the existing collision(s)
+            #
+            if resolution == 'theirs':
+                continue
+
+
+            # ours
+            #
+            #     - Create the projected timeslot
+            #     - Delete the existing collision(s)
+            #
+            if resolution == 'ours':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+                # Delete collision(s)
+                for ex in resolved[index]['collisions']:
+                    try:
+                        existing_ts = TimeSlot.objects.get(pk=ex['id'])
+                        delete.append(existing_ts)
+                    except ObjectDoesNotExist:
+                        pass
+
+
+            # theirs-end
+            #
+            #     - Keep the existing timeslot
+            #     - Create projected with end of existing start
+            #
+            if resolution == 'theirs-end':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], existing['start'], schedule, show)
+                create.append(projected_ts)
+
+
+            # ours-end
+            #
+            #     - Create the projected timeslot
+            #     - Change the start of the existing collision to my end time
+            #
+            if resolution == 'ours-end':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+                existing_ts = TimeSlot.objects.get(pk=existing['id'])
+                existing_ts.start = datetime.strptime(projected['end'], '%Y-%m-%d %H:%M:%S')
+                update.append(existing_ts)
+
+
+            # theirs-start
+            #
+            #     - Keep existing
+            #     - Create projected with start time of existing end
+            #
+            if resolution == 'theirs-start':
+                projected_ts = TimeSlot.objects.instantiate(existing['end'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+
+            # ours-start
+            #
+            #     - Create the projected timeslot
+            #     - Change end of existing to projected start
+            #
+            if resolution == 'ours-start':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+                existing_ts = TimeSlot.objects.get(pk=existing['id'])
+                existing_ts.end = datetime.strptime(projected['start'], '%Y-%m-%d %H:%M:%S')
+                update.append(existing_ts)
+
+
+            # theirs-both
+            #
+            #     - Keep existing
+            #     - Create two projected timeslots with end of existing start and start of existing end
+            #
+            if resolution == 'theirs-both':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], existing['start'], schedule, show)
+                create.append(projected_ts)
+
+                projected_ts = TimeSlot.objects.instantiate(existing['end'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+
+            # ours-both
+            #
+            #     - Create projected
+            #     - Split existing into two:
+            #       - Set existing end time to projected start
+            #       - Create another one with start = projected end and end = existing end
+            #
+            if resolution == 'ours-both':
+                projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show)
+                create.append(projected_ts)
+
+                existing_ts = TimeSlot.objects.get(pk=existing['id'])
+                existing_ts.end = datetime.strptime(projected['start'], '%Y-%m-%d %H:%M:%S')
+                update.append(existing_ts)
+
+                projected_ts = TimeSlot.objects.instantiate(projected['end'], existing['end'], schedule, show)
+                create.append(projected_ts)
+
+
+        # If there were any errors, don't make any db changes yet
+        # but add error messages and already chosen resolutions to conflicts
+        if errors:
+            conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk)
+
+            sdl = conflicts.pop(0)
+            partly_resolved = conflicts
+
+            # Add already chosen resolutions and error message to conflict
+            for index, c in enumerate(conflicts):
+                if 'resolution' in c:
+                    partly_resolved[index]['resolution'] = resolved[index]['resolution']
+
+                if c['projected']['hash'] in errors[0]:
+                    partly_resolved[index]['error'] = errors[0][c['projected']['hash']]
+
+            # Re-insert schedule data
+            partly_resolved.insert(0, sdl)
+
+            return partly_resolved
+
+
+        # If there were no errors, execute all database changes
+
+        # Only save schedule if timeslots were created
+        if create:
+            # Create or save schedule
+            print("saving schedule")
+            schedule.save()
+
+            # Delete upcoming timeslots
+            # TODO: Which way to go?
+            # Two approaches (only valid for updating a schedule):
+            #     - Either we exclude matching a schedule to itself beforehand and then delete every upcoming before we create new ones
+            #     - Or we match a schedule against itself, let the user resolve everything and only delete those which still might exist after the new until date
+            # For now I decided for approach 2:
+            TimeSlot.objects.filter(schedule=schedule, start__gt=schedule.until).delete()
+
+        # Update timeslots
+        for ts in update:
+            print("updating " + str(ts))
+            ts.save(update_fields=["start", "end"])
+
+        # Create timeslots
+        for ts in create:
+            print("creating " + str(ts))
+            ts.schedule = schedule
+            ts.save()
+
+        ######
+        # TODO: Relink notes while the original timeslots are not yet deleted
+        # TODO: Add the ability to relink playlist_ids as well!
+        ######
+
+        # Delete manually resolved timeslots
+        for dl in delete:
+            print("deleting " + str(dl))
+            dl.delete()
+
+        return []
+
+
     def save(self, *args, **kwargs):
-        # TODO: Test if auto_now_add and auto_now really always work
-        #if not self.id or self.id == None:
-        #    self.created = datetime.today()
         super(Schedule, self).save(*args, **kwargs)
 
 
 
 class TimeSlotManager(models.Manager):
+    @staticmethod
+    def instantiate(start, end, schedule, show):
+        return TimeSlot(start=datetime.strptime(start, '%Y-%m-%d %H:%M:%S'),
+                        end=datetime.strptime(end, '%Y-%m-%d %H:%M:%S'),
+                        show=show, is_repetition=schedule.is_repetition, schedule=schedule)
+
     @staticmethod
     def get_or_create_current():
         try:
@@ -632,7 +1090,7 @@ class TimeSlot(models.Model):
     def __str__(self):
         start = self.start.strftime('%a, %d.%m.%Y %H:%M')
         end = self.end.strftime('%H:%M')
-        is_repetition = ' ' + _('REP') if self.schedule.is_repetition is 1 else ''
+        is_repetition = ' (' + _('REP') + ')' if self.schedule.is_repetition is 1 else ''
 
         return '%s - %s  %s (%s)' % (start, end, is_repetition, self.show.name)
 
@@ -643,7 +1101,12 @@ class TimeSlot(models.Model):
 
     def generate(self, **kwargs):
         """Returns the object instance without saving"""
+
         self.show = self.schedule.show
+
+        # Generate a distinct and reproducible hash for the timeslot
+        # Makes sure nothing changed on the timeslot and schedule when resolving conflicts
+        self.hash = hashlib.sha224(str(self.start).encode('utf-8') +  str(self.end).encode('utf-8') + str(self.schedule.until).encode('utf-8') + str(self.schedule.rrule).encode('utf-8') + str(SECRET_KEY).encode('utf-8')).hexdigest()
         return self;
 
     def get_absolute_url(self):
diff --git a/program/views.py b/program/views.py
index 55737bb4..9dfc3c39 100644
--- a/program/views.py
+++ b/program/views.py
@@ -10,34 +10,37 @@ from django.shortcuts import get_object_or_404
 from django.views.generic.base import TemplateView
 from django.views.generic.detail import DetailView
 from django.views.generic.list import ListView
+from django.forms.models import model_to_dict
 from rest_framework import permissions, serializers, status, viewsets
 from rest_framework.views import APIView
 from rest_framework.response import Response
-from rest_framework.renderers import JSONRenderer
 from rest_framework.pagination import LimitOffsetPagination
-from rest_framework.decorators import api_view
 
 from program.models import Type, MusicFocus, Language, Note, Show, Category, RTRCategory, Topic, TimeSlot, Host, Schedule, RRule
 from program.serializers import TypeSerializer, LanguageSerializer, MusicFocusSerializer, NoteSerializer, ShowSerializer, ScheduleSerializer, CategorySerializer, RTRCategorySerializer, TopicSerializer, TimeSlotSerializer, HostSerializer, UserSerializer
 from program.utils import tofirstdayinisoweek, get_cached_shows
 
 
+# Deprecated
 class CalendarView(TemplateView):
     template_name = 'calendar.html'
 
 
+# Deprecated
 class HostListView(ListView):
     context_object_name = 'host_list'
     queryset = Host.objects.filter(Q(is_always_visible=True) | Q(shows__schedules__until__gt=datetime.now())).distinct()
     template_name = 'host_list.html'
 
 
+# Deprecated
 class HostDetailView(DetailView):
     context_object_name = 'host'
     queryset = Host.objects.all()
     template_name = 'host_detail.html'
 
 
+# Deprecated
 class ShowListView(ListView):
     context_object_name = 'show_list'
     template_name = 'show_list.html'
@@ -64,16 +67,19 @@ class ShowListView(ListView):
         return queryset
 
 
+# Deprecated
 class ShowDetailView(DetailView):
     queryset = Show.objects.all().exclude(id=1)
     template_name = 'show_detail.html'
 
 
+# Deprecated
 class TimeSlotDetailView(DetailView):
     queryset = TimeSlot.objects.all()
     template_name = 'timeslot_detail.html'
 
 
+# Deprecated
 class RecommendationsListView(ListView):
     context_object_name = 'recommendation_list'
     template_name = 'recommendation_list.html'
@@ -87,10 +93,12 @@ class RecommendationsListView(ListView):
                                          start__range=(now, end))).order_by('start')[:20]
 
 
+# Deprecated
 class RecommendationsBoxView(RecommendationsListView):
     template_name = 'boxes/recommendation.html'
 
 
+# Deprecated
 class DayScheduleView(TemplateView):
     template_name = 'day_schedule.html'
 
@@ -130,6 +138,7 @@ class DayScheduleView(TemplateView):
         return context
 
 
+# Deprecated
 class CurrentShowBoxView(TemplateView):
     context_object_name = 'recommendation_list'
     template_name = 'boxes/current.html'
@@ -148,6 +157,7 @@ class CurrentShowBoxView(TemplateView):
         return context
 
 
+# Deprecated
 class WeekScheduleView(TemplateView):
     template_name = 'week_schedule.html'
 
@@ -204,6 +214,7 @@ class StylesView(TemplateView):
         return context
 
 
+# Deprecated
 def json_day_schedule(request, year=None, month=None, day=None):
     if year is None and month is None and day is None:
         today = datetime.combine(date.today(), time(0, 0))
@@ -451,6 +462,8 @@ class APIShowViewSet(viewsets.ModelViewSet):
 
         shows = Show.objects.all()
 
+        '''Filters'''
+
         if self.request.GET.get('active') == 'true':
             '''Filter currently running shows'''
 
@@ -581,22 +594,32 @@ class APIScheduleViewSet(viewsets.ModelViewSet):
         return Response(serializer.data)
 
 
-    def create(self, request, show_pk=None, pk=None):
+    def create(self, request, pk=None, show_pk=None):
         """
-        TODO: Create a schedule, generate timeslots, test for collisions and resolve them including notes
+        Create a schedule, generate timeslots, test for collisions and resolve them including notes
+
         Only superusers may add schedules
         """
 
-        # Only allow creating when calling /shows/1/schedules/1
+        # Only allow creating when calling /shows/1/schedules/
         if show_pk == None or not request.user.is_superuser:
             return Response(status=status.HTTP_401_UNAUTHORIZED)
 
-        return Response(status=HTTP_401_UNAUTHORIZED)
+        # First create submit
+        if isinstance(request.data, dict):
+            return Response(Schedule.make_conflicts(request.data, pk, show_pk))
+
+        # While resolving
+        if type(request.data) is list:
+            return Response(Schedule.resolve_conflicts(request.data, pk, show_pk))
+
+        return Response(status=status.HTTP_400_BAD_REQUEST)
 
 
     def update(self, request, pk=None, show_pk=None):
         """
-        TODO: Update a schedule, generate timeslots, test for collisions and resolve them including notes
+        Update a schedule, generate timeslots, test for collisions and resolve them including notes
+
         Only superusers may update schedules
         """
 
@@ -606,21 +629,25 @@ class APIScheduleViewSet(viewsets.ModelViewSet):
 
         schedule = get_object_or_404(Schedule, pk=pk, show=show_pk)
 
-        serializer = ScheduleSerializer(schedule, data=request.data)
-        if serializer.is_valid():
-            serializer.save()
-            return Response(serializer.data, status=status.HTTP_200_OK)
+        # First update submit
+        if isinstance(request.data, dict):
+            return Response(Schedule.make_conflicts(model_to_dict(schedule), pk, show_pk))
 
-        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+        # While resolving
+        if type(request.data) is list:
+            return Response(Schedule.resolve_conflicts(request.data, pk, show_pk))
 
+        return Response(status=status.HTTP_400_BAD_REQUEST)
 
-    def destroy(self, request, pk=None):
+
+    def destroy(self, request, pk=None, show_pk=None):
         """
         Delete a schedule
         Only superusers may delete schedules
         """
 
-        if not request.user.is_superuser:
+        # Only allow deleting when calling /shows/1/schedules/1
+        if show_pk == None or not request.user.is_superuser:
             return Response(status=status.HTTP_401_UNAUTHORIZED)
 
         schedule = get_object_or_404(Schedule, pk=pk)
@@ -651,9 +678,12 @@ class APITimeSlotViewSet(viewsets.ModelViewSet):
 
 
     def get_queryset(self):
+
         show_pk = self.kwargs['show_pk'] if 'show_pk' in self.kwargs else None
         schedule_pk = self.kwargs['schedule_pk'] if 'schedule_pk' in self.kwargs else None
 
+        '''Filters'''
+
         # Return next 60 days by default
         start = datetime.combine(date.today(), time(0, 0))
         end = start + timedelta(days=60)
@@ -662,29 +692,30 @@ class APITimeSlotViewSet(viewsets.ModelViewSet):
             start = datetime.combine( datetime.strptime(self.request.GET.get('start'), '%Y-%m-%d').date(), time(0, 0))
             end = datetime.combine( datetime.strptime(self.request.GET.get('end'), '%Y-%m-%d').date(), time(23, 59))
 
-        # If show is given: Return corresponding timeslots
+        '''Endpoints'''
+
+        #
+        #     /shows/1/schedules/1/timeslots/
+        #
+        #     Returns timeslots of the given show and schedule
+        #
         if show_pk != None and schedule_pk != None:
-            #
-            #     /shows/1/schedules/1/timeslots/
-            #
-            #     Returns timeslots of the given show and schedule
-            #
             return TimeSlot.objects.filter(show=show_pk, schedule=schedule_pk, start__gte=start, end__lte=end).order_by('start')
 
+        #
+        #     /shows/1/timeslots/
+        #
+        #     Returns timeslots of the show
+        #
         elif show_pk != None and schedule_pk == None:
-            #
-            #     /shows/1/timeslots/
-            #
-            #     Returns timeslots of the show
-            #
             return TimeSlot.objects.filter(show=show_pk, start__gte=start, end__lte=end).order_by('start')
 
+        #
+        #     /timeslots/
+        #
+        #     Returns all timeslots
+        #
         else:
-            #
-            #     /timeslots/
-            #
-            #     Returns all timeslots
-            #
             return TimeSlot.objects.filter(start__gte=start, end__lte=end).order_by('start')
 
 
@@ -750,7 +781,8 @@ class APINoteViewSet(viewsets.ModelViewSet):
     /ap1/v1/notes/1                                 Returns a single note (if owned) (GET) - PUT/DELETE not allowed at this level
     /api/v1/notes/?ids=1,2,3,4,5                    Returns given notes (if owned) (GET)
     /api/v1/notes/?host=1                           Returns notes assigned to a given host (GET)
-    /api/v1/notes/?owner=1                          Returns notes created by a given user (GET)
+    /api/v1/notes/?owner=1                          Returns notes editable by a given user (GET)
+    /api/v1/notes/?user=1                           Returns notes created by a given user (GET)
     /api/v1/shows/1/notes                           Returns all notes of a show (GET) - POST not allowed at this level
     /api/v1/shows/1/notes/1                         Returns a note by its ID (GET) - PUT/DELETE not allowed at this level
     /api/v1/shows/1/timeslots/1/note/               Returns a note of the timeslot (GET) - POST not allowed at this level
@@ -774,31 +806,35 @@ class APINoteViewSet(viewsets.ModelViewSet):
         timeslot_pk = self.kwargs['timeslot_pk'] if 'timeslot_pk' in self.kwargs else None
         show_pk = self.kwargs['show_pk'] if 'show_pk' in self.kwargs else None
 
+        '''Endpoints'''
+
+        #
+        #     /shows/1/schedules/1/timeslots/1/note
+        #     /shows/1/timeslots/1/note
+        #
+        #     Return a note to the timeslot
+        #
         if show_pk != None and timeslot_pk != None:
-            #
-            #     /shows/1/schedules/1/timeslots/1/note
-            #     /shows/1/timeslots/1/note
-            #
-            #     Return a note to the timeslot
-            #
             notes = Note.objects.filter(show=show_pk, timeslot=timeslot_pk)
 
+        #
+        #     /shows/1/notes
+        #
+        #     Returns notes to the show
+        #
         elif show_pk != None and timeslot_pk == None:
-            #
-            #     /shows/1/notes
-            #
-            #     Returns notes to the show
-            #
             notes = Note.objects.filter(show=show_pk)
 
+        #
+        #     /notes
+        #
+        #     Returns all notes
+        #
         else:
-            #
-            #     /notes
-            #
-            #     Returns all notes
-            #
             notes = Note.objects.all()
 
+        '''Filters'''
+
         if self.request.GET.get('ids') != None:
             '''Filter notes by their IDs'''
             note_ids = self.request.GET.get('ids').split(',')
@@ -820,14 +856,6 @@ class APINoteViewSet(viewsets.ModelViewSet):
         return notes
 
 
-    '''
-    def list(self, request, pk=None, timeslot_pk=None, schedule_pk=None, show_pk=None):
-        """Lists notes"""
-        notes = self.get_queryset()
-        serializer = NoteSerializer(notes, many=True)
-        return Response(serializer.data)
-    '''
-
     def create(self, request, pk=None, timeslot_pk=None, schedule_pk=None, show_pk=None):
         """Create a note"""
 
@@ -863,29 +891,29 @@ class APINoteViewSet(viewsets.ModelViewSet):
         /shows/1/schedules/1/timeslots/1/note/1
         """
 
+        #
+        #      /shows/1/notes/1
+        #
+        #      Returns a note to a show
+        #
         if show_pk != None and timeslot_pk == None and schedule_pk == None:
-            #
-            #      /shows/1/notes/1
-            #
-            #      Returns a note to a show
-            #
             note = get_object_or_404(Note, pk=pk, show=show_pk)
 
+        #
+        #     /shows/1/timeslots/1/note/1
+        #     /shows/1/schedules/1/timeslots/1/note/1
+        #
+        #     Return a note to a timeslot
+        #
         elif show_pk != None and timeslot_pk != None:
-            #
-            #     /shows/1/timeslots/1/note/1
-            #     /shows/1/schedules/1/timeslots/1/note/1
-            #
-            #     Return a note to a timeslot
-            #
             note = get_object_or_404(Note, pk=pk, show=show_pk, timeslot=timeslot_pk)
 
+        #
+        #     /notes/1
+        #
+        #     Returns the given note
+        #
         else:
-            #
-            #     /notes/1
-            #
-            #     Returns the given note
-            #
             note = get_object_or_404(Note, pk=pk)
 
         serializer = NoteSerializer(note)
diff --git a/pv/settings.py b/pv/settings.py
index ec41cf7c..da471014 100644
--- a/pv/settings.py
+++ b/pv/settings.py
@@ -131,6 +131,14 @@ TINYMCE_DEFAULT_CONFIG = {
 
 CACHE_BACKEND = 'locmem://'
 
+# When generating schedules/timeslots:
+# If until date wasn't set, add x days to start time
+AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE = 365
+
+# If until date wasn't set, auto-set it to the end of the year
+# Overrides the above setting if True
+AUTO_SET_UNTIL_DATE_TO_END_OF_YEAR = True
+
 MUSIKPROG_IDS = (
     1,    # unmodieriertes musikprogramm
 )
diff --git a/pv/urls.py b/pv/urls.py
index 83285c00..a6a8d499 100644
--- a/pv/urls.py
+++ b/pv/urls.py
@@ -12,7 +12,6 @@ admin.autodiscover()
 
 router = routers.DefaultRouter()
 router.register(r'users', APIUserViewSet)
-#router.register(r'owners', APIOwnerViewSet)
 router.register(r'hosts', APIHostViewSet)
 router.register(r'shows', APIShowViewSet)
 router.register(r'schedules', APIScheduleViewSet)
-- 
GitLab