diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ef5a476760eede01cfb350bee04346fcb30103..1b1c0ac1b21e78f7b36a36ae05e0fcb8c22d272e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `basic` and `calendar` program routes. (steering#239) + + ### Changed - Changed the aura user and group ID from 2872 to 872. +- `playout` is now part of the program routes. (steering#120) ## [1.0.0-alpha4] - 2024-04-17 diff --git a/conftest.py b/conftest.py index 2acabcdd1e35f84b3ffc7e0437799dc61b0f4ee8..bad250b0a709df8c7aed77c2c922e46cbcb7e46b 100644 --- a/conftest.py +++ b/conftest.py @@ -57,6 +57,27 @@ def assert_data(response, data) -> None: assert response.data[key] == value +def create_daily_schedule(admin_api_client, daily_rrule, show) -> None: + """creates a schedule for a show that repeats daily using the REST API.""" + + now = datetime.now() + in_one_hour = now + timedelta(hours=1) + in_seven_days = now + timedelta(days=7) + + data = { + "schedule": { + "end_time": in_one_hour.strftime("%H:%M:%S"), + "first_date": now.strftime("%Y-%m-%d"), + "last_date": in_seven_days.strftime("%Y-%m-%d"), + "rrule_id": daily_rrule.id, + "show_id": show.id, + "start_time": now.strftime("%H:%M:%S"), + } + } + + admin_api_client.post("/api/v1/schedules/", data=data, format="json") + + @pytest.fixture def profile() -> Profile: return ProfileFactory() diff --git a/program/filters.py b/program/filters.py index e54eac1563cfc42428db31be00a26a07cef4aa12..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: @@ -283,11 +284,31 @@ class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): class VirtualTimeslotFilterSet(filters.FilterSet): + start = filters.IsoDateTimeFilter(method="filter_noop") + end = filters.IsoDateTimeFilter(method="filter_noop") include_virtual = filters.BooleanFilter( - field_name="is_virtual", 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 = ["include_virtual"] + fields = ["start", "end", "include_virtual"] diff --git a/program/models.py b/program/models.py index fe6ba333e0e139713af97af9ed23d17f8b4abf18..493de37bf5db050f2d5cbd0c21da8ccca06931dd 100644 --- a/program/models.py +++ b/program/models.py @@ -17,6 +17,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # +import dataclasses +import datetime + import jsonschema from rest_framework.exceptions import ValidationError from versatileimagefield.fields import PPOIField, VersatileImageField @@ -687,3 +690,18 @@ class RadioSettings(models.Model): def __str__(self): return self.station_name + + +@dataclasses.dataclass() +class ProgramEntry: + id: str + start: datetime.datetime + end: datetime.datetime + show: Show + timeslot: TimeSlot | None + + def playlist_id(self) -> int | None: + if self.timeslot and self.timeslot.playlist_id: + return self.timeslot.playlist_id + else: + return self.show.default_playlist_id diff --git a/program/serializers.py b/program/serializers.py index 015b01b729a0e7d7283a2ffec92a45a3675fa0c4..612324834b824908ef60db8578c1b5435ced3496 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -19,6 +19,7 @@ # import re +from functools import cached_property from drf_jsonschema_serializer import JSONSchemaField from rest_framework import serializers @@ -28,6 +29,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db import IntegrityError +from django.db.models import Q from django.utils import text, timezone from program.models import ( CBA, @@ -42,6 +44,7 @@ from program.models import ( NoteLink, Profile, ProfileLink, + ProgramEntry, RadioSettings, RRule, Schedule, @@ -54,9 +57,6 @@ from program.models import ( from program.typing import ( Logo, MicroProgram, - NestedEpisode, - NestedSchedule, - NestedShow, ProgramFallback, RadioCBASettings, RadioImageRequirementsSettings, @@ -1288,49 +1288,161 @@ class RadioSettingsSerializer(serializers.ModelSerializer): ) -# done this way to get the schema annotations for datetime right -class NestedTimeslotSerializer(serializers.Serializer): +class BasicProgramEntrySerializer(serializers.Serializer): + id = serializers.CharField() + start = serializers.DateTimeField() end = serializers.DateTimeField() - id = serializers.IntegerField(allow_null=True) - is_virtual = serializers.BooleanField() - memo = serializers.CharField() + timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id") playlist_id = serializers.IntegerField(allow_null=True) - repetition_of_id = serializers.IntegerField(allow_null=True) - start = serializers.DateTimeField() - - -class PlayoutEntrySerializer(serializers.Serializer): - episode = serializers.SerializerMethodField() - schedule = serializers.SerializerMethodField(allow_null=True) - show = serializers.SerializerMethodField() - timeslot = NestedTimeslotSerializer() - - @staticmethod - def get_episode(obj) -> NestedEpisode: - pass + show_id = serializers.IntegerField(source="show.id") + + +class PlayoutProgramEntrySerializer(BasicProgramEntrySerializer): + class PlayoutShowSerializer(serializers.ModelSerializer): + class Meta: + model = Show + fields = ["id", "name", "default_playlist_id"] + + class PlayoutScheduleSerializer(serializers.ModelSerializer): + class Meta: + model = Schedule + fields = ["id", "default_playlist_id"] + + class PlayoutEpisodeSerializer(serializers.ModelSerializer): + class Meta: + model = Note + fields = ["id", "title"] + + timeslot = TimeSlotSerializer() + show = PlayoutShowSerializer() + episode = PlayoutEpisodeSerializer(allow_null=True, source="timeslot.note") + schedule = PlayoutScheduleSerializer(allow_null=True, source="timeslot.schedule") + + +class CalendarSchemaSerializer(serializers.Serializer): + class Wrapper: + def __init__(self, program: list[ProgramEntry]): + self.program = program + + @cached_property + def shows(self): + show_ids = set(entry.show.id for entry in self.program) + return Show.objects.distinct().filter(id__in=show_ids) + + @cached_property + def timeslots(self): + timeslot_ids = set(entry.timeslot.id for entry in self.program if entry.timeslot) + return TimeSlot.objects.distinct().filter(id__in=timeslot_ids) + + @cached_property + def episodes(self): + return Note.objects.distinct().filter(timeslot__in=self.timeslots) + + @cached_property + def profiles(self): + return Profile.objects.distinct().filter( + Q(shows__in=self.shows) | Q(notes__in=self.episodes) + ) - @staticmethod - def get_schedule(obj) -> NestedSchedule: - pass + @property + def categories(self): + return Category.objects.distinct().filter(shows__in=self.shows) - @staticmethod - def get_show(obj) -> NestedShow: - pass + @property + def funding_categories(self): + return FundingCategory.objects.distinct().filter(shows__in=self.shows) + @property + def types(self): + return Type.objects.distinct().filter(shows__in=self.shows) -class ProgramEntrySerializer(serializers.Serializer): - episode = serializers.SerializerMethodField() - show = serializers.SerializerMethodField() - timeslot = NestedTimeslotSerializer() + @property + def topics(self): + return Topic.objects.distinct().filter( + Q(shows__in=self.shows) | Q(episodes__in=self.episodes) + ) - @staticmethod - def get_episode(obj) -> NestedEpisode: - pass + @property + def languages(self): + return Language.objects.distinct().filter( + Q(shows__in=self.shows) | Q(episodes__in=self.episodes) + ) - @staticmethod - def get_schedule(obj) -> NestedSchedule: - pass + @property + def music_focuses(self): + return MusicFocus.objects.distinct().filter(shows__in=self.shows) + + @cached_property + def images(self): + return Image.objects.distinct().filter( + Q(logo_shows__in=self.shows) + | Q(shows__in=self.shows) + | Q(profiles__in=self.profiles) + | Q(notes__in=self.episodes) + ) - @staticmethod - def get_show(obj) -> NestedShow: - pass + @property + def licenses(self): + return License.objects.distinct().filter(images__in=self.images) + + @property + def link_types(self): + return LinkType.objects.all() + + class CalendarTimeslotSerializer(TimeSlotSerializer): + class Meta(TimeSlotSerializer.Meta): + fields = [f for f in TimeSlotSerializer.Meta.fields if f != "memo"] + + class CalendarEpisodeSerializer(NoteSerializer): + class Meta(NoteSerializer.Meta): + fields = [ + field + for field in NoteSerializer.Meta.fields + if field not in ["created_at", "created_by", "updated_at", "updated_by"] + ] + + class CalendarProfileSerializer(ProfileSerializer): + class Meta(ProfileSerializer.Meta): + fields = [ + field + for field in ProfileSerializer.Meta.fields + if field + not in [ + "created_at", + "created_by", + "owner_ids", + "updated_at", + "updated_by", + ] + ] + + class CalendarShowSerializer(ShowSerializer): + class Meta(ShowSerializer.Meta): + fields = [ + field + for field in ShowSerializer.Meta.fields + if field + not in [ + "created_at", + "created_by", + "internal_note", + "owner_ids", + "updated_at", + "updated_by", + ] + ] + + shows = CalendarShowSerializer(many=True) + timeslots = CalendarTimeslotSerializer(many=True) + profiles = CalendarProfileSerializer(many=True) + categories = CategorySerializer(many=True) + funding_categories = FundingCategorySerializer(many=True) + types = TypeSerializer(many=True) + images = ImageSerializer(many=True) + topics = TopicSerializer(many=True) + languages = LanguageSerializer(many=True) + music_focuses = MusicFocusSerializer(many=True) + program = BasicProgramEntrySerializer(many=True) + episodes = CalendarEpisodeSerializer(many=True) + licenses = LicenseSerializer(many=True) + link_types = LinkTypeSerializer(many=True) diff --git a/program/services.py b/program/services.py index fd1e6406a808cdd0424f2492ecd2f8efd8ac1abc..23497ed65fdfa6b9834ef7cb91c291c0820af385 100644 --- a/program/services.py +++ b/program/services.py @@ -18,12 +18,12 @@ # import copy +from collections.abc import Iterator from datetime import datetime, time, timedelta -from itertools import pairwise from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule -from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.exceptions import ValidationError from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -32,6 +32,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from program.models import ( Note, + ProgramEntry, RadioSettings, RRule, Schedule, @@ -40,17 +41,7 @@ from program.models import ( TimeSlot, ) from program.serializers import ScheduleSerializer, TimeSlotSerializer -from program.typing import ( - Conflicts, - DayScheduleEntry, - NestedEpisode, - NestedSchedule, - NestedShow, - NestedTimeslot, - ScheduleCreateUpdateData, - ScheduleData, - TimerangeEntry, -) +from program.typing import Conflicts, ScheduleCreateUpdateData, ScheduleData from program.utils import parse_date, parse_datetime, parse_time @@ -708,146 +699,50 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts: return conflicts -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( - 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_timerange_entry(*, timeslot: TimeSlot) -> TimerangeEntry: - """returns a timerange entry for the given timeslot.""" - - episode = timeslot.note - schedule = timeslot.schedule - show = timeslot.schedule.show - - 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_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(): - 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( - detail=_("Radio settings with fallbacks not found."), code="radio_settings-not_found" - ) - - -def get_timerange_timeslot_entries( - timerange_start: datetime, timerange_end: datetime, include_virtual: bool = False -) -> list[TimerangeEntry]: +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.""" - 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_timerange_entry(timeslot=timeslot) for timeslot in timeslots] - - if not timeslots: - return [] - - timeslot_entries = [] - # gap before the first timeslot - first_timeslot = timeslots.first() - if first_timeslot.start > timerange_start: - timeslot_entries.append( - make_virtual_timerange_entry(gap_start=timerange_start, gap_end=first_timeslot.start) + 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, ) - for index, (current, upcoming) in enumerate(pairwise(timeslots)): - timeslot_entries.append(make_timerange_entry(timeslot=current)) + def create_timeslot_entry(timeslot: TimeSlot): + return create_entry(timeslot.start, timeslot.end, timeslot.schedule.show, timeslot) - # gap between the timeslots - if current.end != upcoming.start: - timeslot_entries.append( - make_virtual_timerange_entry(gap_start=current.end, gap_end=upcoming.start) - ) - else: - timeslot_entries.append(make_timerange_entry(timeslot=first_timeslot)) + if start is None: + start = timezone.now() + if end is None: + end = start + timedelta(days=1) + queryset = queryset.filter(start__gte=start, start__lt=end) - # gap after the last timeslot - last_timeslot = timeslots.last() - if last_timeslot.end < timerange_end: - timeslot_entries.append( - make_virtual_timerange_entry(gap_start=last_timeslot.end, gap_end=timerange_end) - ) - - return timeslot_entries + 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/tests/test_basic_program.py b/program/tests/test_basic_program.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc08892027b9a050dbe4883e9d1f3e5f6fb4e88 --- /dev/null +++ b/program/tests/test_basic_program.py @@ -0,0 +1,128 @@ +from datetime import datetime, timedelta +from itertools import pairwise + +import pytest + +from conftest import create_daily_schedule + +pytestmark = pytest.mark.django_db + + +def url(include_virtual=False, start=None, end=None): + if include_virtual and start and end: + return f"/api/v1/program/basic/?include_virtual=true&start={start}&end={end}" + elif start and end: + return f"/api/v1/program/basic/?start={start}&end={end}" + elif include_virtual: + return "/api/v1/program/basic/?include_virtual=true" + else: + return "/api/v1/program/basic/" + + +def assert_entry(entry, show) -> None: + """asserts the playout entry corresponds to the given show.""" + + assert entry["end"] + assert entry["id"] + assert "playlistId" in entry + assert entry["start"] + assert entry["timeslotId"] + + assert entry["showId"] == show.id + + +def assert_virtual_entry(entry, fallback_show) -> None: + """asserts the playout entry is virtual and corresponds to given fallback show.""" + + assert entry["end"] + assert entry["id"] + assert "playlistId" in entry + assert entry["start"] + + assert not entry["timeslotId"] + + assert entry["showId"] == fallback_show.id + + +def test_basic(admin_api_client, api_client, daily_rrule, show): + create_daily_schedule(admin_api_client, daily_rrule, show) + + response = api_client.get(url()) + + assert response.status_code == 200 + assert len(response.json()) == 1 + + assert_entry(response.json()[0], show) + + +def test_basic_one_week(admin_api_client, api_client, daily_rrule, show): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat())) + + assert response.status_code == 200 + assert len(response.json()) == 6 or 7 # I’m not sure why, but this changes around midnight. + + for entry in response.json(): + assert_entry(entry, show) + + +def test_basic_include_virtual( + admin_api_client, + api_client, + daily_rrule, + show, + fallback_show, + radio_settings, +): + + create_daily_schedule(admin_api_client, daily_rrule, show) + + response = api_client.get(url(include_virtual=True)) + + assert response.status_code == 200 + assert len(response.json()) == 3 + + virtual_entry1, entry, virtual_entry2 = response.json() + + assert_virtual_entry(virtual_entry1, fallback_show) + assert_entry(entry, show) + assert_virtual_entry(virtual_entry2, fallback_show) + + assert virtual_entry1["end"] == entry["start"] + assert entry["end"] == virtual_entry2["start"] + + +def test_basic_one_week_include_virtual( + admin_api_client, + api_client, + daily_rrule, + show, + fallback_show, + radio_settings, +): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get( + url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat()) + ) + + assert response.status_code == 200 + assert len(response.json()) == 13 or 15 # I’m not sure why, but this changes around midnight. + + entries = response.json() + + for virtual_entry in entries[0::2]: + assert_virtual_entry(virtual_entry, fallback_show) + + for entry in entries[1::2]: + assert_entry(entry, show) + + for entry1, entry2 in pairwise(entries): + assert entry1["end"] == entry2["start"] diff --git a/program/tests/test_calendar_program.py b/program/tests/test_calendar_program.py new file mode 100644 index 0000000000000000000000000000000000000000..417f840fb2f0346d5161c6efb2cbfb82f5317bae --- /dev/null +++ b/program/tests/test_calendar_program.py @@ -0,0 +1,174 @@ +from datetime import datetime, timedelta + +import pytest + +from conftest import create_daily_schedule + +pytestmark = pytest.mark.django_db + + +def url(include_virtual=False, start=None, end=None): + if include_virtual and start and end: + return f"/api/v1/program/calendar/?include_virtual=true&start={start}&end={end}" + elif start and end: + return f"/api/v1/program/calendar/?start={start}&end={end}" + elif include_virtual: + return "/api/v1/program/calendar/?include_virtual=true" + else: + return "/api/v1/program/calendar/" + + +def assert_episodes(episodes, one_week=False) -> None: + """asserts the episodes are valid.""" + + assert len(episodes) == 1 if not one_week else 7 + + for episode in episodes: + assert episode["id"] + assert episode["timeslotId"] + + +def assert_program(program, show, fallback_show=None, one_week=False) -> None: + """asserts the program are valid and correspond to the given show and fallback show.""" + + assert len(program) == 1 if fallback_show is None and not one_week else (7 if one_week else 3) + + assert program[0]["end"] + assert program[0]["id"] + assert program[0]["start"] + + if fallback_show is None: + assert program[0]["showId"] == show.id + assert program[0]["timeslotId"] + else: + assert program[0]["showId"] == fallback_show.id + + assert program[1]["showId"] == show.id + assert program[1]["timeslotId"] + + +def assert_shows(shows, show, fallback_show=None) -> None: + """asserts the shows are valid correspond to the given show and fallback show.""" + + assert len(shows) == 1 if fallback_show is None else 2 + + if fallback_show is None: + assert shows[0]["id"] == show.id + assert shows[0]["name"] == show.name + else: + assert shows[0]["id"] == show.id + assert shows[0]["name"] == show.name + + assert shows[1]["id"] == fallback_show.id + assert shows[1]["name"] == fallback_show.name + + +def assert_timeslots(timeslots, show, one_week=False) -> None: + """asserts the timeslots are valid and correspond to the given show.""" + + assert len(timeslots) == 1 if not one_week else 7 + + for timeslot in timeslots: + assert timeslot["id"] + assert timeslot["noteId"] + assert timeslot["end"] + assert timeslot["start"] + assert timeslot["showId"] == show.id + + +def test_calendar(admin_api_client, api_client, daily_rrule, show): + create_daily_schedule(admin_api_client, daily_rrule, show) + + response = api_client.get(url()) + + assert response.status_code == 200 + + for key, value in response.json().items(): + match key: + case "episodes": + assert_episodes(value) + case "program": + assert_program(value, show) + case "shows": + assert_shows(value, show) + case "timeslots": + assert_timeslots(value, show) + + +def test_calendar_one_week(admin_api_client, api_client, daily_rrule, show): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat())) + + assert response.status_code == 200 + + for key, value in response.json().items(): + match key: + case "episodes": + assert_episodes(value, one_week=True) + case "program": + assert_program(value, show, one_week=True) + case "shows": + assert_shows(value, show) + case "timeslots": + assert_timeslots(value, show, one_week=True) + + +def test_calendar_include_virtual( + admin_api_client, + api_client, + daily_rrule, + show, + fallback_show, + radio_settings, +): + create_daily_schedule(admin_api_client, daily_rrule, show) + + response = api_client.get(url(include_virtual=True)) + + assert response.status_code == 200 + + for key, value in response.json().items(): + match key: + case "episodes": + assert_episodes(value) + case "program": + assert_program(value, show, fallback_show) + case "shows": + assert_shows(value, show, fallback_show) + case "timeslots": + assert_timeslots(value, show) + + +def test_calendar_one_week_include_virtual( + admin_api_client, + api_client, + daily_rrule, + show, + fallback_show, + radio_settings, +): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get( + url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat()) + ) + + assert response.status_code == 200 + + for key, value in response.json().items(): + match key: + case "episodes": + assert_episodes(value, one_week=True) + case "program": + assert_program(value, show=show, fallback_show=fallback_show, one_week=True) + case "shows": + assert_shows(value, show=show, fallback_show=fallback_show) + case "timeslots": + assert_timeslots(value, show=show, one_week=True) diff --git a/program/tests/test_playout.py b/program/tests/test_playout.py index f1e6db062f809da4c2686810971b0f56e166f6d1..acf2c217a01dbee02c8751c5e15a0f88ffe4b2de 100644 --- a/program/tests/test_playout.py +++ b/program/tests/test_playout.py @@ -1,36 +1,48 @@ from datetime import datetime, timedelta +from itertools import pairwise import pytest +from conftest import create_daily_schedule + pytestmark = pytest.mark.django_db -def url(include_virtual=False): - if include_virtual: - return "/api/v1/playout/?include_virtual=true" +def url(include_virtual=False, start=None, end=None): + if include_virtual and start and end: + return f"/api/v1/program/playout/?include_virtual=true&start={start}&end={end}" + elif start and end: + return f"/api/v1/program/playout/?start={start}&end={end}" + elif include_virtual: + return "/api/v1/program/playout/?include_virtual=true" + else: + return "/api/v1/program/playout/" - return "/api/v1/playout/" +def assert_entry(entry, show) -> None: + """asserts the playout entry corresponds to the given show.""" -def create_daily_schedule(admin_api_client, daily_rrule, show) -> None: - """creates a schedule for a show that repeats daily using the REST API.""" + assert entry["episode"] + assert entry["schedule"] + assert entry["show"] + assert entry["timeslot"] + assert entry["timeslotId"] + + assert entry["showId"] == entry["show"]["id"] == show.id + assert entry["show"]["name"] == show.name - now = datetime.now() - in_one_hour = now + timedelta(hours=1) - in_seven_days = now + timedelta(days=7) - data = { - "schedule": { - "end_time": in_one_hour.strftime("%H:%M:%S"), - "first_date": now.strftime("%Y-%m-%d"), - "last_date": in_seven_days.strftime("%Y-%m-%d"), - "rrule_id": daily_rrule.id, - "show_id": show.id, - "start_time": now.strftime("%H:%M:%S"), - } - } +def assert_virtual_entry(entry, fallback_show) -> None: + """asserts the playout entry is virtual and corresponds to given fallback show.""" - admin_api_client.post("/api/v1/schedules/", data=data, format="json") + assert entry["show"] + assert not entry["episode"] + assert not entry["schedule"] + assert not entry["timeslot"] + assert not entry["timeslotId"] + + assert entry["showId"] == entry["show"]["id"] == fallback_show.id + assert entry["show"]["name"] == fallback_show.name def test_playout(admin_api_client, api_client, daily_rrule, show): @@ -39,12 +51,24 @@ def test_playout(admin_api_client, api_client, daily_rrule, show): response = api_client.get(url()) assert response.status_code == 200 - assert len(response.json()) == 7 + assert len(response.json()) == 1 + + assert_entry(response.json()[0], show) + + +def test_playout_one_week(admin_api_client, api_client, daily_rrule, show): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat())) + + assert response.status_code == 200 + assert len(response.json()) == 6 or 7 # I’m not sure why, but this changes around midnight. for entry in response.json(): - assert not entry["timeslot"]["isVirtual"] - assert entry["show"]["id"] == show.id - assert entry["show"]["name"] == show.name + assert_entry(entry, show) def test_playout_include_virtual( @@ -60,12 +84,45 @@ def test_playout_include_virtual( response = api_client.get(url(include_virtual=True)) assert response.status_code == 200 - assert len(response.json()) == 14 or 15 + assert len(response.json()) == 3 - for entry in response.json(): - if entry["timeslot"]["isVirtual"]: - assert entry["show"]["id"] == fallback_show.id - assert entry["show"]["name"] == fallback_show.name - else: - assert entry["show"]["id"] == show.id - assert entry["show"]["name"] == show.name + virtual_entry1, entry, virtual_entry2 = response.json() + + assert_virtual_entry(virtual_entry1, fallback_show) + assert_entry(entry, show) + assert_virtual_entry(virtual_entry2, fallback_show) + + assert virtual_entry1["end"] == entry["start"] + assert entry["end"] == virtual_entry2["start"] + + +def test_playout_one_week_include_virtual( + admin_api_client, + api_client, + daily_rrule, + show, + fallback_show, + radio_settings, +): + create_daily_schedule(admin_api_client, daily_rrule, show) + + now = datetime.now() + in_one_week = now + timedelta(days=7) + + response = api_client.get( + url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat()) + ) + + assert response.status_code == 200 + assert len(response.json()) == 13 or 15 # I’m not sure why, but this changes around midnight. + + entries = response.json() + + for virtual_entry in entries[0::2]: + assert_virtual_entry(virtual_entry, fallback_show) + + for entry in entries[1::2]: + assert_entry(entry, show) + + for entry1, entry2 in pairwise(entries): + assert entry1["end"] == entry2["start"] diff --git a/program/tests/test_program.py b/program/tests/test_program.py deleted file mode 100644 index e29c54339fc3708981a73225a83684fb12ed998a..0000000000000000000000000000000000000000 --- a/program/tests/test_program.py +++ /dev/null @@ -1,73 +0,0 @@ -from datetime import datetime, timedelta - -import pytest - -pytestmark = pytest.mark.django_db - - -def url(include_virtual=False): - year, month, day = datetime.today().year, datetime.today().month, datetime.today().day - - if include_virtual: - return f"/api/v1/program/{year}/{month}/{day}/?include_virtual=true" - else: - return f"/api/v1/program/{year}/{month}/{day}/" - - -def create_once_schedule(admin_api_client, once_rrule, show) -> None: - """creates a schedule for a show that repeats once using the REST API.""" - - now = datetime.now() - in_an_hour = now + timedelta(hours=1) - - data = { - "schedule": { - "end_time": in_an_hour.strftime("%H:%M:%S"), - "first_date": now.strftime("%Y-%m-%d"), - "last_date": None, - "rrule_id": once_rrule.id, - "show_id": show.id, - "start_time": now.strftime("%H:%M:%S"), - }, - } - - admin_api_client.post("/api/v1/schedules/", data=data, format="json") - - -def test_day_schedule(admin_api_client, api_client, once_rrule, show): - create_once_schedule(admin_api_client, once_rrule, show) - - response = api_client.get(url()) - - assert response.status_code == 200 - assert len(response.json()) == 1 - - entry = response.json()[0] - - assert not entry["timeslot"]["isVirtual"] - assert entry["show"]["id"] == show.id - assert entry["show"]["name"] == show.name - - -def test_day_schedule_include_virtual( - admin_api_client, - api_client, - once_rrule, - show, - fallback_show, - radio_settings, -): - create_once_schedule(admin_api_client, once_rrule, show) - - response = api_client.get(url(include_virtual=True)) - - assert response.status_code == 200 - assert len(response.json()) == 3 - - for entry in response.json(): - if entry["timeslot"]["isVirtual"]: - assert entry["show"]["id"] == fallback_show.id - assert entry["show"]["name"] == fallback_show.name - else: - assert entry["show"]["id"] == show.id - assert entry["show"]["name"] == show.name diff --git a/program/tests/test_schedules.py b/program/tests/test_schedules.py index a7185f015f74211ff956817e728a30f657c536ab..0416a0f56a45ccb7b3e22f8ac8ed3a9fef1f628e 100644 --- a/program/tests/test_schedules.py +++ b/program/tests/test_schedules.py @@ -240,7 +240,7 @@ def test_patch_set_is_repetition_true(admin_api_client, once_schedule): update = {"is_repetition": "true"} response = admin_api_client.patch(url(schedule=once_schedule), data=update) - print(response.request.items()) + assert response.status_code == 200 assert response.data["is_repetition"] is True diff --git a/program/views.py b/program/views.py index 6b447cec6efb29016e032bdc422055583f6394be..8d151b575e6cde9e221ccc8f7699b9d100ed27e6 100644 --- a/program/views.py +++ b/program/views.py @@ -19,11 +19,10 @@ # import logging -from datetime import date, datetime, time, timedelta +from datetime import date, datetime from textwrap import dedent from django_filters.rest_framework import DjangoFilterBackend -from djangorestframework_camel_case.util import camelize from drf_spectacular.utils import ( OpenApiExample, OpenApiParameter, @@ -65,6 +64,8 @@ from program.models import ( Type, ) from program.serializers import ( + BasicProgramEntrySerializer, + CalendarSchemaSerializer, CategorySerializer, ErrorSerializer, FundingCategorySerializer, @@ -75,9 +76,8 @@ from program.serializers import ( LinkTypeSerializer, MusicFocusSerializer, NoteSerializer, - PlayoutEntrySerializer, + PlayoutProgramEntrySerializer, ProfileSerializer, - ProgramEntrySerializer, RadioSettingsSerializer, RRuleSerializer, ScheduleConflictResponseSerializer, @@ -91,172 +91,440 @@ from program.serializers import ( TypeSerializer, UserSerializer, ) -from program.services import ( - get_timerange_timeslot_entries, - make_day_schedule_entry, - resolve_conflicts, -) -from program.utils import get_values, parse_date +from program.services import resolve_conflicts +from program.utils import get_values logger = logging.getLogger(__name__) +class AbstractAPIProgramViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + filterset_class = filters.VirtualTimeslotFilterSet + queryset = TimeSlot.objects.all() + + @extend_schema_view( list=extend_schema( - summary="List schedule for a specific date.", - description=( - "Returns a list of the schedule for a specific date." - "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, + "Example entry", 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", - }, + "end": "2024-07-15T15:30:00-04:00", + "id": "2024-07-15T19:07:38.604349+00:00...2024-07-15T19:30:00+00:00", + "playlistId": None, + "showId": 1, + "start": "2024-07-15T15:07:38.604349-04:00", + "timeslotId": None, }, ), OpenApiExample( - "Virtual entry", - response_only=True, + "Example virtual entry", 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", - }, + "end": "2024-07-15T18:00:00-04:00", + "id": "2024-07-15T19:30:00+00:00...2024-07-15T22:00:00+00:00", + "playlistId": None, + "showId": 3, + "start": "2024-07-15T15:30:00-04:00", + "timeslotId": 141, }, ), ], + summary=( + "List program for a specific date range. " + "Only returns the most basic data for clients that fetch other data themselves." + ), ), ) -class APIProgramViewSet( - mixins.ListModelMixin, - viewsets.GenericViewSet, -): - filterset_class = filters.VirtualTimeslotFilterSet - queryset = TimeSlot.objects.all() - serializer_class = ProgramEntrySerializer - - def list(self, 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: - start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0))) - else: - start = timezone.make_aware(datetime.combine(date(year, month, day), time(0, 0))) - - end = start + timedelta(hours=24) - - include_virtual = request.GET.get("include_virtual") == "true" - - schedule = [ - make_day_schedule_entry(timerange_entry=timeslot_entry) - for timeslot_entry in get_timerange_timeslot_entries(start, end, include_virtual) - ] - - return JsonResponse(camelize(schedule), safe=False) +class APIProgramBasicViewSet(AbstractAPIProgramViewSet): + serializer_class = BasicProgramEntrySerializer @extend_schema_view( list=extend_schema( - summary="List scheduled playout.", - description=( - "Returns a list of the scheduled playout. " - "The schedule will include virtual timeslots to fill unscheduled gaps if requested." - ), - # TODO: move this into the serializers examples=[ OpenApiExample( - "Full entry", - response_only=True, + "Example entry", value={ - "episode": {"id": 2, "title": ""}, + "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", + "playlistId": None, "schedule": {"defaultPlaylistId": None, "id": 1}, "show": {"defaultPlaylistId": None, "id": 2, "name": "EINS"}, + "showId": 2, + "start": "2024-07-16T10:00:00-04:00", "timeslot": { - "end": "2024-07-03T16:30:00", - "id": 2, - "isVirtual": False, + "end": "2024-07-16T12:30:00-04:00", + "id": 11, "memo": "", + "noteId": 11, "playlistId": None, "repetitionOfId": None, - "start": "2024-07-03T14:00:00", + "scheduleId": 1, + "showId": 2, + "start": "2024-07-16T10:00:00-04:00", }, + "timeslotId": 11, }, ), OpenApiExample( - "Virtual entry", - response_only=True, + "Example virtual entry", value={ - "episode": {"id": None, "title": "Station Fallback Pool"}, + "end": "2024-07-16T15:23:34.084852-04:00", + "episode": None, + "id": "2024-07-16T16:30:00+00:00...2024-07-16T19:23:34.084852+00:00", + "playlistId": None, "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", - }, + "showId": 1, + "start": "2024-07-16T12:30:00-04:00", + "timeslot": None, + "timeslotId": None, }, ), ], - ) + summary=( + "List program for a specific date range. " + "Returns an extended program dataset for use in the AURA engine. " + "Not recommended for other tools." + ), + ), ) -class APIPlayoutViewSet( - mixins.ListModelMixin, - viewsets.GenericViewSet, -): - filterset_class = filters.VirtualTimeslotFilterSet - queryset = TimeSlot.objects.all() - serializer_class = PlayoutEntrySerializer +class APIProgramPlayoutViewSet(AbstractAPIProgramViewSet): + serializer_class = PlayoutProgramEntrySerializer - def list(self, request, *args, **kwargs): - """ - Return a JSON representation of the scheduled playout. - Called by - - engine (playout) to retrieve timeslots within a given timerange - - internal calendar to retrieve all timeslots for a week - """ - # datetime.now and datetime.combine return timezone naive datetime objects - if request.GET.get("start") is None: - schedule_start = timezone.make_aware(datetime.combine(datetime.now(), time(0, 0))) - else: - schedule_start = timezone.make_aware( - datetime.combine(parse_date(request.GET.get("start")), time(0, 0)) - ) +class APIProgramCalendarViewSet(AbstractAPIProgramViewSet): + serializer_class = CalendarSchemaSerializer - if request.GET.get("end") is None: - schedule_end = schedule_start + timedelta(days=7) - else: - schedule_end = timezone.make_aware( - datetime.combine( - parse_date(request.GET.get("end")) + timedelta(days=1), time(0, 0) - ) + @extend_schema( + examples=[ + OpenApiExample( + "Example entry with virtual timeslots", + value={ + "shows": [ + { + "categoryIds": [5], + "cbaSeriesId": None, + "defaultPlaylistId": None, + "description": "", + "email": "ernesto@helsinki.at", + "fundingCategoryId": 1, + "hostIds": [1], + "id": 4, + "imageId": None, + "isActive": True, + "isPublic": True, + "languageIds": [1, 3], + "links": [{"typeId": 1, "url": "https://helsinki.at"}], + "logoId": None, + "musicFocusIds": [4], + "name": "DREI", + "predecessorId": None, + "shortDescription": "chores", + "slug": "drei", + "topicIds": [5], + "typeId": 2, + }, + { + "categoryIds": [], + "cbaSeriesId": None, + "defaultPlaylistId": None, + "description": "", + "email": "", + "fundingCategoryId": 1, + "hostIds": [1], + "id": 2, + "imageId": None, + "isActive": True, + "isPublic": True, + "languageIds": [], + "links": [], + "logoId": None, + "musicFocusIds": [], + "name": "EINS", + "predecessorId": None, + "shortDescription": "shallow work", + "slug": "eins", + "topicIds": [], + "typeId": 5, + }, + { + "categoryIds": [], + "cbaSeriesId": None, + "defaultPlaylistId": None, + "description": "", + "email": "", + "fundingCategoryId": 1, + "hostIds": [], + "id": 1, + "imageId": None, + "isActive": True, + "isPublic": True, + "languageIds": [], + "links": [], + "logoId": None, + "musicFocusIds": [], + "name": "Musikpool", + "predecessorId": None, + "shortDescription": "Musik aus der Dose", + "slug": "musikpool", + "topicIds": [], + "typeId": 3, + }, + { + "categoryIds": [], + "cbaSeriesId": None, + "defaultPlaylistId": None, + "description": "", + "email": "", + "fundingCategoryId": 1, + "hostIds": [1], + "id": 3, + "imageId": None, + "isActive": True, + "isPublic": True, + "languageIds": [], + "links": [], + "logoId": None, + "musicFocusIds": [], + "name": "ZWEI", + "predecessorId": None, + "shortDescription": "deep work", + "slug": "zwei", + "topicIds": [], + "typeId": 5, + }, + ], + "timeslots": [ + { + "playlistId": None, + "repetitionOfId": None, + "end": "2024-07-22T18:00:00-04:00", + "id": 146, + "noteId": 146, + "scheduleId": 2, + "showId": 3, + "start": "2024-07-22T15:30:00-04:00", + }, + { + "playlistId": None, + "repetitionOfId": None, + "end": "2024-07-22T23:00:00-04:00", + "id": 267, + "noteId": 267, + "scheduleId": 3, + "showId": 4, + "start": "2024-07-22T20:30:00-04:00", + }, + { + "playlistId": None, + "repetitionOfId": None, + "end": "2024-07-23T12:30:00-04:00", + "id": 16, + "noteId": 16, + "scheduleId": 1, + "showId": 2, + "start": "2024-07-23T10:00:00-04:00", + }, + ], + "profiles": [ + { + "biography": "", + "email": "", + "id": 1, + "imageId": None, + "isActive": True, + "links": [], + "name": "Ernesto", + } + ], + "categories": [ + { + "description": "", + "id": 5, + "isActive": True, + "name": "Mehr-/Fremdsprachig", + "slug": "mehr-fremdsprachig", + "subtitle": "", + } + ], + "fundingCategories": [ + {"id": 1, "isActive": True, "name": "Standard", "slug": "standard"} + ], + "types": [ + { + "id": 5, + "isActive": True, + "name": "Experimentell", + "slug": "experimentell", + }, + { + "id": 2, + "isActive": True, + "name": "Musiksendung", + "slug": "musiksendung", + }, + { + "id": 3, + "isActive": True, + "name": "Unmoderiertes Musikprogramm", + "slug": "unmoderiertes-musikprogramm", + }, + ], + "images": [], + "topics": [ + { + "id": 5, + "isActive": True, + "name": "Wissenschaft/Philosophie", + "slug": "wissenschaft-philosophie", + } + ], + "languages": [ + {"id": 1, "isActive": True, "name": "Deutsch"}, + {"id": 3, "isActive": True, "name": "Spanisch"}, + ], + "musicFocuses": [ + {"id": 4, "isActive": True, "name": "Rock/Indie", "slug": "rock-indie"} + ], + "program": [ + { + "id": "2024-07-22T15:26:44.738502+00:00...2024-07-22T19:30:00+00:00", + "start": "2024-07-22T11:26:44.738502-04:00", + "end": "2024-07-22T15:30:00-04:00", + "timeslotId": None, + "playlistId": None, + "showId": 1, + }, + { + "id": "2024-07-22T19:30:00+00:00...2024-07-22T22:00:00+00:00", + "start": "2024-07-22T15:30:00-04:00", + "end": "2024-07-22T18:00:00-04:00", + "timeslotId": 146, + "playlistId": None, + "showId": 3, + }, + { + "id": "2024-07-22T22:00:00+00:00...2024-07-23T00:30:00+00:00", + "start": "2024-07-22T18:00:00-04:00", + "end": "2024-07-22T20:30:00-04:00", + "timeslotId": None, + "playlistId": None, + "showId": 1, + }, + { + "id": "2024-07-23T00:30:00+00:00...2024-07-23T03:00:00+00:00", + "start": "2024-07-22T20:30:00-04:00", + "end": "2024-07-22T23:00:00-04:00", + "timeslotId": 267, + "playlistId": None, + "showId": 4, + }, + { + "id": "2024-07-23T03:00:00+00:00...2024-07-23T14:00:00+00:00", + "start": "2024-07-22T23:00:00-04:00", + "end": "2024-07-23T10:00:00-04:00", + "timeslotId": None, + "playlistId": None, + "showId": 1, + }, + { + "id": "2024-07-23T14:00:00+00:00...2024-07-23T16:30:00+00:00", + "start": "2024-07-23T10:00:00-04:00", + "end": "2024-07-23T12:30:00-04:00", + "timeslotId": 16, + "playlistId": None, + "showId": 2, + }, + { + "id": "2024-07-23T16:30:00+00:00...2024-07-23T15:26:44.738502+00:00", + "start": "2024-07-23T12:30:00-04:00", + "end": "2024-07-23T11:26:44.738502-04:00", + "timeslotId": None, + "playlistId": None, + "showId": 1, + }, + ], + "episodes": [ + { + "cbaId": None, + "content": "", + "contributorIds": [], + "id": 146, + "imageId": None, + "languageIds": [], + "links": [], + "summary": "", + "tags": [], + "timeslotId": 146, + "title": "", + "topicIds": [], + }, + { + "cbaId": None, + "content": "", + "contributorIds": [], + "id": 267, + "imageId": None, + "languageIds": [], + "links": [], + "summary": "", + "tags": [], + "timeslotId": 267, + "title": "", + "topicIds": [], + }, + { + "cbaId": None, + "content": "", + "contributorIds": [], + "id": 16, + "imageId": None, + "languageIds": [], + "links": [], + "summary": "", + "tags": [], + "timeslotId": 16, + "title": "", + "topicIds": [], + }, + ], + "licenses": [], + "linkTypes": [ + {"id": 2, "isActive": True, "name": "CBA"}, + {"id": 11, "isActive": True, "name": "Facebook"}, + {"id": 3, "isActive": True, "name": "Freie Radios Online"}, + {"id": 4, "isActive": True, "name": "Funkwhale"}, + {"id": 10, "isActive": True, "name": "Instagram"}, + {"id": 7, "isActive": True, "name": "Internet Archive (archive.org)"}, + {"id": 13, "isActive": True, "name": "Mastodon"}, + {"id": 5, "isActive": True, "name": "Mixcloud"}, + {"id": 6, "isActive": True, "name": "SoundCloud"}, + {"id": 9, "isActive": True, "name": "Spotify"}, + {"id": 12, "isActive": True, "name": "Twitter"}, + {"id": 1, "isActive": True, "name": "Website"}, + {"id": 8, "isActive": True, "name": "YouTube"}, + ], + }, ) - - include_virtual = request.GET.get("include_virtual") == "true" - - playout = get_timerange_timeslot_entries(schedule_start, schedule_end, include_virtual) - - return JsonResponse(camelize(playout), safe=False) + ], + summary=( + "List program for a specific date range. " + "Returns all relevant data for the specified time frame." + ), + # FIXME: + # This doesn’t work because the list wrapping behaviour is forced in drf-spectacular. + # This can potentially be fixed with an OpenApiSerializerExtension. + # see: https://drf-spectacular.readthedocs.io/en/latest/customization.html#declare-serializer-magic-with-openapiserializerextension # noqa: E501 + responses=serializer_class(many=False), + ) + def list(self, request, *args, **kwargs): + program = list(self.filter_queryset(self.get_queryset())) + serializer = self.get_serializer(instance=CalendarSchemaSerializer.Wrapper(program)) + return Response(serializer.data) @extend_schema_view( diff --git a/steering/urls.py b/steering/urls.py index e807f164d7e3d12f60948017bce616e49d2eb56f..ccf2a0d0f7a65d60e2504ab54b8a5235d3ea6f1c 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -34,9 +34,10 @@ from program.views import ( APILinkTypeViewSet, APIMusicFocusViewSet, APINoteViewSet, - APIPlayoutViewSet, APIProfileViewSet, - APIProgramViewSet, + APIProgramBasicViewSet, + APIProgramCalendarViewSet, + APIProgramPlayoutViewSet, APIRadioSettingsViewSet, APIRRuleViewSet, APIScheduleViewSet, @@ -67,17 +68,13 @@ 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"playout", APIPlayoutViewSet, basename="playout") -router.register(r"program/week", APIPlayoutViewSet, basename="program/week") +router.register(r"program/basic", APIProgramBasicViewSet, basename="program-basic") +router.register(r"program/playout", APIProgramPlayoutViewSet, basename="program-playout") +router.register(r"program/calendar", APIProgramCalendarViewSet, basename="program-calendar") urlpatterns = [ path("openid/", include("oidc_provider.urls", namespace="oidc_provider")), path("api/v1/", include(router.urls)), - path( - "api/v1/program/<int:year>/<int:month>/<int:day>/", - APIProgramViewSet.as_view({"get": "list"}), - name="program", - ), path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"), path( "api/v1/schema/swagger-ui/",