-
Ernesto Rico Schmidt authoredErnesto Rico Schmidt authored
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