Skip to content
Snippets Groups Projects
views.py 51.44 KiB
#
# steering, Programme/schedule management for AURA
#
# Copyright (C) 2011-2017, 2020, Ernesto Rico Schmidt
# Copyright (C) 2017-2019, Ingo Leindecker
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
# details.
#
# 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 logging
from datetime import date, datetime
from textwrap import dedent

from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import (
    OpenApiExample,
    OpenApiParameter,
    OpenApiResponse,
    OpenApiTypes,
    extend_schema,
    extend_schema_view,
)
from rest_framework import decorators
from rest_framework import filters as drf_filters
from rest_framework import mixins, permissions, status, viewsets
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.response import Response

from django.conf import settings
from django.contrib.auth.models import User
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 program import filters
from program.models import (
    Category,
    FundingCategory,
    Image,
    Language,
    License,
    LinkType,
    MusicFocus,
    Note,
    Profile,
    RadioSettings,
    RRule,
    Schedule,
    ScheduleConflictError,
    Show,
    TimeSlot,
    Topic,
    Type,
)
from program.serializers import (
    BasicProgramEntrySerializer,
    CalendarSchemaSerializer,
    CategorySerializer,
    ErrorSerializer,
    FundingCategorySerializer,
    ImageRenderSerializer,
    ImageSerializer,
    LanguageSerializer,
    LicenseSerializer,
    LinkTypeSerializer,
    MusicFocusSerializer,
    NoteSerializer,
    PlayoutProgramEntrySerializer,
    ProfileSerializer,
    RadioSettingsSerializer,
    RRuleSerializer,
    ScheduleConflictResponseSerializer,
    ScheduleCreateUpdateRequestSerializer,
    ScheduleDryRunResponseSerializer,
    ScheduleResponseSerializer,
    ScheduleSerializer,
    ShowSerializer,
    TimeSlotSerializer,
    TopicSerializer,
    TypeSerializer,
    UserSerializer,
)
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(
        examples=[
            OpenApiExample(
                "Example entry",
                value={
                    "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(
                "Example virtual entry",
                value={
                    "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 APIProgramBasicViewSet(AbstractAPIProgramViewSet):
    serializer_class = BasicProgramEntrySerializer


@extend_schema_view(
    list=extend_schema(
        examples=[
            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",
                    "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-16T12:30:00-04:00",
                        "id": 11,
                        "memo": "",
                        "noteId": 11,
                        "playlistId": None,
                        "repetitionOfId": None,
                        "scheduleId": 1,
                        "showId": 2,
                        "start": "2024-07-16T10:00:00-04:00",
                    },
                    "timeslotId": 11,
                },
            ),
            OpenApiExample(
                "Example virtual entry",
                value={
                    "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"},
                    "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 APIProgramPlayoutViewSet(AbstractAPIProgramViewSet):
    serializer_class = PlayoutProgramEntrySerializer


class APIProgramCalendarViewSet(AbstractAPIProgramViewSet):
    serializer_class = CalendarSchemaSerializer

    @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"},
                    ],
                },
            )
        ],
        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(
    create=extend_schema(summary="Create a new user."),
    retrieve=extend_schema(
        summary="Retrieve a single user.",
        description="Non-admin users may only retrieve their own user record.",
    ),
    update=extend_schema(
        summary="Update an existing user.",
        description="Non-admin users may only update their own user record.",
    ),
    partial_update=extend_schema(
        summary="Partially update an existing user.",
        description="Non-admin users may only update their own user record.",
    ),
    list=extend_schema(
        summary="List all users.",
        description=(
            "The returned list of records will only contain a single record "
            "for non-admin users which is their own user account."
        ),
    ),
)
class APIUserViewSet(
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.UpdateModelMixin,
    mixins.ListModelMixin,
    viewsets.GenericViewSet,
):
    serializer_class = UserSerializer
    filter_backends = [drf_filters.SearchFilter]
    search_fields = ["username", "first_name", "last_name", "email"]

    def get_queryset(self):
        """The queryset is empty if the user is not authenticated, contains all the users if the
        method is safe or the requesting user is a superuser, otherwise it only contains the
        requesting user."""

        user = self.request.user

        if not user.is_authenticated:
            return User.objects.none()
        elif self.request.method in permissions.SAFE_METHODS or user.is_superuser:
            return User.objects.all()
        else:
            return User.objects.filter(pk=user.id)

    def create(self, request, *args, **kwargs):
        serializer = UserSerializer(
            # FIXME: the method get_serializer_context should be used but it does seem to get lost
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
        )

        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)


@extend_schema_view(
    create=extend_schema(summary="Create a new image."),
    destroy=extend_schema(summary="Delete an existing image."),
    list=extend_schema(summary="List all images."),
    partial_update=extend_schema(
        summary="Partially update an existing image.",
        description="Only `alt_text`, `credits`, and `ppoi` can be updated.",
    ),
    retrieve=extend_schema(summary="Retrieve a single image."),
    update=extend_schema(
        summary="Update an existing image.",
        description="Only `alt_text`, `credits`, and `ppoi` can be updated.",
    ),
)
class APIImageViewSet(viewsets.ModelViewSet):
    serializer_class = ImageSerializer
    pagination_class = LimitOffsetPagination
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        """The queryset contains all the images if the method is safe, otherwise it only contains
        the images owned by the requesting user."""

        if self.request.method in permissions.SAFE_METHODS:
            return Image.objects.all()
        else:
            return Image.objects.filter(owner=self.request.user.username)

    def create(self, request, *args, **kwargs):
        """Create an Image instance. Any user can create an image."""

        serializer = ImageSerializer(
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
        )

        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)

    def update(self, request, *args, **kwargs):
        """Update an Image instance. Only the creator can update an image."""

        image = self.get_object()

        serializer = ImageSerializer(
            image,
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
        )

        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.data)

    def destroy(self, request, *args, **kwargs):
        """Destroy an Image instance. Only the owner can delete an image."""

        image = self.get_object()

        image.delete()

        return Response(status=status.HTTP_204_NO_CONTENT)

    @extend_schema(
        parameters=[
            ImageRenderSerializer,
            OpenApiParameter(
                name="Location",
                type=OpenApiTypes.URI,
                location=OpenApiParameter.HEADER,
                description="/",
                response=[301],
            ),
        ],
        responses={301: None},
    )
    @decorators.action(["GET"], detail=True)
    def render(self, *args, **kwargs):
        image = self.get_object()
        serializer = ImageRenderSerializer(data=self.request.GET)
        if serializer.is_valid():
            image_spec = serializer.validated_data
            url = image.render(
                width=image_spec.get("width", None),
                height=image_spec.get("height", None),
            )
            return HttpResponseRedirect(settings.SITE_URL + url)
        else:
            return Response(status=status.HTTP_400_BAD_REQUEST)


