From a4becabe3044ec8b67e0f02bf2b1638bb908e2c4 Mon Sep 17 00:00:00 2001 From: ingo <ingo.leindecker@fro.at> Date: Wed, 17 Jan 2018 17:07:24 +0100 Subject: [PATCH] Restructured conflict resolution * Added dryrun * Added reassignment of notes and playlists See #8 --- program/models.py | 341 ++++++++++++++++++++++++---------------------- program/views.py | 47 +++++-- 2 files changed, 211 insertions(+), 177 deletions(-) diff --git a/program/models.py b/program/models.py index fa980520..670eb5ae 100644 --- a/program/models.py +++ b/program/models.py @@ -418,7 +418,6 @@ class Schedule(models.Model): tend = self.tend.strftime('%H:%M') dstart = self.dstart.strftime('%d. %b %Y') tstart = self.tstart.strftime('%H:%M') - #is_repetition = self.is_repetition if self.rrule.freq == 0: return '%s %s, %s - %s' % (self.rrule, dstart, tstart, tend) @@ -428,7 +427,7 @@ class Schedule(models.Model): 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""" + """Returns an upcoming schedule instance for conflict resolution""" pk = int(show_pk) if pk != None else None rrule = RRule.objects.get(pk=int(sdl['rrule'])) @@ -460,11 +459,8 @@ class Schedule(models.Model): 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, + dstart=dstart, tstart=tstart, tend=tend, until=until, is_repetition=is_repetition, fallback_id=fallback_id, show=show) @@ -588,7 +584,9 @@ class Schedule(models.Model): Returns a list of conflicts containing dicts of projected timeslots, collisions and solutions """ - conflicts = [] + conflicts = {} + projected = [] + solutions = {} # Cycle each timeslot for ts in timeslots: @@ -596,14 +594,11 @@ class Schedule(models.Model): # Contains one conflict: a projected timeslot, collisions and solutions conflict = {} - # The projected timeslot - projected = {} - # Contains collisions collisions = [] - # Contains solutions - solutions = set() + # Contains possible solutions + solution_choices = set() # Get collisions for each timeslot collision_list = list(TimeSlot.objects.filter( @@ -613,7 +608,11 @@ class Schedule(models.Model): ( Q(start__lte=ts.start) & Q(end__gte=ts.end) ) ).order_by('start')) - + # Add the projected timeslot + projected_entry = {} + projected_entry['hash'] = ts.hash + projected_entry['start'] = str(ts.start) + projected_entry['end'] = str(ts.end) for c in collision_list: @@ -624,8 +623,10 @@ class Schedule(models.Model): 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 + collision['show_name'] = c.show.name + collision['is_repetition'] = c.is_repetition + collision['schedule'] = c.schedule_id + collision['memo'] = c.memo # Get note try: @@ -641,82 +642,80 @@ class Schedule(models.Model): 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') + solution_choices.add('theirs') + solution_choices.add('ours') else: # These two are always possible: Either keep theirs and remove ours or vice versa - solutions.add('theirs') - solutions.add('ours') + solution_choices.add('theirs') + solution_choices.add('ours') # Partly overlapping: projected starts earlier than existing and ends earlier # - # ex. pr. - # +--+ - # | | - # +--+ | | - # | | +--+ - # | | - # +--+ + # 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') + solution_choices.add('theirs-end') + solution_choices.add('ours-end') - # Partly overlapping: projected start later than existing and ends later + # Partly overlapping: projected starts later than existing and ends later + # + # ex. pr. + # +--+ + # | | + # | | +--+ + # +--+ | | + # | | + # +--+ # - # 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') + solution_choices.add('theirs-start') + solution_choices.add('ours-start') # Fully overlapping: projected starts earlier and ends later than existing # - # ex. pr. - # +--+ - # +--+ | | - # | | | | - # +--+ | | - # +--+ + # ex. pr. + # +--+ + # +--+ | | + # | | | | + # +--+ | | + # +--+ + # if ts.start < c.start and ts.end > c.end: - solutions.add('theirs-end') - solutions.add('theirs-start') - solutions.add('theirs-both') + solution_choices.add('theirs-end') + solution_choices.add('theirs-start') + solution_choices.add('theirs-both') # Fully overlapping: projected starts later and ends earlier than existing # - # ex. pr. - # +--+ - # | | +--+ - # | | | | - # | | +--+ - # +--+ + # ex. pr. + # +--+ + # | | +--+ + # | | | | + # | | +--+ + # +--+ + # if ts.start > c.start and ts.end < c.end: - solutions.add('ours-end') - solutions.add('ours-start') - solutions.add('ours-both') + solution_choices.add('ours-end') + solution_choices.add('ours-start') + solution_choices.add('ours-both') + if len(collisions) > 0: + solutions[ts.hash] = '' - # 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) + projected_entry['collisions'] = collisions + projected_entry['solution_choices'] = solution_choices + projected.append(projected_entry) + + conflicts['projected'] = projected + conflicts['solutions'] = solutions + conflicts['notes'] = {} + conflicts['playlists'] = {} return conflicts @@ -735,39 +734,40 @@ class Schedule(models.Model): # Generate timeslots timeslots = Schedule.generate_timeslots(schedule) - # Generate conflicts + # Generate conflicts and add schedule conflicts = Schedule.generate_conflicts(timeslots) - - # Prepend schedule data - conflicts.insert(0, model_to_dict(schedule)) + conflicts['schedule'] = model_to_dict(schedule) return conflicts - def resolve_conflicts(resolved, schedule_pk, show_pk): + def resolve_conflicts(data, schedule_pk, show_pk): """ Resolves conflicts - Expects JSON POST data from /shows/1/schedules/ + Expects JSON POST/PUT data from /shows/1/schedules/ - Returns an array of objects if errors were found - Returns nothing if resolution was successful + Returns a list of dicts if errors were found + Returns an empty list if resolution was successful """ - # Get schedule data - sdl = resolved.pop(0) + sdl = data['schedule'] + solutions = data['solutions'] + # Regenerate conflicts 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")} + return {'detail': _("Start and until dates mustn't be the same")} + + if schedule.until < schedule.dstart: + return {'detail': _("Until date mustn't before start")} + + num_conflicts = len([pr for pr in conflicts['projected'] if len(x['collisions']) > 0]) - if len(resolved) != len(conflicts): - return {'detail': _("Numbers of conflicts and resolutions don't match.")} + if len(solutions) != num_conflicts: + return {'detail': _("Numbers of conflicts and solutions don't match.")} # Projected timeslots to create create = [] @@ -779,59 +779,56 @@ class Schedule(models.Model): delete = [] # Error messages - errors = [] + 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 + for ts in conflicts['projected']: # Ignore past dates - if datetime.strptime(ts['projected']['start'], "%Y-%m-%d %H:%M:%S") <= datetime.today(): + if datetime.strptime(ts['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) + if not 'solution_choices' in ts or len(ts['collisions']) < 1: + projected_ts = TimeSlot.objects.instantiate(ts['start'], ts['end'], schedule, show) + create.append(projected_ts) + continue + + # Check hash (if start, end, rrule or byweekday changed) + if not ts['hash'] in solutions: + errors[ts['hash']] = _("This change on the timeslot is not allowed.") continue # If no resolution given # # - Skip # - if not 'resolution' in resolved[index] or resolved[index]['resolution'] == '': - errors.append({ts['projected']['hash']: _("No resolution given.")}) + if solutions[ts['hash']] == '': + errors[ts['hash']] = _("No solution 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.")}) + if not solutions[ts['hash']] in ts['solution_choices']: + errors[ts['hash']] = _("Given solution 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'] + existing = ts['collisions'][0] + solution = solutions[ts['hash']] # theirs # # - Discard the projected timeslot # - Keep the existing collision(s) # - if resolution == 'theirs': + if solution == 'theirs': continue @@ -840,12 +837,12 @@ class Schedule(models.Model): # - Create the projected timeslot # - Delete the existing collision(s) # - if resolution == 'ours': - projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show) + if solution == 'ours': + projected_ts = TimeSlot.objects.instantiate(ts['start'], ts['end'], schedule, show) create.append(projected_ts) # Delete collision(s) - for ex in resolved[index]['collisions']: + for ex in ts['collisions']: try: existing_ts = TimeSlot.objects.get(pk=ex['id']) delete.append(existing_ts) @@ -858,22 +855,22 @@ class Schedule(models.Model): # - 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) + if solution == 'theirs-end': + projected_ts = TimeSlot.objects.instantiate(ts['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 + # - Change the start of the existing collision to projected end # - if resolution == 'ours-end': - projected_ts = TimeSlot.objects.instantiate(projected['start'], projected['end'], schedule, show) + if solution == 'ours-end': + projected_ts = TimeSlot.objects.instantiate(ts['start'], ts['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') + existing_ts.start = datetime.strptime(ts['end'], '%Y-%m-%d %H:%M:%S') update.append(existing_ts) @@ -882,8 +879,8 @@ class Schedule(models.Model): # - 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) + if solution == 'theirs-start': + projected_ts = TimeSlot.objects.instantiate(existing['end'], ts['end'], schedule, show) create.append(projected_ts) @@ -892,12 +889,12 @@ class Schedule(models.Model): # - 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) + if solution == 'ours-start': + projected_ts = TimeSlot.objects.instantiate(ts['start'], ts['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') + existing_ts.end = datetime.strptime(ts['start'], '%Y-%m-%d %H:%M:%S') update.append(existing_ts) @@ -906,11 +903,11 @@ class Schedule(models.Model): # - 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) + if solution == 'theirs-both': + projected_ts = TimeSlot.objects.instantiate(ts['start'], existing['start'], schedule, show) create.append(projected_ts) - projected_ts = TimeSlot.objects.instantiate(existing['end'], projected['end'], schedule, show) + projected_ts = TimeSlot.objects.instantiate(existing['end'], ts['end'], schedule, show) create.append(projected_ts) @@ -921,78 +918,95 @@ class Schedule(models.Model): # - 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) + if solution == 'ours-both': + projected_ts = TimeSlot.objects.instantiate(ts['start'], ts['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') + existing_ts.end = datetime.strptime(ts['start'], '%Y-%m-%d %H:%M:%S') update.append(existing_ts) - projected_ts = TimeSlot.objects.instantiate(projected['end'], existing['end'], schedule, show) + projected_ts = TimeSlot.objects.instantiate(ts['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: + # but add error messages and return already chosen solutions + if len(errors) > 0: conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk) - sdl = conflicts.pop(0) - partly_resolved = conflicts + partly_resolved = conflicts['projected'] + saved_solutions = {} # 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'] + for index, c in enumerate(conflicts['projected']): + + # The element should only exist if there was a collision + if len(c['collisions']) > 0: + saved_solutions[c['hash']] = '' + + if c['hash'] in solutions and solutions[c['hash']] in c['solution_choices']: + saved_solutions[c['hash']] = solutions[c['hash']] - if c['projected']['hash'] in errors[0]: - partly_resolved[index]['error'] = errors[0][c['projected']['hash']] + if c['hash'] in errors: + partly_resolved[index]['error'] = errors[c['hash']] - # Re-insert schedule data - partly_resolved.insert(0, sdl) + # Re-insert post data + conflicts['projected'] = partly_resolved + conflicts['solutions'] = saved_solutions + conflicts['notes'] = data['notes'] + conflicts['playlists'] = data['playlists'] - return partly_resolved + return conflicts - # If there were no errors, execute all database changes + # If 'dryrun' is true, just return the projected changes instead of executing them + if 'dryrun' in sdl and sdl['dryrun']: + output = {} + output['create'] = [model_to_dict(ts) for ts in create] + output['update'] = [model_to_dict(ts) for ts in update] + output['delete'] = [model_to_dict(ts) for ts in delete] + return output + + + '''Database changes if no errors found''' # 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: + # Delete upcoming timeslots which still remain 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 + + # Reassign playlists + if 'playlists' in data and ts.hash in data['playlists']: + ts.playlist_id = int(data['playlists'][ts.hash]) + ts.save() - ###### - # TODO: Relink notes while the original timeslots are not yet deleted - # TODO: Add the ability to relink playlist_ids as well! - ###### + # Reassign notes + if 'notes' in data and ts.hash in data['notes']: + try: + note = Note.objects.get(pk=int(data['notes'][ts.hash])) + note.timeslot_id = ts.id + note.save(update_fields=["timeslot_id"]) + except ObjectDoesNotExist: + pass # Delete manually resolved timeslots for dl in delete: - print("deleting " + str(dl)) dl.delete() - return [] + return model_to_dict(schedule) def save(self, *args, **kwargs): @@ -1005,7 +1019,7 @@ class TimeSlotManager(models.Manager): 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) + show=show, is_repetition=schedule.is_repetition, schedule=schedule).generate() @staticmethod def get_or_create_current(): @@ -1097,7 +1111,7 @@ class TimeSlot(models.Model): def save(self, *args, **kwargs): self.show = self.schedule.show super(TimeSlot, self).save(*args, **kwargs) - return self; + return self def generate(self, **kwargs): """Returns the object instance without saving""" @@ -1105,9 +1119,10 @@ class TimeSlot(models.Model): 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; + # Makes sure none of these fields changed when updating + string = str(self.start) + str(self.end) + str(self.schedule.rrule.id) + str(self.schedule.byweekday) + self.hash = str(''.join(s for s in string if s.isdigit())) + return self def get_absolute_url(self): return reverse('timeslot-detail', args=[str(self.id)]) diff --git a/program/views.py b/program/views.py index 9dfc3c39..b1e61b6d 100644 --- a/program/views.py +++ b/program/views.py @@ -599,21 +599,32 @@ class APIScheduleViewSet(viewsets.ModelViewSet): Create a schedule, generate timeslots, test for collisions and resolve them including notes Only superusers may add schedules + TODO: if nothing changed except for is_repetition, fallback_id or automation_id + TODO: Prolonging a schedule properly withouth matching against itself + + Perhaps directly insert into database if no conflicts found """ # 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) - # First create submit - if isinstance(request.data, dict): - return Response(Schedule.make_conflicts(request.data, pk, show_pk)) + # The schedule dict is mandatory + if not 'schedule' in request.data: + return Response(status=status.HTTP_400_BAD_REQUEST) - # While resolving - if type(request.data) is list: - return Response(Schedule.resolve_conflicts(request.data, pk, show_pk)) + # First create submit -> return projected timeslots and collisions + if not 'solutions' in request.data: + return Response(Schedule.make_conflicts(request.data['schedule'], pk, show_pk)) - return Response(status=status.HTTP_400_BAD_REQUEST) + # Otherwise try to resolve + resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + + # If resolution went well + if not 'projected' in resolution: + return Response(resolution, status=status.HTTP_201_CREATED) + + # Otherwise return conflicts + return Response(resolution) def update(self, request, pk=None, show_pk=None): @@ -629,15 +640,23 @@ class APIScheduleViewSet(viewsets.ModelViewSet): schedule = get_object_or_404(Schedule, pk=pk, show=show_pk) - # First update submit - if isinstance(request.data, dict): - return Response(Schedule.make_conflicts(model_to_dict(schedule), pk, show_pk)) + # The schedule dict is mandatory + if not 'schedule' in request.data: + return Response(status=status.HTTP_400_BAD_REQUEST) - # While resolving - if type(request.data) is list: - return Response(Schedule.resolve_conflicts(request.data, pk, show_pk)) + # First update submit -> return projected timeslots and collisions + if not 'solutions' in request.data: + return Response(Schedule.make_conflicts(request.data['schedule'], pk, show_pk)) - return Response(status=status.HTTP_400_BAD_REQUEST) + # Otherwise try to resolve + resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + + # If resolution went well + if not 'projected' in resolution: + return Response(resolution, status=status.HTTP_200_OK) + + # Otherwise return conflicts + return Response(resolution) def destroy(self, request, pk=None, show_pk=None): -- GitLab