From 4a4a8b3abf30061d0a70008db051c6ea6c04f857 Mon Sep 17 00:00:00 2001
From: Ernesto Rico Schmidt <ernesto@helsinki.at>
Date: Fri, 5 Jul 2024 16:26:04 -0400
Subject: [PATCH] feat: rewrite services for playout, playout serializer, add
 filter & typing

---
 program/filters.py     |  11 +++
 program/serializers.py |  37 ++++++---
 program/services.py    | 167 ++++++++++++++++++++++-------------------
 program/typing.py      |  40 ++++++++++
 program/views.py       |  90 +++++++++++++++-------
 5 files changed, 231 insertions(+), 114 deletions(-)
 create mode 100644 program/typing.py

diff --git a/program/filters.py b/program/filters.py
index dc2c841a..317ff635 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -280,3 +280,14 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
         fields = [
             "is_active",
         ]
+
+
+class PlayoutFilterSet(filters.FilterSet):
+    include_virtual = filters.BooleanFilter(
+        field_name="is_virtual",
+        help_text="Include virtual timeslot entries (default: false).",
+    )
+
+    class Meta:
+        model = models.TimeSlot
+        fields = ["include_virtual"]
diff --git a/program/serializers.py b/program/serializers.py
index 6e566c73..537f01b8 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -52,6 +52,7 @@ from program.models import (
     Type,
     UserProfile,
 )
+from program.typing import NestedEpisode, NestedSchedule, NestedShow
 from program.utils import update_links
 
 SOLUTION_CHOICES = {
@@ -1341,20 +1342,34 @@ class RadioSettingsSerializer(serializers.ModelSerializer):
         }
 
 
-class PlayoutSerializer(serializers.Serializer):
+# done this way to get the schema annotations for datetime right
+class NestedTimeslotSerializer(serializers.Serializer):
     end = serializers.DateTimeField()
-    episode_title = serializers.CharField(allow_blank=True)
+    id = serializers.IntegerField(allow_null=True)
     is_virtual = serializers.BooleanField()
-    memo = serializers.CharField(allow_blank=True, required=False)
-    playlist_id = serializers.IntegerField(allow_null=True, required=False)
-    repetition_of_id = serializers.IntegerField(allow_null=True, required=False)
-    schedule_default_playlist_id = serializers.IntegerField(allow_null=True, required=False)
-    schedule_id = serializers.IntegerField(allow_null=True, required=False)
-    show_default_playlist_id = serializers.IntegerField(allow_null=True, required=False)
-    show_id = serializers.IntegerField()
-    show_name = serializers.CharField()
+    memo = serializers.CharField()
+    playlist_id = serializers.IntegerField(allow_null=True)
+    repetition_of_id = serializers.IntegerField(allow_null=True)
     start = serializers.DateTimeField()
-    timeslot_id = serializers.IntegerField(allow_null=True, required=False)
+
+
+class PlayoutEntrySerializer(serializers.Serializer):
+    episode = serializers.SerializerMethodField()
+    schedule = serializers.SerializerMethodField()
+    show = serializers.SerializerMethodField()
+    timeslot = NestedTimeslotSerializer()
+
+    @staticmethod
+    def get_episode(obj) -> NestedEpisode:
+        pass
+
+    @staticmethod
+    def get_schedule(obj) -> NestedSchedule:
+        pass
+
+    @staticmethod
+    def get_show(obj) -> NestedShow:
+        pass
 
 
 class DayScheduleSerializer(serializers.Serializer):
diff --git a/program/services.py b/program/services.py
index 6ae8790d..67a11456 100644
--- a/program/services.py
+++ b/program/services.py
@@ -20,7 +20,7 @@
 import copy
 from datetime import datetime, time, timedelta
 from itertools import pairwise
-from typing import Literal, TypedDict
+from typing import TypedDict
 
 from dateutil.relativedelta import relativedelta
 from dateutil.rrule import rrule
@@ -41,6 +41,14 @@ from program.models import (
     TimeSlot,
 )
 from program.serializers import ScheduleSerializer, TimeSlotSerializer
+from program.typing import (
+    DayScheduleEntry,
+    NestedEpisode,
+    NestedSchedule,
+    NestedShow,
+    NestedTimeslot,
+    TimerangeEntry,
+)
 from program.utils import parse_date, parse_datetime, parse_time
 
 
@@ -95,39 +103,6 @@ class ScheduleCreateUpdateData(TypedDict):
     solutions: dict[str, str]
 
 