@extend_schema_view(
    create=extend_schema(summary="Create a new show."),
    retrieve=extend_schema(summary="Retrieve a single show."),
    update=extend_schema(summary="Update an existing show."),
    partial_update=extend_schema(summary="Partially update an existing show."),
    destroy=extend_schema(summary="Delete an existing show."),
    list=extend_schema(summary="List all shows."),
)
class APIShowViewSet(viewsets.ModelViewSet):
    queryset = Show.objects.all()
    serializer_class = ShowSerializer
    pagination_class = LimitOffsetPagination
    filter_backends = [DjangoFilterBackend, drf_filters.SearchFilter]
    filterset_class = filters.ShowFilterSet
    search_fields = ["name", "slug", "short_description", "description"]

    def get_object(self):
        queryset = self.filter_queryset(self.get_queryset())
        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
        lookup_arg = self.kwargs[lookup_url_kwarg]
        # allow object retrieval through id or slug
        try:
            filter_kwargs = {self.lookup_field: int(lookup_arg)}
        except ValueError:
            filter_kwargs = {"slug": lookup_arg}
        obj = get_object_or_404(queryset, **filter_kwargs)
        self.check_object_permissions(self.request, obj)
        return obj

    def create(self, request, *args, **kwargs):
        serializer = ShowSerializer(
            # FIXME: the method get_serializer_context should be used but it does seem to get lost
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
        )

        if serializer.is_valid(raise_exception=True):
            try:
                serializer.save()
            except IntegrityError:
                data = {
                    "slug": {"code": "unique", "message": "show with this slug already exists."}
                }

                return Response(data, status=status.HTTP_400_BAD_REQUEST)

            return Response(serializer.data, status=status.HTTP_201_CREATED)

    def update(self, request, *args, **kwargs):
        partial = kwargs.get("partial", False)
        show = self.get_object()
        serializer = ShowSerializer(
            # FIXME: the method get_serializer_context should be used but it does seem to get lost
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
            instance=show,
            partial=partial,
        )

        if serializer.is_valid(raise_exception=True):
            try:
                serializer.save()
                return Response(serializer.data)
            except IntegrityError:
                data = {
                    "slug": {"code": "unique", "message": "show with this slug already exists."}
                }

                return Response(data, status=status.HTTP_400_BAD_REQUEST)

    def partial_update(self, request, *args, **kwargs):
        kwargs["partial"] = True
        return self.update(request, *args, **kwargs)


