diff --git a/program/services.py b/program/services.py index 038f3089355ef1c27a12053364a1d317225fa423..df3a45f27591081083974005778f41fec15a44a4 100644 --- a/program/services.py +++ b/program/services.py @@ -19,7 +19,8 @@ import copy from datetime import datetime, time, timedelta -from typing import TypedDict +from itertools import pairwise +from typing import Literal, TypedDict from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule @@ -30,7 +31,15 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q, QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from program.models import Note, RRule, Schedule, ScheduleConflictError, Show, TimeSlot +from program.models import ( + Note, + RadioSettings, + RRule, + Schedule, + ScheduleConflictError, + Show, + TimeSlot, +) from program.serializers import ScheduleSerializer, TimeSlotSerializer from program.utils import parse_date, parse_datetime, parse_time @@ -86,6 +95,36 @@ class ScheduleCreateUpdateData(TypedDict): solutions: dict[str, str] +class ScheduleEntry(TypedDict): + end: str + is_virtual: bool + show_id: int + start: str + title: str + + +class TimeslotEntry(TypedDict): + end: str + id: int + is_virtual: Literal[False] + 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 + start: str + title: str + + +class VirtualTimeslotEntry(TypedDict): + end: str + is_virtual: Literal[True] + show_id: int + start: str + title: str + + def create_timeslot(start: str, end: str, schedule: Schedule) -> TimeSlot: """Creates and returns an unsaved timeslot with the given `start`, `end` and `schedule`.""" @@ -740,14 +779,93 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts: return conflicts -def get_timerange_timeslots(start: datetime, end: datetime) -> QuerySet[TimeSlot]: - """Gets a queryset of timeslots between the given `start` and `end` datetime.""" +def make_schedule_entry(*, timeslot_entry: TimeslotEntry) -> ScheduleEntry: + """returns a schedule entry for the given timeslot entry.""" + + return { + "end": timeslot_entry["end"], + "show_id": timeslot_entry["show_id"], + "is_virtual": timeslot_entry["is_virtual"], + "start": timeslot_entry["start"], + "title": timeslot_entry["title"], + } + + +def make_timeslot_entry(*, timeslot: TimeSlot) -> TimeslotEntry: + """returns a timeslot entry for the given timeslot.""" + + schedule = timeslot.schedule + show = timeslot.schedule.show + + return { + "end": timeslot.end.strftime("%Y-%m-%dT%H:%M:%S %z"), + "id": timeslot.id, + "is_virtual": False, + "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, + "start": timeslot.start.strftime("%Y-%m-%dT%H:%M:%S %z"), + "title": f"{show.name} {_('REP')}" if schedule.is_repetition else show.name, + } + + +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`.""" + + return { + "end": gap_end.strftime("%Y-%m-%dT%H:%M:%S %z"), + "is_virtual": True, + "show_id": RadioSettings.objects.first().fallback_show.id, + "start": gap_start.strftime("%Y-%m-%dT%H:%M:%S %z"), + "title": RadioSettings.objects.first().fallback_default_pool, + } + + +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`. + + Include virtual timeslots if requested.""" + + timeslots = TimeSlot.objects.filter( + # start before `timerange_start` and end after `timerange_start` + Q(start__lt=timerange_start, end__gt=timerange_start) + # start after/at `timerange_start`, end before/at `timerange_end` + | Q(start__gte=timerange_start, end__lte=timerange_end) + # start before `timerange_end`, end after/at `timerange_end` + | Q(start__lt=timerange_end, end__gte=timerange_end) + ).select_related("schedule") + + if not include_virtual: + return [make_timeslot_entry(timeslot=timeslot) for timeslot in timeslots] + + timeslot_entries = [] + # gap before the first timeslot + 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) + ) - return TimeSlot.objects.filter( - # start before `start` and end after `start` - Q(start__lt=start, end__gt=start) - # start after/at `start`, end before/at `end` - | Q(start__gte=start, end__lte=end) - # start before `end`, end after/at `end` - | Q(start__lt=end, end__gte=end) - ) + for index, (current, upcoming) in enumerate(pairwise(timeslots)): + timeslot_entries.append(make_timeslot_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) + ) + + # 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) + ) + + return timeslot_entries diff --git a/program/views.py b/program/views.py index b70e0d78c41c7103bdcb2ca424ba5b0bc5ff606e..ecfa6794e3e5ef0e7998e4b4d724330fa56a8a2a 100644 --- a/program/views.py +++ b/program/views.py @@ -20,7 +20,6 @@ import logging from datetime import date, datetime, time, timedelta -from itertools import pairwise from textwrap import dedent from django_filters.rest_framework import DjangoFilterBackend @@ -43,7 +42,6 @@ from django.db import IntegrityError from django.http import HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone -from django.utils.translation import gettext as _ from program import filters from program.models import ( Category, @@ -89,59 +87,16 @@ from program.serializers import ( TypeSerializer, UserSerializer, ) -from program.services import get_timerange_timeslots, resolve_conflicts +from program.services import ( + get_timerange_timeslot_entries, + make_schedule_entry, + resolve_conflicts, +) from program.utils import get_values, parse_date logger = logging.getLogger(__name__) -def timeslot_entry(*, timeslot: TimeSlot) -> dict: - """return a timeslot entry as a dict""" - - schedule = timeslot.schedule - show = timeslot.schedule.show - playlist_id = timeslot.playlist_id - - title = show_name = f"{show.name} {_('REP')}" if schedule.is_repetition else show.name - # we start and end as timezone naive datetime objects - start = timezone.make_naive(timeslot.start).strftime("%Y-%m-%dT%H:%M:%S") - end = timezone.make_naive(timeslot.end).strftime("%Y-%m-%dT%H:%M:%S") - - return { - "end": end, - "id": timeslot.id, - "playlistId": playlist_id, - # `Timeslot.repetition_of` is a foreign key that can be null - "repetitionOfId": timeslot.repetition_of.id if timeslot.repetition_of else None, - "scheduleDefaultPlaylistId": schedule.default_playlist_id, - "scheduleId": schedule.id, - "showCategories": ", ".join(show.category.values_list("name", flat=True)), - "showDefaultPlaylistId": show.default_playlist_id, - # `Show.funding_category` is a foreign key can be null - "showFundingCategory": show.funding_category.name if show.funding_category_id else "", - "showHosts": ", ".join(show.hosts.values_list("name", flat=True)), - "showId": show.id, - "showLanguages": ", ".join(show.language.values_list("name", flat=True)), - "showMusicFocus": ", ".join(show.music_focus.values_list("name", flat=True)), - "showName": show_name, - "showTopics": ", ".join(show.topic.values_list("name", flat=True)), - # `Show.type` is a foreign key that can be null - "showType": show.type.name if show.type_id else "", - "start": start, - "title": title, - } - - -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 { - "end": gap_end.strftime("%Y-%m-%dT%H:%M:%S"), - "start": gap_start.strftime("%Y-%m-%dT%H:%M:%S"), - "virtual": True, - } - - @extend_schema_view( list=extend_schema( summary="List schedule for a specific date.", @@ -168,18 +123,12 @@ class APIDayScheduleViewSet( end = start + timedelta(hours=24) - timeslots = get_timerange_timeslots(start, end).select_related("schedule") - schedule = [] - - for ts in timeslots: - entry = { - "start": ts.start.strftime("%Y-%m-%d_%H:%M:%S"), - "end": ts.end.strftime("%Y-%m-%d_%H:%M:%S"), - "title": ts.schedule.show.name, - "id": ts.schedule.show.id, - } + include_virtual = request.GET.get("include_virtual") == "true" - schedule.append(entry) + schedule = [ + make_schedule_entry(timeslot_entry=timeslot_entry) + for timeslot_entry in get_timerange_timeslot_entries(start, end, include_virtual) + ] return JsonResponse(schedule, safe=False) @@ -229,33 +178,9 @@ class APIPlayoutViewSet( include_virtual = request.GET.get("include_virtual") == "true" - timeslots = get_timerange_timeslots(schedule_start, schedule_end).select_related( - "schedule" - ) - - schedule = [] - - first_timeslot = timeslots.first() + playout = get_timerange_timeslot_entries(schedule_start, schedule_end, include_virtual) - if include_virtual and first_timeslot.start > schedule_start: - schedule.append(gap_entry(gap_start=schedule_start, gap_end=first_timeslot.start)) - - for current, upcoming in pairwise(timeslots): - schedule.append(timeslot_entry(timeslot=current)) - - if include_virtual and current.end != upcoming.start: - schedule.append(gap_entry(gap_start=current.end, gap_end=upcoming.start)) - - last_timeslot = timeslots.last() - - # we need to append the last timeslot to the schedule to complete it - if last_timeslot: - 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 JsonResponse(schedule, safe=False) + return JsonResponse(playout, safe=False) @extend_schema_view(