# # 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 json import logging from datetime import date, datetime, time from rest_framework import mixins, permissions, status, viewsets from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response from django.contrib.auth.models import User from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.translation import gettext as _ from program import filters from program.models import ( Category, FundingCategory, Host, Language, MusicFocus, Note, Schedule, Show, TimeSlot, Topic, Type, ) from program.serializers import ( CategorySerializer, FundingCategorySerializer, HostSerializer, LanguageSerializer, MusicFocusSerializer, NoteSerializer, ScheduleSerializer, ShowSerializer, TimeSlotSerializer, TopicSerializer, TypeSerializer, UserSerializer, ) from program.utils import ( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, get_values, parse_date, ) logger = logging.getLogger(__name__) def json_day_schedule(request, year=None, month=None, day=None): if year is None and month is None and day is None: today = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0))) else: today = timezone.make_aware( datetime.combine(date(year, month, day), time(0, 0)) ) timeslots = ( TimeSlot.objects.get_24h_timeslots(today) .select_related("schedule") .select_related("show") ) schedule = [] for ts in timeslots: entry = { "start": ts.start.strftime("%Y-%m-%d_%H:%M:%S"), "end": ts.end.strftime("%Y-%m-%d_%H:%M:%S"), "title": ts.show.name, "id": ts.show.id, } schedule.append(entry) return HttpResponse( json.dumps(schedule, ensure_ascii=False).encode("utf8"), content_type="application/json; charset=utf-8", ) def json_playout(request): """ Called by - engine (playout) to retrieve timeslots within a given timerange Expects GET variables 'start' (date) and 'end' (date). If start not given, it will be today - internal calendar to retrieve all timeslots for a week Expects GET variable 'start' (date), otherwise start will be today If end not given, it returns all timeslots of the next 7 days """ if request.GET.get("start") is None: start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0))) else: start = timezone.make_aware( datetime.combine(parse_date(request.GET.get("start")), time(0, 0)) ) if request.GET.get("end") is None: # If no end was given, return the next week timeslots = ( TimeSlot.objects.get_7d_timeslots(start) .select_related("schedule") .select_related("show") ) else: # Otherwise return the given timerange end = timezone.make_aware( datetime.combine(parse_date(request.GET.get("end")), time(23, 59)) ) timeslots = ( TimeSlot.objects.get_timerange_timeslots(start, end) .select_related("schedule") .select_related("show") ) schedule = [] for ts in timeslots: is_repetition = " " + _("REP") if ts.schedule.is_repetition is True else "" hosts = ", ".join(ts.show.hosts.values_list("name", flat=True)) categories = ", ".join(ts.show.category.values_list("name", flat=True)) topics = ", ".join(ts.show.topic.values_list("name", flat=True)) music_focus = ", ".join(ts.show.music_focus.values_list("name", flat=True)) languages = ", ".join(ts.show.language.values_list("name", flat=True)) funding_category = ( FundingCategory.objects.get(pk=ts.show.funding_category_id) if ts.show.funding_category_id else None ) type_ = Type.objects.get(pk=ts.show.type_id) classname = "default" if ts.playlist_id is None or ts.playlist_id == 0: classname = "danger" entry = { "id": ts.id, "start": ts.start.strftime("%Y-%m-%dT%H:%M:%S"), "end": ts.end.strftime("%Y-%m-%dT%H:%M:%S"), "title": ts.show.name + is_repetition, # For JS Calendar "schedule_id": ts.schedule.id, "is_repetition": ts.is_repetition, "playlist_id": ts.playlist_id, "schedule_default_playlist_id": ts.schedule.default_playlist_id, "show_default_playlist_id": ts.show.default_playlist_id, "show_id": ts.show.id, "show_name": ts.show.name + is_repetition, "show_hosts": hosts, "show_type": type_.name, "show_categories": categories, "show_topics": topics, # TODO: replace `show_musicfocus` with `show_music_focus` when engine is updated "show_musicfocus": music_focus, "show_languages": languages, # TODO: replace `show_fundingcategory` with `show_funding_category` when engine is # updated "show_fundingcategory": funding_category.name, "memo": ts.memo, "className": classname, } schedule.append(entry) return HttpResponse( json.dumps(schedule, ensure_ascii=False).encode("utf8"), content_type="application/json; charset=utf-8", ) class APIUserViewSet( DisabledObjectPermissionCheckMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet, ): """ Returns a list of users. Only returns the user that is currently authenticated unless the user is a superuser. """ permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] serializer_class = UserSerializer queryset = User.objects.all() def get_queryset(self): queryset = super().get_queryset() # Constrain access to oneself except for superusers. if not self.request.user.is_superuser: queryset = queryset.filter(pk=self.request.user.id) return queryset def create(self, request, *args, **kwargs): """ Create a User. Only superusers may create users. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) serializer = UserSerializer(data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): """ Returns a list of available shows. Only superusers may add and delete shows. """ queryset = Show.objects.all() serializer_class = ShowSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] pagination_class = LimitOffsetPagination filterset_class = filters.ShowFilterSet 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): """ Create a show. Only superusers may create a show. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) serializer = ShowSerializer(data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def update(self, request, *args, **kwargs): """ Update a show. Common users may only update shows they own. """ pk = get_values(self.kwargs, "pk") if not request.user.is_superuser and pk not in request.user.shows.values_list( "id", flat=True ): return Response(status=status.HTTP_401_UNAUTHORIZED) show = self.get_object() serializer = ShowSerializer( show, data=request.data, context={"user": request.user} ) if serializer.is_valid(): # Common users mustn't edit the show's name if not request.user.is_superuser: serializer.validated_data["name"] = show.name serializer.save() return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): """ Delete a show. Only superusers may delete shows. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) self.get_object().delete() return Response(status=status.HTTP_204_NO_CONTENT) class APIScheduleViewSet( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, viewsets.ModelViewSet, ): """ Returns a list of schedules. Only superusers may create and update schedules. """ ROUTE_FILTER_LOOKUPS = { "show_pk": "show", } queryset = Schedule.objects.all() serializer_class = ScheduleSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] def create(self, request, *args, **kwargs): """ Create a schedule, generate timeslots, test for collisions and resolve them including notes Only superusers may add schedules. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) pk, show_pk = get_values(self.kwargs, "pk", "show_pk") # 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) # First create submit -> return projected timeslots and collisions # TODO: Perhaps directly insert into database if no conflicts found if "solutions" not in request.data: # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it return Response( Schedule.make_conflicts(request.data["schedule"], pk, show_pk), ) # Otherwise try to resolve resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) if all(key in resolution for key in ["create", "update", "delete"]): # this is a dry-run return Response(resolution, status=status.HTTP_202_ACCEPTED) # If resolution went well if "projected" not in resolution: return Response(resolution, status=status.HTTP_201_CREATED) # Otherwise return conflicts # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it return Response(resolution) def update(self, request, *args, **kwargs): """ Update a schedule, generate timeslots, test for collisions and resolve them including notes. Only superusers may update schedules. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) # 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() # 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) # First update submit -> return projected timeslots and collisions if "solutions" not in request.data: # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it return Response( Schedule.make_conflicts( request.data["schedule"], schedule.pk, schedule.show.pk ) ) # Otherwise try to resolve resolution = Schedule.resolve_conflicts( request.data, schedule.pk, schedule.show.pk ) # If resolution went well if "projected" not in resolution: return Response(resolution) # Otherwise return conflicts # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it return Response(resolution) def destroy(self, request, *args, **kwargs): """ Delete a schedule. Only superusers may delete schedules. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) self.get_object().delete() return Response(status=status.HTTP_204_NO_CONTENT) # 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. class APITimeSlotViewSet( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet, ): """ Returns a list of timeslots. By default, only timeslots ranging from now + 60 days will be displayed. You may override this default overriding start and/or end parameter. Timeslots may only be added by creating/updating a schedule. """ ROUTE_FILTER_LOOKUPS = { "show_pk": "show", "schedule_pk": "schedule", } permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] serializer_class = TimeSlotSerializer pagination_class = LimitOffsetPagination queryset = TimeSlot.objects.all().order_by("-start") filterset_class = filters.TimeSlotFilterSet def update(self, request, *args, **kwargs): """Link a playlist_id to a timeslot""" show_pk = get_values(self.kwargs, "show_pk") if ( not request.user.is_superuser and show_pk not in request.user.shows.values_lis("id", flat=True) ): return Response(status=status.HTTP_401_UNAUTHORIZED) timeslot = self.get_object() serializer = TimeSlotSerializer(timeslot, data=request.data) if serializer.is_valid(): 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 ts = TimeSlot.objects.filter(show=show_pk, start__gt=timeslot.start)[0] if ts.is_repetition: serializer = TimeSlotSerializer(ts) return Response(serializer.data) # ...or nothing if there isn't one return Response(status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): """ Deletes a timeslot. Only superusers may delete timeslots. """ if not request.user.is_superuser: return Response(status=status.HTTP_401_UNAUTHORIZED) self.get_object().delete() return Response(status=status.HTTP_204_NO_CONTENT) class APINoteViewSet( DisabledObjectPermissionCheckMixin, NestedObjectFinderMixin, viewsets.ModelViewSet, ): """ Returns a list of notes. Superusers may access and update all notes. """ ROUTE_FILTER_LOOKUPS = { "show_pk": "show", "timeslot_pk": "timeslot", } queryset = Note.objects.all() serializer_class = NoteSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] pagination_class = LimitOffsetPagination filter_class = filters.NoteFilterSet def create(self, request, *args, **kwargs): """ Only superusers can create new notes. """ show_pk, timeslot_pk = get_values(self.kwargs, "show_pk", "timeslot_pk") if ( not request.user.is_superuser and show_pk not in request.user.shows.values_list("id", flat=True) ): return Response(status=status.HTTP_401_UNAUTHORIZED) serializer = NoteSerializer( data={"show": show_pk, "timeslot": timeslot_pk} | request.data, context={"user_id": request.user.id}, ) if serializer.is_valid(): hosts = Host.objects.filter( shows__in=request.user.shows.values_list("id", flat=True) ) if not request.user.is_superuser and request.data["host"] not in hosts: serializer.validated_data["host"] = None serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def update(self, request, *args, **kwargs): """ Only superusers can update existing notes. """ show_pk = get_values(self.kwargs, "show_pk") if ( not request.user.is_superuser and show_pk not in request.user.shows.values_list("id", flat=True) ): return Response(status=status.HTTP_401_UNAUTHORIZED) note = self.get_object() serializer = NoteSerializer(note, data=request.data) if serializer.is_valid(): hosts = Host.objects.filter( shows__in=request.user.shows.values_list("id", flat=True) ) # Don't assign a host the user mustn't edit. Reassign the original value instead if not request.user.is_superuser and int(request.data["host"]) not in hosts: serializer.validated_data["host"] = Host.objects.filter( pk=note.host_id )[0] serializer.save() return Response(serializer.data) return Response(status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): """ Only superusers can delete existing notes. """ show_pk = get_values(self.kwargs, "show_pk") if ( not request.user.is_superuser and show_pk not in request.user.shows.values_list("id", flat=True) ): return Response(status=status.HTTP_401_UNAUTHORIZED) self.get_object().delete() return Response(status=status.HTTP_204_NO_CONTENT) class ActiveFilterMixin: filter_class = filters.ActiveFilterSet class APICategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of categories. """ queryset = Category.objects.all() serializer_class = CategorySerializer class APITypeViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of types. """ queryset = Type.objects.all() serializer_class = TypeSerializer class APITopicViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of topics. """ queryset = Topic.objects.all() serializer_class = TopicSerializer class APIMusicFocusViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of music focuses. """ queryset = MusicFocus.objects.all() serializer_class = MusicFocusSerializer class APIFundingCategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of funding categories. """ queryset = FundingCategory.objects.all() serializer_class = FundingCategorySerializer class APILanguageViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of languages. """ queryset = Language.objects.all() serializer_class = LanguageSerializer class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ Returns a list of hosts. """ queryset = Host.objects.all() serializer_class = HostSerializer pagination_class = LimitOffsetPagination