@extend_schema_view(
    create=extend_schema(summary="Create a new rrule."),
    retrieve=extend_schema(summary="Retrieve a single rrule."),
    update=extend_schema(summary="Update an existing rrule."),
    partial_update=extend_schema(summary="Partially update an existing rrule."),
    destroy=extend_schema(summary="Delete an existing rrule."),
    list=extend_schema(summary="List all rrules."),
)
class APIRRuleViewSet(viewsets.ModelViewSet):
    queryset = RRule.objects.all()
    serializer_class = RRuleSerializer


@extend_schema_view(
    create=extend_schema(
        summary="Create a new schedule.",
        request=ScheduleCreateUpdateRequestSerializer,
        responses={
            status.HTTP_201_CREATED: OpenApiResponse(
                response=ScheduleResponseSerializer,
                description=(
                    "Signals the successful creation of the schedule and of the projected "
                    "timeslots."
                ),
            ),
            status.HTTP_202_ACCEPTED: OpenApiResponse(
                response=ScheduleDryRunResponseSerializer,
                description=(
                    "Returns the list of timeslots that would be created, updated and deleted if "
                    "the schedule request would not have been sent with the dryrun flag."
                ),
            ),
            status.HTTP_400_BAD_REQUEST: OpenApiResponse(
                response=ErrorSerializer(many=True),
                description=dedent(
                    """
                    Returned in case the request contained invalid data.

                    This may happen if:
                    * the last date is before the start date (`no-start-after-end`),
                      in which case you should correct either the start or until date.
                    * The start and last date are the same (`no-same-day-start-and-end`).
                      This is only allowed for single timeslots with the recurrence rule
                      set to `once`. You should fix either the start or until date.
                    * The number of conflicts and solutions aren’t the same
                      (`one-solution-per-conflict`). Only one solution is allowed per conflict,
                      so you either offered too many or not enough solutions for any reported
                      conflicts.
                    * The referenced recurrence rule does not exist.
                      """
                ),
            ),
            status.HTTP_403_FORBIDDEN: OpenApiResponse(
                response=ErrorSerializer,
                description=(
                    "Returned in case the request contained no or invalid authenticated data "
                    "or the authenticated user does not have authorization to perform the "
                    "requested operation."
                ),
            ),
            status.HTTP_409_CONFLICT: OpenApiResponse(
                response=ScheduleConflictResponseSerializer,
                description=dedent(
                    """
                    Returns the list of projected timeslots and any collisions that may have
                    been found for existing timeslots.

                    Errors on projected timeslots may include:
                    * 'This change on the timeslot is not allowed.'
                      When adding: There was a change in the schedule's data during conflict
                      resolution.
                      When updating: Fields 'start', 'end', 'by_weekday' or 'rrule' have changed,
                      which is not allowed.
                    * 'No solution given': No solution was provided for the conflict in
                      `solutions`. Provide a value of `solution_choices`.
                    * 'Given solution is not accepted for this conflict.':
                      The solution has a value which is not part of `solution_choices`.
                      Provide a value of `solution_choices` (at least `ours` or `theirs`).
                    """
                ),
            ),
        },
    ),
    retrieve=extend_schema(summary="Retrieve a single schedule."),
    update=extend_schema(
        summary="Update an existing schedule.",
        request=ScheduleCreateUpdateRequestSerializer,
    ),
    partial_update=extend_schema(
        summary="Partially update an existing schedule.",
        request=ScheduleCreateUpdateRequestSerializer,
    ),
    destroy=extend_schema(summary="Delete an existing schedule."),
    list=extend_schema(summary="List all schedules."),
)
class APIScheduleViewSet(viewsets.ModelViewSet):
    filterset_class = filters.ScheduleFilterSet
    pagination_class = LimitOffsetPagination
    queryset = Schedule.objects.all()
    serializer_class = ScheduleSerializer

    def get_serializer_class(self):
        if self.action in ("create", "update", "partial_update"):
            return ScheduleCreateUpdateRequestSerializer
        return super().get_serializer_class()

    def create(self, request, *args, **kwargs):
        """
        Create a schedule, generate timeslots, test for collisions and resolve them
        (including notes).

        Note that creating or updating a schedule is the only way to create timeslots.

        The projected timeslots defined by the schedule are matched against existing
        timeslots. The API will return an object that contains

        * the schedule's data,
        * projected timeslots,
        * detected collisions,
        * and possible solutions.

        As long as no `solutions` object has been set or unresolved collisions exist,
        no data is written to the database. A schedule is only created if at least
        one timeslot was generated by it.

        In order to resolve any possible conflicts, the client must submit a new request with
        a solution for each conflict. Possible solutions are listed as part of the projected
        timeslot in the `solution_choices` array. In a best-case scenario with no detected
        conflicts an empty solutions object will suffice. For more details on the individual
        types of solutions see the SolutionChoicesEnum.

        **Please note**:
        If there's more than one collision for a projected timeslot, only `theirs` and `ours`
        are currently supported as solutions.
        """

        pk, show_pk = get_values(self.kwargs, "pk", "show_pk")

        if show_pk is None:
            show_pk = request.data.get("schedule").get("show_id")

        # FIXME: this is wrong
        if show_pk == 0:
            show_pk = request.data.get("show_id")

        # Only allow creating when calling /shows/{show_pk}/schedules/ and with ehe `schedule` JSON
        # object
        if show_pk is None or "schedule" not in request.data:
            return Response(status=status.HTTP_400_BAD_REQUEST)

        try:
            resolution = resolve_conflicts(request.data, pk, show_pk)
        # FIXME: Find a better way to do this.
        # The exception is thrown by the instantiate_upcoming_schedule function.
        except RRule.DoesNotExist as exc:
            return JsonResponse({"rruleId": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
        except ScheduleConflictError as exc:
            return Response(exc.conflicts, status.HTTP_409_CONFLICT)

        if all(key in resolution for key in ["create", "update", "delete"]):
            # this is a dry-run
            return Response(resolution, status=status.HTTP_202_ACCEPTED)

        return Response(resolution, status=status.HTTP_201_CREATED)

    def update(self, request, *args, **kwargs):
        """
        Update a schedule, generate timeslots, test for collisions and resolve
        them including notes.
        """

        if not request.user.is_superuser:
            return Response(status=status.HTTP_401_UNAUTHORIZED)

        if request.method == "PATCH":
            # only these fields can be updated without generating conflicts
            allowed = {"default_playlist_id", "is_repetition", "last_date"}

            if set(request.data.keys()).issubset(allowed):
                schedule = self.get_object()

                if default_playlist_id := request.data.get("default_playlist_id"):
                    if default_playlist_id == "":
                        # "clear" the default_playlist_id if the field has no value
                        schedule.default_playlist_id = None
                    else:
                        schedule.default_playlist_id = int(default_playlist_id)

                if is_repetition := request.data.get("is_repetition"):
                    if is_repetition == "true" or is_repetition == "1":
                        schedule.is_repetition = True
                    if is_repetition == "false" or is_repetition == "0":
                        schedule.is_repetition = False

                if last_date := request.data.get("last_date"):
                    last_date = date.fromisoformat(last_date)

                    if schedule.last_date is None or schedule.last_date > last_date:
                        schedule.last_date = last_date

                        last_end = timezone.make_aware(
                            datetime.combine(last_date, schedule.end_time)
                        )

                        TimeSlot.objects.filter(schedule=schedule, start__gt=last_end).delete()
                    else:
                        return Response(status=status.HTTP_400_BAD_REQUEST)

                schedule.save()
                serializer = ScheduleSerializer(schedule)

                return Response(serializer.data)
            else:
                return Response(status=status.HTTP_400_BAD_REQUEST)

        # Only allow updating when with the `schedule` JSON object
        if "schedule" not in request.data:
            return Response(status=status.HTTP_400_BAD_REQUEST)

        schedule = self.get_object()

        # FIXME: this is redundant now and should be removed
        # If default playlist id or repetition are given, just update
        if default_playlist_id := request.data.get("schedule").get("default_playlist_id"):
            schedule.default_playlist_id = int(default_playlist_id)
            schedule.save()

            serializer = ScheduleSerializer(schedule)
            return Response(serializer.data)

        if is_repetition := request.data.get("schedule").get("is_repetition"):
            schedule.is_repetition = bool(is_repetition)
            schedule.save()

            serializer = ScheduleSerializer(schedule)
            return Response(serializer.data)

        try:
            resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk)
        except ScheduleConflictError as exc:
            return Response(exc.conflicts, status.HTTP_409_CONFLICT)

        return Response(resolution)


# TODO: Create is currently not implemented because timeslots are supposed to be inserted
#       by creating or updating a schedule.
#       There might be a use case for adding a single timeslot without any conflicts though.
@extend_schema_view(
    retrieve=extend_schema(summary="Retrieve a single timeslot."),
    update=extend_schema(summary="Update an existing timeslot."),
    partial_update=extend_schema(summary="Partially update an existing timeslot."),
    destroy=extend_schema(summary="Delete an existing timeslot."),
    list=extend_schema(
        summary="List all timeslots.",
        description=dedent(
            """
            By default, only timeslots ranging from now + 60 days will be displayed.
            You may override this default overriding start and/or end parameter.
            """
        ),
    ),
)
class APITimeSlotViewSet(
    mixins.RetrieveModelMixin,
    mixins.UpdateModelMixin,
    mixins.DestroyModelMixin,
    mixins.ListModelMixin,
    viewsets.GenericViewSet,
):
    filterset_class = filters.TimeSlotFilterSet
    pagination_class = LimitOffsetPagination
    queryset = TimeSlot.objects.all().order_by("-start")
    serializer_class = TimeSlotSerializer

    def update(self, request, *args, **kwargs):
        show_pk = get_values(self.kwargs, "show_pk")
        timeslot = self.get_object()
        serializer = TimeSlotSerializer(timeslot, data=request.data)
        if serializer.is_valid(raise_exception=True):
            serializer.save()

            # Return the next repetition
            # We do this because the Dashboard needs to update the repetition timeslot as well
            # but with another playlist containing the recording instead of the original playlist
            if first_repetition := TimeSlot.objects.filter(
                schedule__show=show_pk, start__gt=timeslot.start, repetition_of=timeslot
            ).first():
                serializer = TimeSlotSerializer(first_repetition)
                return Response(serializer.data)

            # ...or nothing if there isn't one
            return Response(serializer.data, status=status.HTTP_200_OK)


@extend_schema_view(
    create=extend_schema(summary="Create a new note."),
    retrieve=extend_schema(summary="Retrieve a single note."),
    update=extend_schema(summary="Update an existing note."),
    partial_update=extend_schema(
        summary="Partially update an existing note.",
        description="Only admins can partially update existing notes.",
    ),
    destroy=extend_schema(summary="Delete an existing note."),
    list=extend_schema(summary="List all notes."),
)
class APINoteViewSet(viewsets.ModelViewSet):
    filterset_class = filters.NoteFilterSet
    pagination_class = LimitOffsetPagination
    serializer_class = NoteSerializer

    def get_queryset(self):
        """The queryset contains all the notes if the method is safe or the requesting user is a
        superuser, otherwise it only contains the notes for shows owned by the requesting user."""

        user = self.request.user

        if self.request.method in permissions.SAFE_METHODS or user.has_perm("program.update_note"):
            return Note.objects.all()
        else:
            return Note.objects.filter(timeslot__schedule__show__owners=user)


class ActiveFilterMixin:
    filter_class = filters.ActiveFilterSet


@extend_schema_view(
    create=extend_schema(summary="Create a new category."),
    retrieve=extend_schema(summary="Retrieve a single category."),
    update=extend_schema(summary="Update an existing category."),
    partial_update=extend_schema(summary="Partially update an existing category."),
    destroy=extend_schema(summary="Delete an existing category."),
    list=extend_schema(summary="List all categories."),
)
class APICategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = Category.objects.all()
    serializer_class = CategorySerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new type."),
    retrieve=extend_schema(summary="Retrieve a single type."),
    update=extend_schema(summary="Update an existing type."),
    partial_update=extend_schema(summary="Partially update an existing type."),
    destroy=extend_schema(summary="Delete an existing type."),
    list=extend_schema(summary="List all types."),
)
class APITypeViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = Type.objects.all()
    serializer_class = TypeSerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new topic."),
    retrieve=extend_schema(summary="Retrieve a single topic."),
    update=extend_schema(summary="Update an existing topic."),
    partial_update=extend_schema(summary="Partially update an existing topic."),
    destroy=extend_schema(summary="Delete an existing topic."),
    list=extend_schema(summary="List all topics."),
)
class APITopicViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = Topic.objects.all()
    serializer_class = TopicSerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new music focus."),
    retrieve=extend_schema(summary="Retrieve a single music focus."),
    update=extend_schema(summary="Update an existing music focus."),
    partial_update=extend_schema(summary="Partially update an existing music focus."),
    destroy=extend_schema(summary="Delete an existing music focus."),
    list=extend_schema(summary="List all music focuses."),
)
class APIMusicFocusViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = MusicFocus.objects.all()
    serializer_class = MusicFocusSerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new funding category."),
    retrieve=extend_schema(summary="Retrieve a single funding category."),
    update=extend_schema(summary="Update an existing funding category."),
    partial_update=extend_schema(summary="Partially update an existing funding category."),
    destroy=extend_schema(summary="Delete an existing funding category."),
    list=extend_schema(summary="List all funding categories."),
)
class APIFundingCategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = FundingCategory.objects.all()
    serializer_class = FundingCategorySerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new language."),
    retrieve=extend_schema(summary="Retrieve a single language."),
    update=extend_schema(summary="Update an existing language."),
    partial_update=extend_schema(summary="Partially update an existing language."),
    destroy=extend_schema(summary="Delete an existing language."),
    list=extend_schema(summary="List all languages."),
)
class APILanguageViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = Language.objects.all()
    serializer_class = LanguageSerializer


