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