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