From 4292ae43efc75e38b5fa18a9d4957eff2631e018 Mon Sep 17 00:00:00 2001 From: Ernesto Rico Schmidt <ernesto@helsinki.at> Date: Tue, 4 Apr 2023 15:39:09 -0400 Subject: [PATCH] Extract resolve_conflicts as function --- program/models.py | 288 +---------------------------------------- program/services.py | 309 ++++++++++++++++++++++++++++++++++++++++++++ program/views.py | 5 +- 3 files changed, 313 insertions(+), 289 deletions(-) create mode 100644 program/services.py diff --git a/program/models.py b/program/models.py index a0ba4630..dde3b044 100644 --- a/program/models.py +++ b/program/models.py @@ -18,7 +18,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule @@ -750,292 +750,6 @@ class Schedule(models.Model): return conflicts - # FIXME: this does not belong here - @staticmethod - def resolve_conflicts(data, schedule_pk, show_pk): - """ - Resolves conflicts - Expects JSON POST/PUT data from /shows/1/schedules/ - - Returns a list of dicts if errors were found - Returns an empty list if resolution was successful - """ - - sdl = data["schedule"] - solutions = data.get("solutions", []) - - # Regenerate conflicts - schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) - show = schedule.show - conflicts = Schedule.make_conflicts(sdl, schedule_pk, show_pk) - - if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date: - raise ValidationError( - _("Start and end dates must not be the same."), - code="no-same-day-start-and-end", - ) - - if schedule.last_date < schedule.first_date: - raise ValidationError( - _("End date mustn't be before start."), - code="no-start-after-end", - ) - - num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0]) - - if len(solutions) != num_conflicts: - raise ScheduleConflictError( - _("Numbers of conflicts and solutions don't match."), - code="one-solution-per-conflict", - conflicts=conflicts, - ) - - # Projected timeslots to create - create = [] - - # Existing timeslots to update - update = [] - - # Existing timeslots to delete - delete = [] - - # Error messages - errors = {} - - for ts in conflicts["projected"]: - # If no solution necessary - # - # - Create the projected timeslot and skip - # - if "solution_choices" not 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 by_weekday 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 solutions[ts["hash"]] == "": - errors[ts["hash"]] = _("No solution given.") - continue - - # If resolution is not accepted for this conflict - # - # - Skip - # - if not solutions[ts["hash"]] in ts["solution_choices"]: - errors[ts["hash"]] = _("Given solution is not accepted for this conflict.") - continue - - """Conflict resolution""" - - existing = ts["collisions"][0] - solution = solutions[ts["hash"]] - - # theirs - # - # - Discard the projected timeslot - # - Keep the existing collision(s) - # - if solution == "theirs": - continue - - # ours - # - # - Create the projected timeslot - # - Delete the existing collision(s) - # - if solution == "ours": - projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) - create.append(projected_ts) - - # Delete collision(s) - for ex in ts["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 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 projected end - # - 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 = parse_datetime(ts["end"]) - update.append(existing_ts) - - # theirs-start - # - # - Keep existing - # - Create projected with start time of existing end - # - if solution == "theirs-start": - projected_ts = TimeSlot.objects.instantiate( - existing["end"], ts["end"], schedule, show - ) - create.append(projected_ts) - - # ours-start - # - # - Create the projected timeslot - # - Change end of existing to projected start - # - 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 = parse_datetime(ts["start"]) - update.append(existing_ts) - - # theirs-both - # - # - Keep existing - # - Create two projected timeslots with end of existing start and start of existing - # end - # - 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"], ts["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 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 = parse_datetime(ts["start"]) - update.append(existing_ts) - - 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 return already chosen solutions - if len(errors) > 0: - conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk) - - partly_resolved = conflicts["projected"] - saved_solutions = {} - - # Add already chosen resolutions and error message to conflict - 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["hash"] in errors: - partly_resolved[index]["error"] = errors[c["hash"]] - - # Re-insert post data - conflicts["projected"] = partly_resolved - conflicts["solutions"] = saved_solutions - conflicts["notes"] = data.get("notes") - conflicts["playlists"] = data.get("playlists") - - raise ScheduleConflictError( - _("Not all conflicts have been resolved."), - code="unresolved-conflicts", - conflicts=conflicts, - ) - - # Collect upcoming timeslots to delete which might still remain - del_timeslots = TimeSlot.objects.filter( - schedule=schedule, - start__gt=timezone.make_aware(datetime.combine(schedule.last_date, time(0, 0))), - ) - for del_ts in del_timeslots: - delete.append(del_ts) - - # If 'dryrun' is true, just return the projected changes instead of executing them - if "dryrun" in sdl and sdl["dryrun"]: - return { - "create": [model_to_dict(ts) for ts in create], - "update": [model_to_dict(ts) for ts in update], - "delete": [model_to_dict(ts) for ts in delete], - } - - """Database changes if no errors found""" - - # Only save schedule if timeslots were created - if create: - # Create or update schedule - schedule.save() - - # Update timeslots - for ts in update: - ts.save(update_fields=["start", "end"]) - - # Create timeslots - for ts in create: - 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() - - # 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"]) - - timeslot = TimeSlot.objects.get(pk=ts.id) - timeslot.note_id = note.id - timeslot.save(update_fields=["note_id"]) - except ObjectDoesNotExist: - pass - - # Delete manually resolved timeslots and those after until - for dl in delete: - dl.delete() - - return model_to_dict(schedule) - class TimeSlotManager(models.Manager): @staticmethod diff --git a/program/services.py b/program/services.py new file mode 100644 index 00000000..e7ead3cb --- /dev/null +++ b/program/services.py @@ -0,0 +1,309 @@ +# +# steering, Programme/schedule management for AURA +# +# Copyright (C) 2017, Ingo Leindecker +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from datetime import datetime, time + +from rest_framework.exceptions import ValidationError + +from django.core.exceptions import ObjectDoesNotExist +from django.forms.models import model_to_dict +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from program.models import Note, Schedule, ScheduleConflictError, TimeSlot +from program.utils import parse_datetime + + +# TODO: add type annotations +def resolve_conflicts(data, schedule_pk, show_pk): + """ + Resolves conflicts + Expects JSON POST/PUT data from /shows/1/schedules/ + + Returns a list of dicts if errors were found + Returns an empty list if resolution was successful + """ + + sdl = data["schedule"] + solutions = data.get("solutions", []) + + # Regenerate conflicts + schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) + show = schedule.show + conflicts = Schedule.make_conflicts(sdl, schedule_pk, show_pk) + + if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date: + raise ValidationError( + _("Start and end dates must not be the same."), + code="no-same-day-start-and-end", + ) + + if schedule.last_date < schedule.first_date: + raise ValidationError( + _("End date mustn't be before start."), + code="no-start-after-end", + ) + + num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0]) + + if len(solutions) != num_conflicts: + raise ScheduleConflictError( + _("Numbers of conflicts and solutions don't match."), + code="one-solution-per-conflict", + conflicts=conflicts, + ) + + # Projected timeslots to create + create = [] + + # Existing timeslots to update + update = [] + + # Existing timeslots to delete + delete = [] + + # Error messages + errors = {} + + for ts in conflicts["projected"]: + # If no solution necessary + # + # - Create the projected timeslot and skip + # + if "solution_choices" not 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 by_weekday 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 solutions[ts["hash"]] == "": + errors[ts["hash"]] = _("No solution given.") + continue + + # If resolution is not accepted for this conflict + # + # - Skip + # + if not solutions[ts["hash"]] in ts["solution_choices"]: + errors[ts["hash"]] = _("Given solution is not accepted for this conflict.") + continue + + """Conflict resolution""" + + existing = ts["collisions"][0] + solution = solutions[ts["hash"]] + + # theirs + # + # - Discard the projected timeslot + # - Keep the existing collision(s) + # + if solution == "theirs": + continue + + # ours + # + # - Create the projected timeslot + # - Delete the existing collision(s) + # + if solution == "ours": + projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show) + create.append(projected_ts) + + # Delete collision(s) + for ex in ts["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 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 projected end + # + 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 = parse_datetime(ts["end"]) + update.append(existing_ts) + + # theirs-start + # + # - Keep existing + # - Create projected with start time of existing end + # + if solution == "theirs-start": + projected_ts = TimeSlot.objects.instantiate(existing["end"], ts["end"], schedule, show) + create.append(projected_ts) + + # ours-start + # + # - Create the projected timeslot + # - Change end of existing to projected start + # + 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 = parse_datetime(ts["start"]) + update.append(existing_ts) + + # theirs-both + # + # - Keep existing + # - Create two projected timeslots with end of existing start and start of existing + # end + # + 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"], ts["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 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 = parse_datetime(ts["start"]) + update.append(existing_ts) + + 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 return already chosen solutions + if len(errors) > 0: + conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk) + + partly_resolved = conflicts["projected"] + saved_solutions = {} + + # Add already chosen resolutions and error message to conflict + 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["hash"] in errors: + partly_resolved[index]["error"] = errors[c["hash"]] + + # Re-insert post data + conflicts["projected"] = partly_resolved + conflicts["solutions"] = saved_solutions + conflicts["notes"] = data.get("notes") + conflicts["playlists"] = data.get("playlists") + + raise ScheduleConflictError( + _("Not all conflicts have been resolved."), + code="unresolved-conflicts", + conflicts=conflicts, + ) + + # Collect upcoming timeslots to delete which might still remain + del_timeslots = TimeSlot.objects.filter( + schedule=schedule, + start__gt=timezone.make_aware(datetime.combine(schedule.last_date, time(0, 0))), + ) + for del_ts in del_timeslots: + delete.append(del_ts) + + # If 'dryrun' is true, just return the projected changes instead of executing them + if "dryrun" in sdl and sdl["dryrun"]: + return { + "create": [model_to_dict(ts) for ts in create], + "update": [model_to_dict(ts) for ts in update], + "delete": [model_to_dict(ts) for ts in delete], + } + + """Database changes if no errors found""" + + # Only save schedule if timeslots were created + if create: + # Create or update schedule + schedule.save() + + # Update timeslots + for ts in update: + ts.save(update_fields=["start", "end"]) + + # Create timeslots + for ts in create: + 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() + + # 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"]) + + timeslot = TimeSlot.objects.get(pk=ts.id) + timeslot.note_id = note.id + timeslot.save(update_fields=["note_id"]) + except ObjectDoesNotExist: + pass + + # Delete manually resolved timeslots and those after until + for dl in delete: + dl.delete() + + return model_to_dict(schedule) diff --git a/program/views.py b/program/views.py index 1bee42fb..7e07607e 100644 --- a/program/views.py +++ b/program/views.py @@ -77,6 +77,7 @@ from program.serializers import ( TypeSerializer, UserSerializer, ) +from program.services import resolve_conflicts from program.utils import ( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, @@ -615,7 +616,7 @@ class APIScheduleViewSet( return Response(status=status.HTTP_400_BAD_REQUEST) try: - resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + resolution = resolve_conflicts(request.data, pk, show_pk) except ScheduleConflictError as exc: return Response(exc.conflicts, status.HTTP_409_CONFLICT) @@ -658,7 +659,7 @@ class APIScheduleViewSet( return Response(serializer.data) try: - resolution = Schedule.resolve_conflicts(request.data, schedule.pk, schedule.show.pk) + resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk) except ScheduleConflictError as exc: return Response(exc.conflicts, status.HTTP_409_CONFLICT) -- GitLab