-class DayScheduleEntry(TypedDict):
-    end: str
-    is_virtual: bool
-    show_id: int
-    start: str
-    show_name: str
-
-
-class TimeslotEntry(TypedDict):
-    end: str
-    episode_title: str
-    is_virtual: Literal[False]
-    memo: str
-    playlist_id: int | None
-    repetition_of_id: int | None
-    schedule_default_playlist_id: int | None
-    schedule_id: int
-    show_default_playlist_id: int | None
-    show_id: int
-    show_name: str
-    start: str
-    timeslot_id: int
-
-
-class VirtualTimeslotEntry(TypedDict):
-    end: str
-    episode_title: str
-    is_virtual: Literal[True]
-    show_id: int
-    show_name: str
-    start: str
-
-
 def create_timeslot(start: str, end: str, schedule: Schedule) -> TimeSlot:
     """Creates and returns an unsaved timeslot with the given `start`, `end` and `schedule`."""
 
@@ -782,53 +757,93 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
     return conflicts
 
 
-def make_day_schedule_entry(*, timeslot_entry: TimeslotEntry) -> DayScheduleEntry:
-    """returns a day schedule entry for the given timeslot entry."""
+def make_day_schedule_entry(*, timerange_entry: TimerangeEntry) -> DayScheduleEntry:
+    """returns a day schedule entry for the given timerange entry."""
+
+    episode = timerange_entry["episode"]
+    timeslot = timerange_entry["timeslot"]
+    show = timerange_entry["show"]
 
     return DayScheduleEntry(
-        end=timeslot_entry["end"],
-        show_id=timeslot_entry["show_id"],
-        is_virtual=timeslot_entry["is_virtual"],
-        start=timeslot_entry["start"],
-        show_name=timeslot_entry["show_name"],
+        episode=NestedEpisode(
+            id=episode.get("id"),
+            title=episode["title"],
+        ),
+        timeslot=NestedTimeslot(
+            end=timeslot["end"],
+            id=timeslot.get("id"),
+            is_virtual=bool(timeslot["is_virtual"]),
+            memo=timeslot.get("memo"),
+            playlist_id=timeslot.get("playlist_id"),
+            repetition_of_id=timeslot.get("repetition_of_id"),
+            start=timeslot["start"],
+        ),
+        show=NestedShow(
+            default_playlist_id=show["default_playlist_id"],
+            id=show["id"],
+            name=show["name"],
+        ),
     )
 
 
-def make_timeslot_entry(*, timeslot: TimeSlot) -> TimeslotEntry:
-    """returns a timeslot entry for the given timeslot."""
+def make_timerange_entry(*, timeslot: TimeSlot) -> TimerangeEntry:
+    """returns a timerange entry for the given timeslot."""
 
+    episode = timeslot.note
     schedule = timeslot.schedule
     show = timeslot.schedule.show
 
