diff --git a/program/filters.py b/program/filters.py index af8421a919ffd1607b6c29dff3c70cec61654275..d442c2acc34d4669dcc5c90c4ad08bcf857b82a2 100644 --- a/program/filters.py +++ b/program/filters.py @@ -300,6 +300,13 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): class VirtualTimeslotFilterSet(filters.FilterSet): start = filters.IsoDateTimeFilter(method="filter_noop") end = filters.IsoDateTimeFilter(method="filter_noop") + cut_at_range_boundaries = filters.BooleanFilter( + help_text=( + "If true guarantees that the first and last program entry match the requested range " + "even if these entries earlier or end later." + ), + method="filter_noop", + ) include_virtual = filters.BooleanFilter( help_text="Include virtual timeslot entries (default: false).", method="filter_noop", @@ -319,7 +326,8 @@ class VirtualTimeslotFilterSet(filters.FilterSet): queryset, start=filter_data["start"], end=filter_data["end"], - include_virtual=filter_data["include_virtual"], + include_virtual=bool(filter_data["include_virtual"]), + cut_at_range_boundaries=bool(filter_data["cut_at_range_boundaries"]), ) ) diff --git a/program/serializers.py b/program/serializers.py index bbae2a63899d1d5f737b7ea5781927bc993baf73..cf89e77e9e937a96b256f4a78bcdffcea25c09c0 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -1243,7 +1243,7 @@ class RadioSettingsSerializer(serializers.ModelSerializer): class BasicProgramEntrySerializer(serializers.Serializer): - id = serializers.CharField() + id = serializers.UUIDField() start = serializers.DateTimeField() end = serializers.DateTimeField() timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id") diff --git a/program/services.py b/program/services.py index 87693930e93d08256d2b2c79e48c0ca467ea01a9..df7565b51019f9dd6e02f0cb729a13b9f2fe3cb8 100644 --- a/program/services.py +++ b/program/services.py @@ -18,6 +18,8 @@ # import copy +import hashlib +import uuid from collections.abc import Iterator from datetime import datetime, time, timedelta @@ -700,32 +702,54 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts: return conflicts +def get_fallback_show(raise_exceptions: bool = False): + radio_settings: RadioSettings | None = RadioSettings.objects.first() + fallback_show = radio_settings.fallback_show if radio_settings is not None else None + if raise_exceptions and fallback_show is None: + raise ConfigurationError( + "Radio settings must define a fallback show if include_virtual is True.", + code="no-fallback-show-defined", + ) + return fallback_show + + +def uuid_from_string(data: str): + hash = hashlib.md5() + hash.update(data.encode()) + return uuid.UUID(hex=hash.hexdigest(), version=4) + + def generate_program_entries( queryset: QuerySet[TimeSlot], *, start: datetime | None, end: datetime | None, include_virtual: bool, + cut_at_range_boundaries: 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): + def create_entry( + starts_at: datetime, ends_at: datetime, show: Show, timeslot: TimeSlot | None = None + ): + entry_id = uuid_from_string(f"{starts_at.isoformat()}...{ends_at.isoformat()}") + if cut_at_range_boundaries: + starts_at = max(starts_at, start) + ends_at = min(ends_at, end) return ProgramEntry( - id=f"{start.isoformat()}...{end.isoformat()}", - start=start, - end=end, + id=entry_id, + start=starts_at, + end=ends_at, timeslot=timeslot, show=show, ) def create_timeslot_entry(timeslot: TimeSlot): return create_entry( - # Ensure the program entry never starts before the requested start - # and never ends after the requested end. - max(timeslot.start, start), - min(timeslot.end, end), + timeslot.start, + timeslot.end, timeslot.schedule.show, timeslot, ) @@ -734,29 +758,40 @@ def generate_program_entries( start = timezone.now() if end is None: end = start + timedelta(days=1) + + queryset = queryset.order_by("start") # Find all timeslots that # * have started before the specified start value but end after it # * or end after the specified end value but start before it - queryset = queryset.filter(end__gt=start, start__lt=end).order_by("start") + timeslots = queryset.filter(end__gt=start, start__lt=end) if not include_virtual: - yield from (create_timeslot_entry(timeslot) for timeslot in queryset) + yield from (create_timeslot_entry(timeslot) for timeslot in timeslots) 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 ConfigurationError( - "Radio settings must define a fallback show if include_virtual is True.", - code="no-fallback-show-defined", - ) - - entry_start = start + # Program entries that are not based on scheduled timeslots are generated using the fallback + # show. We can only create these program entries if a fallback show has been specified. + fallback_show = get_fallback_show(raise_exceptions=True) + + # Shift the range start/end to the closest scheduled timeslots around the specified range. + # This ensures that we generate stable ids for entries. + # We first check if the timeslots in our queryset might already start/end before/after the + # specified range, because we potentially include them according to the filter above. + first_ts_before_start = timeslots.first() + if not first_ts_before_start or first_ts_before_start.start > start: + first_ts_before_start = queryset.filter(end__lte=start).last() + first_ts_after_end = timeslots.last() + if not first_ts_after_end or first_ts_after_end.end < end: + first_ts_after_end = queryset.filter(start__gte=end).first() + range_start = first_ts_before_start.end if first_ts_before_start else start + range_end = first_ts_after_end.start if first_ts_after_end else end + + entry_start = range_start timeslot: TimeSlot - for timeslot in queryset: + for timeslot in timeslots: 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) + if entry_start < range_end: + yield create_entry(entry_start, range_end, fallback_show) diff --git a/program/views.py b/program/views.py index a03556f645a8bf84dc04fd5682b2550211b4a526..bfc700feb4abafa96f9edb2ad50b735ddca5e846 100644 --- a/program/views.py +++ b/program/views.py @@ -150,39 +150,39 @@ class APIProgramBasicViewSet(AbstractAPIProgramViewSet): OpenApiExample( "Example entry", value={ - "end": "2024-07-16T12:30:00-04:00", - "episode": {"id": 11, "title": ""}, - "id": "2024-07-16T14:00:00+00:00...2024-07-16T16:30:00+00:00", + "end": "2024-07-31T12:15:00-04:00", + "episode": {"id": 6, "title": ""}, + "id": "44b26957-fa84-4704-89dd-308e26b00556", "playlistId": None, "schedule": {"defaultPlaylistId": None, "id": 1}, - "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"}, - "showId": 2, - "start": "2024-07-16T10:00:00-04:00", + "show": {"defaultPlaylistId": None, "id": 1, "name": "EINS"}, + "showId": 1, + "start": "2024-07-31T11:00:00-04:00", "timeslot": { - "end": "2024-07-16T12:30:00-04:00", - "id": 11, + "end": "2024-07-31T12:15:00-04:00", + "id": 6, "memo": "", - "noteId": 11, + "noteId": 6, "playlistId": None, "repetitionOfId": None, "scheduleId": 1, - "showId": 2, - "start": "2024-07-16T10:00:00-04:00", + "showId": 1, + "start": "2024-07-31T11:00:00-04:00", }, - "timeslotId": 11, + "timeslotId": 6, }, ), OpenApiExample( "Example virtual entry", value={ - "end": "2024-07-16T15:23:34.084852-04:00", + "end": "2024-08-01T11:00:00-04:00", "episode": None, - "id": "2024-07-16T16:30:00+00:00...2024-07-16T19:23:34.084852+00:00", + "id": "5e8a3075-b5d6-40c8-97d1-5ee11d8a090d", "playlistId": None, "schedule": None, - "show": {"defaultPlaylistId": None, "id": 1, "name": "Musikpool"}, - "showId": 1, - "start": "2024-07-16T12:30:00-04:00", + "show": {"defaultPlaylistId": None, "id": 2, "name": "Musikpool"}, + "showId": 2, + "start": "2024-07-31T12:15:00-04:00", "timeslot": None, "timeslotId": None, },