diff --git a/program/filters.py b/program/filters.py index 55ec829e80b05ee1a35191a8a123401df6119c38..60bbce5de6a0d766cbbc3bf807ac39d35d551041 100644 --- a/program/filters.py +++ b/program/filters.py @@ -8,6 +8,7 @@ from django import forms from django.db.models import Exists, OuterRef, QuerySet from django.utils import timezone from program import models +from program.services import generate_program_entries class StaticFilterHelpTextMixin: @@ -280,3 +281,34 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): fields = [ "is_active", ] + + +class VirtualTimeslotFilterSet(filters.FilterSet): + start = filters.IsoDateTimeFilter(method="filter_noop") + end = filters.IsoDateTimeFilter(method="filter_noop") + include_virtual = filters.BooleanFilter( + help_text="Include virtual timeslot entries (default: false).", + method="filter_noop", + ) + + # Filters using the noop are implemented in the generate_program_entries generator. + # We do this, so that we have all the bells and whistles of the automatic value conversion, + # but can still implement custom filter logic on top. + def filter_noop(self, queryset: QuerySet, _: str, value: bool) -> QuerySet: + return queryset + + def filter_queryset(self, queryset: QuerySet): + queryset = super().filter_queryset(queryset) + filter_data = self.form.cleaned_data + return list( + generate_program_entries( + queryset, + start=filter_data["start"], + end=filter_data["end"], + include_virtual=filter_data["include_virtual"], + ) + ) + + class Meta: + model = models.TimeSlot + fields = ["start", "end", "include_virtual"] diff --git a/program/serializers.py b/program/serializers.py index 9f052dd0e03997dbf8faed876a932995ed80a81c..b8e23db578cddd7a221c6175638f9ae76f95caa5 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -1283,3 +1283,18 @@ class RadioSettingsSerializer(serializers.ModelSerializer): logo=logo, website=obj.station_website, ) + + +class ProgramEntrySerializer(serializers.Serializer): + id = serializers.CharField() + start = serializers.DateTimeField() + end = serializers.DateTimeField() + timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id") + show_id = serializers.IntegerField(source="show.id") + + +class ExtendedProgramEntrySerializer(ProgramEntrySerializer): + show = ShowSerializer() + timeslot = TimeSlotSerializer(allow_null=True) + episode = NoteSerializer(allow_null=True, source="timeslot.note") + schedule = ScheduleSerializer(allow_null=True, source="timeslot.schedule") diff --git a/program/services.py b/program/services.py index 7af891b9a72f073a405bc6b9f06adcc7e5140faf..39efd7109233724fa693bdfb7ce47e0c52c33f2d 100644 --- a/program/services.py +++ b/program/services.py @@ -18,7 +18,9 @@ # import copy +from collections.abc import Iterator from datetime import datetime, time, timedelta +from typing import TypedDict from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule @@ -29,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.typing import Conflicts, ScheduleCreateUpdateData, ScheduleData from program.utils import parse_date, parse_datetime, parse_time @@ -687,3 +697,60 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts: conflicts["playlists"] = {} return conflicts + + +class ProgramEntry(TypedDict): + id: str + start: datetime + end: datetime + show: Show + timeslot: TimeSlot | None + + +def generate_program_entries( + queryset: QuerySet[TimeSlot], + *, + start: datetime | None, + end: datetime | None, + include_virtual: bool, +) -> Iterator[ProgramEntry]: + """Gets list of timerange entries between the given `timerange_start` and `timerange_end`. + + Include virtual timerange entries if requested.""" + + def create_entry(start: datetime, end: datetime, show: Show, timeslot: TimeSlot | None = None): + return ProgramEntry( + id=f"{start.isoformat()}...{end.isoformat()}", + start=start, + end=end, + timeslot=timeslot, + show=show, + ) + + def create_timeslot_entry(timeslot: TimeSlot): + return create_entry(timeslot.start, timeslot.end, timeslot.schedule.show, timeslot) + + if start is None: + start = timezone.now() + if end is None: + end = start + timedelta(days=7) + queryset = queryset.filter(start__gte=start, start__lt=end) + + if not include_virtual: + yield from (create_timeslot_entry(timeslot) for timeslot in queryset) + return + + radio_settings: RadioSettings | None = RadioSettings.objects.first() + fallback_show = radio_settings.fallback_show if radio_settings is not None else None + if fallback_show is None: + raise ValueError("Radio settings must set fallback show if include_virtual is True.") + + entry_start = start + timeslot: TimeSlot + for timeslot in queryset: + if timeslot.start > entry_start: + yield create_entry(entry_start, timeslot.start, fallback_show) + yield create_timeslot_entry(timeslot) + entry_start = timeslot.end + if entry_start != end: + yield create_entry(entry_start, end, fallback_show) diff --git a/program/views.py b/program/views.py index d4f3716524f19f4adbf8a1ee1fb7129149467fe6..aefd2f8491237941b89bc82676412d23ab975af1 100644 --- a/program/views.py +++ b/program/views.py @@ -66,6 +66,7 @@ from program.models import ( from program.serializers import ( CategorySerializer, ErrorSerializer, + ExtendedProgramEntrySerializer, FundingCategorySerializer, ImageRenderSerializer, ImageSerializer, @@ -75,6 +76,7 @@ from program.serializers import ( MusicFocusSerializer, NoteSerializer, ProfileSerializer, + ProgramEntrySerializer, RadioSettingsSerializer, RRuleSerializer, ScheduleConflictResponseSerializer, @@ -94,6 +96,27 @@ from program.utils import get_values logger = logging.getLogger(__name__) +@extend_schema_view( + list=extend_schema( + summary="List program for a specific date range.", + ), +) +class APIProgramViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + filterset_class = filters.VirtualTimeslotFilterSet + queryset = TimeSlot.objects.all() + + def get_serializer_class(self): + serializer_format = self.request.query_params.get("serializer_format", "simple") + if serializer_format == "simple": + return ProgramEntrySerializer + elif serializer_format == "extended": + return ExtendedProgramEntrySerializer + raise ValueError("Unknown serializer format") + + @extend_schema_view( create=extend_schema(summary="Create a new user."), retrieve=extend_schema( diff --git a/steering/urls.py b/steering/urls.py index 46cca2d469e8b44f2a6d8e9ce103a2d279ce6522..855667b68d4bfbffbf805892389b8517d6d81707 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -35,6 +35,7 @@ from program.views import ( APIMusicFocusViewSet, APINoteViewSet, APIProfileViewSet, + APIProgramViewSet, APIRadioSettingsViewSet, APIRRuleViewSet, APIScheduleViewSet, @@ -65,6 +66,7 @@ router.register(r"link-types", APILinkTypeViewSet, basename="link-type") router.register(r"rrules", APIRRuleViewSet, basename="rrule") router.register(r"images", APIImageViewSet, basename="image") router.register(r"settings", APIRadioSettingsViewSet, basename="settings") +router.register(r"program", APIProgramViewSet, basename="program") urlpatterns = [ path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),