-    return TimeslotEntry(
-        end=timeslot.end.strftime("%Y-%m-%dT%H:%M:%S"),
-        episode_title=timeslot.note.title,
-        is_virtual=False,
-        memo=timeslot.memo,
-        playlist_id=timeslot.playlist_id,
-        # `timeslot.repetition_of` is a foreign key that can be null
-        repetition_of_id=timeslot.repetition_of.id if timeslot.repetition_of else None,
-        schedule_default_playlist_id=schedule.default_playlist_id,
-        schedule_id=schedule.id,
-        show_default_playlist_id=show.default_playlist_id,
-        show_id=show.id,
-        show_name=show.name,
-        start=timeslot.start.strftime("%Y-%m-%dT%H:%M:%S"),
-        timeslot_id=timeslot.id,
+    return TimerangeEntry(
+        episode=NestedEpisode(
+            id=episode.id,
+            title=episode.title,
+        ),
+        schedule=NestedSchedule(
+            default_playlist_id=schedule.default_playlist_id,
+            id=schedule.id,
+        ),
+        show=NestedShow(
+            default_playlist_id=show.default_playlist_id,
+            id=show.id,
+            name=show.name,
+        ),
+        timeslot=NestedTimeslot(
+            end=timeslot.end.strftime("%Y-%m-%dT%H:%M:%S"),
+            id=timeslot.id,
+            is_virtual=False,
+            memo=timeslot.memo,
+            playlist_id=timeslot.playlist_id,
+            repetition_of_id=timeslot.repetition_of.id if timeslot.repetition_of_id else None,
+            start=timeslot.start.strftime("%Y-%m-%dT%H:%M:%S"),
+        ),
     )
 
 
-def make_virtual_timeslot_entry(*, gap_start: datetime, gap_end: datetime) -> VirtualTimeslotEntry:
-    """returns a virtual timeslot entry to fill the gap in between `gap_start` and `gap_end`."""
+def make_virtual_timerange_entry(*, gap_start: datetime, gap_end: datetime) -> TimerangeEntry:
+    """returns a timerange entry to fill the gap between `gap_start` and `gap_end`."""
 
     if radio_settings := RadioSettings.objects.first():
-        return VirtualTimeslotEntry(
-            end=gap_end.strftime("%Y-%m-%dT%H:%M:%S"),
-            episode_title=radio_settings.fallback_default_pool,
-            is_virtual=True,
-            show_id=radio_settings.fallback_show.id if radio_settings.fallback_show else None,
-            show_name=radio_settings.fallback_show.name if radio_settings.fallback_show else None,
-            start=gap_start.strftime("%Y-%m-%dT%H:%M:%S"),
+        fallback_show = radio_settings.fallback_show
+        return TimerangeEntry(
+            episode=NestedEpisode(
+                id=None,
+                title=radio_settings.pools[radio_settings.fallback_default_pool],
+            ),
+            schedule=None,
+            show=NestedShow(
+                default_playlist_id=fallback_show.default_playlist_id if fallback_show else None,
+                id=fallback_show.id if fallback_show else None,
+                name=fallback_show.name if fallback_show else "",
+            ),
+            timeslot=NestedTimeslot(
+                end=gap_end.strftime("%Y-%m-%dT%H:%M:%S"),
+                id=None,
+                is_virtual=True,
+                memo="",
+                playlist_id=None,
+                repetition_of_id=None,
+                start=gap_start.strftime("%Y-%m-%dT%H:%M:%S"),
+            ),
         )
     else:
         raise NotFound(
@@ -838,10 +853,10 @@ def make_virtual_timeslot_entry(*, gap_start: datetime, gap_end: datetime) -> Vi
 
 def get_timerange_timeslot_entries(
     timerange_start: datetime, timerange_end: datetime, include_virtual: bool = False
-) -> list[TimeslotEntry | VirtualTimeslotEntry]:
-    """Gets list of timeslot entries between the given `timerange_start` and `timerange_end`.
+) -> list[TimerangeEntry]:
+    """Gets list of timerange entries between the given `timerange_start` and `timerange_end`.
 
-    Include virtual timeslots if requested."""
+    Include virtual timerange entries if requested."""
 
     timeslots = TimeSlot.objects.filter(
         # start before `timerange_start` and end after `timerange_start`
@@ -853,7 +868,7 @@ def get_timerange_timeslot_entries(
     ).select_related("schedule")
 
     if not include_virtual:
-        return [make_timeslot_entry(timeslot=timeslot) for timeslot in timeslots]
+        return [make_timerange_entry(timeslot=timeslot) for timeslot in timeslots]
 
     if not timeslots:
         return []
@@ -863,25 +878,25 @@ def get_timerange_timeslot_entries(
     first_timeslot = timeslots.first()
     if first_timeslot.start > timerange_start:
         timeslot_entries.append(
-            make_virtual_timeslot_entry(gap_start=timerange_start, gap_end=first_timeslot.start)
+            make_virtual_timerange_entry(gap_start=timerange_start, gap_end=first_timeslot.start)
         )
 
     for index, (current, upcoming) in enumerate(pairwise(timeslots)):
-        timeslot_entries.append(make_timeslot_entry(timeslot=current))
+        timeslot_entries.append(make_timerange_entry(timeslot=current))
 
         # gap between the timeslots
         if current.end != upcoming.start:
             timeslot_entries.append(
-                make_virtual_timeslot_entry(gap_start=current.end, gap_end=upcoming.start)
+                make_virtual_timerange_entry(gap_start=current.end, gap_end=upcoming.start)
             )
     else:
-        timeslot_entries.append(make_timeslot_entry(timeslot=first_timeslot))
+        timeslot_entries.append(make_timerange_entry(timeslot=first_timeslot))
 
     # gap after the last timeslot
     last_timeslot = timeslots.last()
     if last_timeslot.end < timerange_end:
         timeslot_entries.append(
-            make_virtual_timeslot_entry(gap_start=last_timeslot.end, gap_end=timerange_end)
+            make_virtual_timerange_entry(gap_start=last_timeslot.end, gap_end=timerange_end)
         )
 
     return timeslot_entries
diff --git a/program/typing.py b/program/typing.py
new file mode 100644
index 00000000..32e8412e
--- /dev/null
+++ b/program/typing.py
@@ -0,0 +1,40 @@
+from typing import TypedDict
+
+
+class NestedTimeslot(TypedDict):
+    end: str
+    id: int | None
+    is_virtual: bool
+    memo: str
+    playlist_id: int | None
+    repetition_of_id: int | None
+    start: str
+
+
+class NestedShow(TypedDict):
+    default_playlist_id: int | None
+    id: int
+    name: str
+
+
+class NestedSchedule(TypedDict):
+    id: int | None
+    default_playlist_id: int | None
+
+
+class NestedEpisode(TypedDict):
+    id: int | None
+    title: str
+
+
+class DayScheduleEntry(TypedDict):
+    episode: NestedEpisode
+    timeslot: NestedTimeslot
+    show: NestedShow
+
+
+class TimerangeEntry(TypedDict):
+    episode: NestedEpisode
+    schedule: NestedSchedule | None
+    show: NestedShow
+    timeslot: NestedTimeslot
diff --git a/program/views.py b/program/views.py
index df98edd3..fabee36a 100644
--- a/program/views.py
+++ b/program/views.py
@@ -77,7 +77,7 @@ from program.serializers import (
     LinkTypeSerializer,
     MusicFocusSerializer,
     NoteSerializer,
-    PlayoutSerializer,
+    PlayoutEntrySerializer,
     RadioSettingsSerializer,
     RRuleSerializer,
     ScheduleConflictResponseSerializer,
@@ -109,6 +109,42 @@ logger = logging.getLogger(__name__)
             "Expects parameters `year` (int), `month` (int), and `day` (int) as url components."
             "e.g. /program/2024/01/31/"
         ),
+        examples=[
+            OpenApiExample(
+                "Full entry",
+                response_only=True,
+                value={
+                    "episode": {"id": 2, "title": ""},
+                    "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"},
+                    "timeslot": {
+                        "end": "2024-07-03T16:30:00",
+                        "id": 2,
+                        "isVirtual": False,
+                        "memo": "",
+                        "playlistId": None,
+                        "repetitionOfId": None,
+                        "start": "2024-07-03T14:00:00",
+                    },
+                },
+            ),
+            OpenApiExample(
+                "Virtual entry",
+                response_only=True,
+                value={
+                    "episode": {"id": None, "title": "Station Fallback Pool"},
+                    "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"},
+                    "timeslot": {
+                        "end": "2024-07-04T00:00:00",
+                        "id": None,
+                        "isVirtual": True,
+                        "memo": "",
+                        "playlistId": None,
+                        "repetitionOfId": None,
+                        "start": "2024-07-03T22:00:00",
+                    },
+                },
+            ),
+        ],
     ),
 )
 class APIDayScheduleViewSet(
@@ -130,7 +166,7 @@ class APIDayScheduleViewSet(
         include_virtual = request.GET.get("include_virtual") == "true"
 
         schedule = [
-            make_day_schedule_entry(timeslot_entry=timeslot_entry)
+            make_day_schedule_entry(timerange_entry=timeslot_entry)
             for timeslot_entry in get_timerange_timeslot_entries(start, end, include_virtual)
         ]
 
@@ -147,37 +183,36 @@ class APIDayScheduleViewSet(
         # TODO: move this into the serializers
         examples=[
             OpenApiExample(
-                "Regular timeslot example",
-                summary="Regular timeslot",
-                description="Example values for a regular timeslot",
+                "Full entry",
                 response_only=True,
                 value={
-                    "end": "2024-06-19T20:30:00",
-                    "episodeTitle": "First live show!",
-                    "isVirtual": False,
-                    "memo": "Things that I should not forget",
-                    "playlistId": None,
-                    "repetitionOfId": None,
-                    "scheduleId": 9,
-                    "showDefaultPlaylistId": None,
-                    "showId": 3,
-                    "showName": "The Live Show",
-                    "start": "2024-06-19T19:15:00",
-                    "timeslotId": 2,
+                    "episode": {"id": 2, "title": ""},
+                    "schedule": {"defaultPlaylistId": None, "id": 1},
+                    "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"},
+                    "timeslot": {
+                        "end": "2024-07-03T16:30:00",
+                        "id": 2,
+                        "isVirtual": False,
+                        "memo": "",
+                        "playlistId": None,
+                        "repetitionOfId": None,
+                        "start": "2024-07-03T14:00:00",
+                    },
                 },
             ),
             OpenApiExample(
-                "Virtual timeslot example",
-                summary="Virtual timeslot",
-                description="Example values for a virtual timeslot",
+                "Virtual entry",
                 response_only=True,
                 value={
-                    "end": "2024-06-19T20:30:00",
-                    "episodeTitle": "Music from the archive",
-                    "isVirtual": True,
-                    "showId": 1,
-                    "showName": "Music pool",
-                    "start": "2024-06-19T15:00:00",
+                    "episode": {"id": None, "title": "Station Fallback Pool"},
+                    "schedule": None,
+                    "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"},
+                    "timeslot": {
+                        "end": "2024-07-10T00:00:00",
+                        "id": None,
+                        "isVirtual": True,
+                        "start": "2024-07-09T22:00:00",
+                    },
                 },
             ),
         ],
@@ -187,8 +222,9 @@ class APIPlayoutViewSet(
     mixins.ListModelMixin,
     viewsets.GenericViewSet,
 ):
+    filterset_class = filters.PlayoutFilterSet
     queryset = TimeSlot.objects.all()
-    serializer_class = PlayoutSerializer
+    serializer_class = PlayoutEntrySerializer
 
     def list(self, request, *args, **kwargs):
         """
-- 
GitLab