From 209082808f428e823e774641d627113f86fb7197 Mon Sep 17 00:00:00 2001
From: Ernesto Rico Schmidt <ernesto@helsinki.at>
Date: Tue, 21 Feb 2023 18:57:26 -0400
Subject: [PATCH] Generate virtual timeslots for unscheduled areas

- add `includeVirtual` as GET parameter for `json_playout()`
- refactor `json_playout` to be cleaner

Closes: #120
---
 program/views.py | 139 ++++++++++++++++++++++++++++-------------------
 1 file changed, 83 insertions(+), 56 deletions(-)

diff --git a/program/views.py b/program/views.py
index 90630e82..5c65f97f 100644
--- a/program/views.py
+++ b/program/views.py
@@ -21,6 +21,7 @@
 import json
 import logging
 from datetime import date, datetime, time, timedelta
+from itertools import pairwise
 from textwrap import dedent
 
 from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
@@ -77,6 +78,54 @@ from program.utils import (
 logger = logging.getLogger(__name__)
 
 
+def timeslot_entry(*, timeslot: TimeSlot) -> dict:
+    """return a timeslot entry as a dict"""
+
+    show = timeslot.show
+    schedule = timeslot.schedule
+    playlist_id = timeslot.playlist_id
+
+    title = show_name = f"{show.name} {_('REP')}" if schedule.is_repetition else show.name
+
+    return {
+        "id": timeslot.id,
+        "start": timeslot.start.strftime("%Y-%m-%dT%H:%M:%S"),
+        "end": timeslot.end.strftime("%Y-%m-%dT%H:%M:%S"),
+        "title": title,
+        "schedule_id": schedule.id,
+        "is_repetition": timeslot.is_repetition,
+        "playlist_id": playlist_id,
+        "schedule_default_playlist_id": schedule.default_playlist_id,
+        "show_default_playlist_id": show.default_playlist_id,
+        "show_id": show.id,
+        "show_name": show_name,
+        "show_hosts": ", ".join(show.hosts.values_list("name", flat=True)),
+        # `Show.type` is a foreign key that can be null
+        "show_type": show.type.name if show.type_id else "",
+        "show_categories": ", ".join(show.category.values_list("name", flat=True)),
+        "show_topics": ", ".join(show.topic.values_list("name", flat=True)),
+        # TODO: replace `show_musicfocus` with `show_music_focus` when engine is updated
+        "show_musicfocus": ", ".join(show.music_focus.values_list("name", flat=True)),
+        "show_languages": ", ".join(show.language.values_list("name", flat=True)),
+        # TODO: replace `show_fundingcategory` with `show_funding_category` when engine is
+        #  updated
+        # `Show.funding_category` is a foreign key can be null
+        "show_fundingcategory": show.funding_category.name if show.funding_category_id else "",
+        "memo": timeslot.memo,
+        "className": "danger" if playlist_id is None or playlist_id == 0 else "default",
+    }
+
+
+def gap_entry(*, gap_start: datetime, gap_end: datetime) -> dict:
+    """return a virtual timeslot to fill the gap in between `gap_start` and `gap_end` as a dict"""
+
+    return {
+        "start": gap_start.strftime("%Y-%m-%dT%H:%M:%S"),
+        "end": gap_end.strftime("%Y-%m-%dT%H:%M:%S"),
+        "virtual": True,
+    }
+
+
 def json_day_schedule(request, year=None, month=None, day=None):
     # datetime.combine returns a timezone naive datetime object
     if year is None and month is None and day is None:
@@ -111,87 +160,65 @@ def json_day_schedule(request, year=None, month=None, day=None):
 
 def json_playout(request):
     """
-    Called by
-       - engine (playout) to retrieve timeslots within a given timerange
-         Expects GET variables 'start' (date) and 'end' (date).
-         If start not given, it will be today
+    Return a JSON representation of the scheduled playout.
+
+    Expects GET parameters `start` (date), `end` (date), and `includeVirtual` (boolean).
+
+    - `start` is today by default.
+    - `end` is one week after the start date by default.
+    - `includeVirtual` is false by default.
+
+    The schedule will include virtual timeslots to fill unscheduled gaps if requested.
 
-       - internal calendar to retrieve all timeslots for a week
-         Expects GET variable 'start' (date), otherwise start will be today
-         If end not given, it returns all timeslots of the next 7 days
+    Called by
+    - engine (playout) to retrieve timeslots within a given timerange
+    - internal calendar to retrieve all timeslots for a week
     """
 
     # datetime.combine returns a timezone naive datetime object
     if request.GET.get("start") is None:
-        start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0)))
+        schedule_start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0)))
     else:
-        start = timezone.make_aware(
+        schedule_start = timezone.make_aware(
             datetime.combine(parse_date(request.GET.get("start")), time(0, 0))
         )
 
     if request.GET.get("end") is None:
-        end = start + timedelta(days=7)
+        schedule_end = schedule_start + timedelta(days=7)
     else:
-        end = timezone.make_aware(
-            datetime.combine(parse_date(request.GET.get("end")), time(23, 59))
+        schedule_end = timezone.make_aware(
+            datetime.combine(parse_date(request.GET.get("end")) + timedelta(days=1), time(0, 0)),
+            timezone=timezone.get_current_timezone(),
         )
 
+    include_virtual = request.GET.get("includeVirtual") == "true"
+
     timeslots = (
-        TimeSlot.objects.get_timerange_timeslots(start, end)
+        TimeSlot.objects.get_timerange_timeslots(schedule_start, schedule_end)
         .select_related("schedule")
         .select_related("show")
     )
 
     schedule = []
 
-    for ts in timeslots:
-        is_repetition = " " + _("REP") if ts.schedule.is_repetition is True else ""
-
-        hosts = ", ".join(ts.show.hosts.values_list("name", flat=True))
-        categories = ", ".join(ts.show.category.values_list("name", flat=True))
-        topics = ", ".join(ts.show.topic.values_list("name", flat=True))
-        music_focus = ", ".join(ts.show.music_focus.values_list("name", flat=True))
-        languages = ", ".join(ts.show.language.values_list("name", flat=True))
-        funding_category = (
-            FundingCategory.objects.get(pk=ts.show.funding_category_id)
-            if ts.show.funding_category_id
-            else None
-        )
+    first_timeslot = timeslots.first()
 
-        type_ = Type.objects.get(pk=ts.show.type_id)
+    if include_virtual and first_timeslot.start > schedule_start:
+        schedule.append(gap_entry(gap_start=schedule_start, gap_end=first_timeslot.start))
 
-        classname = "default"
+    for current, upcoming in pairwise(timeslots):
+        schedule.append(timeslot_entry(timeslot=current))
 
-        if ts.playlist_id is None or ts.playlist_id == 0:
-            classname = "danger"
+        if include_virtual and current.end != upcoming.start:
+            schedule.append(gap_entry(gap_start=current.end, gap_end=upcoming.start))
 
-        entry = {
-            "id": ts.id,
-            "start": ts.start.strftime("%Y-%m-%dT%H:%M:%S"),
-            "end": ts.end.strftime("%Y-%m-%dT%H:%M:%S"),
-            "title": ts.show.name + is_repetition,  # For JS Calendar
-            "schedule_id": ts.schedule.id,
-            "is_repetition": ts.is_repetition,
-            "playlist_id": ts.playlist_id,
-            "schedule_default_playlist_id": ts.schedule.default_playlist_id,
-            "show_default_playlist_id": ts.show.default_playlist_id,
-            "show_id": ts.show.id,
-            "show_name": ts.show.name + is_repetition,
-            "show_hosts": hosts,
-            "show_type": type_.name,
-            "show_categories": categories,
-            "show_topics": topics,
-            # TODO: replace `show_musicfocus` with `show_music_focus` when engine is updated
-            "show_musicfocus": music_focus,
-            "show_languages": languages,
-            # TODO: replace `show_fundingcategory` with `show_funding_category` when engine is
-            #  updated
-            "show_fundingcategory": funding_category.name,
-            "memo": ts.memo,
-            "className": classname,
-        }
+    last_timeslot = timeslots.last()
 
-        schedule.append(entry)
+    # we need to append the last timeslot to the schedule to complete it
+    schedule.append(timeslot_entry(timeslot=last_timeslot))
+
+    if include_virtual and last_timeslot.end < schedule_end:
+        schedule.append(gap_entry(gap_start=last_timeslot.end, gap_end=schedule_end))
 
     return HttpResponse(
         json.dumps(schedule, ensure_ascii=False).encode("utf8"),
-- 
GitLab