@extend_schema_view(
    create=extend_schema(summary="Create a new profile."),
    retrieve=extend_schema(summary="Retrieve a single profile."),
    update=extend_schema(summary="Update an existing profile."),
    partial_update=extend_schema(summary="Partially update an existing profile."),
    destroy=extend_schema(summary="Delete an existing profile."),
    list=extend_schema(summary="List all profiles."),
)
class APIProfileViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
    queryset = Profile.objects.all().order_by("-is_active", "name")
    serializer_class = ProfileSerializer
    pagination_class = LimitOffsetPagination
    filter_backends = [drf_filters.SearchFilter]
    search_fields = ["name", "email"]

    def create(self, request, *args, **kwargs):
        serializer = ProfileSerializer(
            # FIXME: the method get_serializer_context should be used but it does seem to get lost
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
        )
        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)

    def update(self, request, *args, **kwargs):
        partial = kwargs.get("partial", False)
        profile = self.get_object()

        serializer = ProfileSerializer(
            # FIXME: the method get_serializer_context should be used but it does seem to get lost
            context={"request": request},  # the serializer needs the request in the context
            data=request.data,
            instance=profile,
            partial=partial,
        )

        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.data, status=status.HTTP_200_OK)

    def partial_update(self, request, *args, **kwargs):
        kwargs["partial"] = True
        return self.update(request, *args, **kwargs)


@extend_schema_view(
    create=extend_schema(summary="Create a new link type."),
    retrieve=extend_schema(summary="Retrieve a single link type."),
    update=extend_schema(summary="Update an existing link type."),
    partial_update=extend_schema(summary="Partially update an existing link type."),
    destroy=extend_schema(summary="Delete an existing link type."),
    list=extend_schema(summary="List all link types."),
)
class APILinkTypeViewSet(viewsets.ModelViewSet):
    queryset = LinkType.objects.all()
    serializer_class = LinkTypeSerializer

    def destroy(self, request, *args, **kwargs):
        """Destroying a link type just makes is inactive.

        This is needed to preserve all the links that reference a link type."""

        link_type = self.get_object()
        link_type.is_active = False
        link_type.save()

        return Response(status=status.HTTP_204_NO_CONTENT)


@extend_schema_view(
    create=extend_schema(summary="Create a new license type."),
    retrieve=extend_schema(summary="Retrieve a single license type."),
    update=extend_schema(summary="Update an existing license type."),
    partial_update=extend_schema(summary="Partially update an existing license type."),
    destroy=extend_schema(summary="Delete an existing license type."),
    list=extend_schema(summary="List all license types."),
)
class APILicenseViewSet(viewsets.ModelViewSet):
    queryset = License.objects.all()
    serializer_class = LicenseSerializer


@extend_schema_view(
    list=extend_schema(
        # TODO: move this into the serializers
        examples=[
            OpenApiExample(
                "Example Radio Settings",
                value={
                    "id": 1,
                    "cba": {"domains": ["cba.media"]},
                    "imageRequirements": {
                        "note.image": {"frame": {"aspectRatio": [16, 9], "shape": "round"}},
                        "profile.image": {"frame": {"aspectRatio": [1, 1], "shape": "round"}},
                        "show.image": {"frame": {"aspectRatio": [16, 9], "shape": "round"}},
                        "show.logo": {"frame": {"aspectRatio": [1, 1], "shape": "round"}},
                    },
                    "playout": {
                        "lineInChannels": {"0": "live", "1": "preprod"},
                        "pools": {"fallback": "Station Fallback Pool"},
                    },
                    "program": {
                        "micro": {"showId": None},
                        "fallback": {"showId": None, "defaultPool": "fallback"},
                    },
                    "station": {
                        "name": "Radio AURA",
                        "logo": None,
                        "website": "https://aura.radio",
                    },
                },
            )
        ],
        summary="List all settings.",
    ),
)
class APIRadioSettingsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
    queryset = RadioSettings.objects.all()
    serializer_class = RadioSettingsSerializer