diff --git a/program/filters.py b/program/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..e05e58d70c58d55ae4171ceb22055487f03bfe8d --- /dev/null +++ b/program/filters.py @@ -0,0 +1,236 @@ +import datetime + +from django_filters import rest_framework as filters +from django_filters import widgets + +from django import forms +from django.contrib.auth.models import User +from django.db.models import Q, QuerySet +from django.utils import timezone +from program import models + + +class StaticFilterHelpTextMixin: + @classmethod + def filter_for_field(cls, field, field_name, lookup_expr=None): + _filter = super().filter_for_field(field, field_name, lookup_expr) + if "help_text" not in _filter.extra: + help_texts = getattr(cls.Meta, "help_texts", {}) + _filter.extra["help_text"] = help_texts.get(field_name, "") + return _filter + + +class ModelMultipleChoiceFilter(filters.ModelMultipleChoiceFilter): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", widgets.CSVWidget()) + kwargs["lookup_expr"] = "in" + super().__init__(*args, **kwargs) + + def get_filter_predicate(self, v): + # There is something wrong with using ModelMultipleChoiceFilter + # along the CSVWidget that causes lookups to fail. + # May be related to: https://github.com/carltongibson/django-filter/issues/1103 + return super().get_filter_predicate([v.pk]) + + +class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): + active = filters.BooleanFilter( + field_name="is_active", + method="filter_active", + help_text=( + "Return only currently running shows if true or past or upcoming shows if false.", + ), + ) + host = ModelMultipleChoiceFilter( + queryset=models.Host.objects.all(), + field_name="hosts", + help_text="Return only shows assigned to the given host(s).", + ) + # TODO: replace `musicfocus` with `music_focus` when dashboard is updated + musicfocus = ModelMultipleChoiceFilter( + queryset=models.MusicFocus.objects.all(), + field_name="music_focus", + help_text="Return only shows with given music focus(es).", + ) + owner = ModelMultipleChoiceFilter( + queryset=User.objects.all(), + field_name="owners", + help_text="Return only shows that belong to the given owner(s).", + ) + category = ModelMultipleChoiceFilter( + queryset=models.Category.objects.all(), + help_text="Return only shows of the given category or categories.", + ) + language = ModelMultipleChoiceFilter( + queryset=models.Language.objects.all(), + help_text="Return only shows of the given language(s).", + ) + topic = ModelMultipleChoiceFilter( + queryset=models.Topic.objects.all(), + help_text="Return only shows of the given topic(s).", + ) + public = filters.BooleanFilter( + field_name="is_public", + help_text="Return only shows that are public/non-public.", + ) + + def filter_active(self, queryset: QuerySet, name: str, value: bool): + # Filter currently running shows + # Get currently running schedules to filter by first + # For single dates we test if there'll be one in the future (and ignore the until date) + # TODO: Really consider first_date? (=currently active, not just upcoming ones) + # Add limit for future? + show_ids = ( + models.Schedule.objects.filter( + Q( + rrule_id__gt=1, + first_date__lte=timezone.now(), + last_date__gte=timezone.now(), + ) + | Q(rrule_id=1, first_date__gte=timezone.now()) + ) + .distinct() + .values_list("show_id", flat=True) + ) + if value: + # Filter active shows based on timeslots as well as on the is_active flag + # Even if there are future timeslots but is_active=True the show will be considered as + # inactive + return queryset.filter(id__in=show_ids, is_active=True) + else: + return queryset.exclude(id__in=show_ids, is_active=True) + + class Meta: + model = models.Show + help_texts = { + "type": "Return only shows of a given type.", + } + fields = [ + "active", + "category", + "host", + "language", + "musicfocus", + "owner", + "public", + "topic", + "type", + ] + + +class TimeSlotFilterSet(filters.FilterSet): + order = filters.OrderingFilter( + fields=[field.name for field in models.TimeSlot._meta.get_fields()] + ) + surrounding = filters.DateTimeFilter( + method="filter_surrounding", + label="Return surrounding timeslots", + help_text=( + "Returns the 10 nearest timeslots around the specified datetime. " + "If specified without a datetime value the current date and time is assumed." + ), + ) + # The start/end filters will always be applied even if no query parameter has been set. + # This is because we enforce a value in the clean_start and clean_end methods + # of the filterset form. + start = filters.DateFilter( + method="filter_start", + help_text=( + "Only returns timeslots after that start on or after the specified date. " + "By default, this is set to the current date." + ), + ) + end = filters.DateFilter( + method="filter_end", + help_text=( + "Only returns timeslots that end on or before the specified date. " + "By default, this is set to value of the start filter + 60 days." + ), + ) + + def filter_surrounding( + self, queryset: QuerySet, name: str, value: datetime.datetime + ): + nearest_timeslots_in_future = ( + models.TimeSlot.objects.filter(start__gte=value) + .order_by("start") + .values_list("id", flat=True)[:5] + ) + nearest_timeslots_in_past = ( + models.TimeSlot.objects.filter(start__lt=value) + .order_by("-start") + .values_list("id", flat=True)[:5] + ) + relevant_timeslot_ids = list(nearest_timeslots_in_future) + list( + nearest_timeslots_in_past + ) + return queryset.filter(id__in=relevant_timeslot_ids) + + def filter_start(self, queryset: QuerySet, name: str, value: datetime.date): + start = timezone.make_aware(datetime.datetime.combine(value, datetime.time.min)) + return queryset.filter(start__gte=start) + + def filter_end(self, queryset: QuerySet, name: str, value: datetime.date): + end = timezone.make_aware(datetime.datetime.combine(value, datetime.time.max)) + return queryset.filter(end__lte=end) + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + # This is for backwards compatibility as the surrounding-filter was formerly implemented + # by just checking for the existence of the query parameter. + if self.request.GET.get("surrounding", None) == "": + queryset = self.filter_surrounding(queryset, "surrounding", timezone.now()) + return queryset + + class Meta: + model = models.TimeSlot + fields = [ + "order", + "start", + "end", + "surrounding", + ] + + class form(forms.Form): + def clean_start(self): + start = self.cleaned_data.get("start", None) + return start or timezone.now().date() + + def clean_end(self): + end = self.cleaned_data.get("end", None) + return end or self.cleaned_data["start"] + datetime.timedelta(days=60) + + +class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): + ids = ModelMultipleChoiceFilter( + field_name="id", + queryset=models.Note.objects.all(), + help_text="Return only notes matching the specified id(s).", + ) + owner = ModelMultipleChoiceFilter( + field_name="show__owners", + queryset=models.User.objects.all(), + help_text="Return only notes by show the specified owner(s): all notes the user may edit.", + ) + + class Meta: + model = models.Note + help_texts = { + "host": "Return only notes from the specified host.", + "user": "Return only notes created by the specified user.", + } + fields = [ + "host", + "ids", + "owner", + "user", + ] + + +class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): + active = filters.BooleanFilter(field_name="is_active") + + class Meta: + fields = [ + "active", + ] diff --git a/program/models.py b/program/models.py index 511ba383f467225930dd58bdf2eb672cff2cda8a..6a4762df388c594a6d4534e0e6e966b12234588f 100644 --- a/program/models.py +++ b/program/models.py @@ -47,6 +47,9 @@ class Type(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + class Category(models.Model): name = models.CharField(max_length=32) @@ -58,6 +61,9 @@ class Category(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + class Topic(models.Model): name = models.CharField(max_length=32) @@ -68,6 +74,9 @@ class Topic(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + class MusicFocus(models.Model): name = models.CharField(max_length=32) @@ -78,6 +87,9 @@ class MusicFocus(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + class FundingCategory(models.Model): name = models.CharField(max_length=32) @@ -88,6 +100,9 @@ class FundingCategory(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + class Language(models.Model): name = models.CharField(max_length=32) @@ -96,6 +111,9 @@ class Language(models.Model): class Meta: ordering = ("language",) + def __str__(self): + return self.name + class Host(models.Model): name = models.CharField(max_length=128) @@ -118,6 +136,9 @@ class Host(models.Model): class Meta: ordering = ("name",) + def __str__(self): + return self.name + def save(self, *args, **kwargs): super(Host, self).save(*args, **kwargs) @@ -132,6 +153,9 @@ class Link(models.Model): description = models.CharField(max_length=8) url = models.URLField() + def __str__(self): + return self.url + class Show(models.Model): predecessor = models.ForeignKey( @@ -181,6 +205,9 @@ class Show(models.Model): class Meta: ordering = ("slug",) + def __str__(self): + return self.name + class RRule(models.Model): name = models.CharField(max_length=32, unique=True) @@ -192,6 +219,9 @@ class RRule(models.Model): class Meta: ordering = ("pk",) + def __str__(self): + return self.name + class Schedule(models.Model): rrule = models.ForeignKey(RRule, on_delete=models.CASCADE, related_name="schedules") @@ -979,6 +1009,20 @@ class TimeSlot(models.Model): class Meta: ordering = ("start", "end") + def __str__(self): + if self.start.date() == self.end.date(): + time_span = "{0}, {1} - {2}".format( + self.start.strftime("%x"), + self.start.strftime("%X"), + self.end.strftime("%X"), + ) + else: + time_span = "{0} - {1}".format( + self.start.strftime("%X %x"), + self.end.strftime("%X %x"), + ) + return f"{str(self.show)} ({time_span})" + def save(self, *args, **kwargs): self.show = self.schedule.show super(TimeSlot, self).save(*args, **kwargs) @@ -1029,6 +1073,9 @@ class Note(models.Model): class Meta: ordering = ("timeslot",) + def __str__(self): + return self.title + def save(self, *args, **kwargs): self.start = self.timeslot.start self.show = self.timeslot.schedule.show diff --git a/program/serializers.py b/program/serializers.py index a8b85bba2bc58648d83b35973addd2bcbf01f426..7e9e8af40a3adee5c4a6bb2a12811d2c7fbe00d7 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -453,6 +453,7 @@ class NoteSerializer(serializers.ModelSerializer): timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all()) host = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all()) thumbnails = serializers.SerializerMethodField() # Read-only + cba_id = serializers.IntegerField(required=False, write_only=True) @staticmethod def get_thumbnails(note): @@ -476,7 +477,7 @@ class NoteSerializer(serializers.ModelSerializer): validated_data["user_id"] = self.context["user_id"] # Try to retrieve audio URL from CBA - validated_data["audio_url"] = get_audio_url(validated_data["cba_id"]) + validated_data["audio_url"] = get_audio_url(validated_data.get("cba_id", None)) note = Note.objects.create(**validated_data) diff --git a/program/utils.py b/program/utils.py index 6d30c2079d584dbf794f9ac5eff8430bb40df0c5..53b6420d1b95bc99e98df51ac8e905e0d073eeff 100644 --- a/program/utils.py +++ b/program/utils.py @@ -18,6 +18,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # +import json from datetime import date, datetime, time from typing import Dict, Optional, Tuple, Union @@ -57,7 +58,7 @@ def parse_time(date_string: str) -> time: return datetime.strptime(date_string, "%H:%M:%S").time() -def get_audio_url(cba_id): +def get_audio_url(cba_id: Optional[int]) -> str: """ Retrieve the direct URL to the mp3 in CBA In order to retrieve the URL, stations need @@ -67,8 +68,8 @@ def get_audio_url(cba_id): For these contact cba@fro.at """ - if cba_id is None or cba_id == "" or CBA_API_KEY == "": - return None + if not cba_id or CBA_API_KEY == "": + return "" else: if DEBUG: url = ( @@ -85,9 +86,11 @@ def get_audio_url(cba_id): + CBA_API_KEY ) - audio_url = requests.get(url).json() - - return audio_url + try: + return requests.get(url).json() + except (requests.RequestException, json.JSONDecodeError): + # TODO: we might want to add some logging + return "" def get_values( diff --git a/program/views.py b/program/views.py index e2ec555c5a62aadc28e916226360b2c4215a8644..51f95b55d2c2e7426e0aecc4ee27cdbae12f93b8 100644 --- a/program/views.py +++ b/program/views.py @@ -20,18 +20,18 @@ import json import logging -from datetime import date, datetime, time, timedelta +from datetime import date, datetime, time -from rest_framework import permissions, status, viewsets +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.db.models import Q 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, @@ -187,28 +187,34 @@ def json_playout(request): ) -class APIUserViewSet(viewsets.ModelViewSet): +class APIUserViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): """ - /users returns oneself. Superusers see all users. Only superusers may create a user (GET, POST) - /users/{pk} retrieves or updates a single user. Non-superusers may only update certain fields - (GET, PUT) + Returns a list of users. - Superusers may access and update all 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.none() + queryset = User.objects.all() def get_queryset(self): - """Constrain access to oneself except for superusers""" - if self.request.user.is_superuser: - return User.objects.all() + queryset = super().get_queryset() - return User.objects.filter(pk=self.request.user.id) + # 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 retrieve(self, request, *args, **kwargs): - """Returns a single user""" + """Returns a single user.""" pk = get_values(self.kwargs, "pk") # Common users only see themselves @@ -221,8 +227,9 @@ class APIUserViewSet(viewsets.ModelViewSet): def create(self, request, *args, **kwargs): """ - Create a User - Only superusers may create a user + Create a User. + + Only superusers may create users. """ if not request.user.is_superuser: @@ -237,6 +244,11 @@ class APIUserViewSet(viewsets.ModelViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def update(self, request, *args, **kwargs): + """ + Updates the user’s data. + + Non-superusers may not be able to edit all of the available data. + """ pk = get_values(self.kwargs, "pk") serializer = UserSerializer(data=request.data) @@ -257,113 +269,25 @@ class APIUserViewSet(viewsets.ModelViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, *args, **kwargs): - """Deleting users is prohibited: Set 'is_active' to False instead""" - return Response(status=status.HTTP_400_BAD_REQUEST) - class APIShowViewSet(viewsets.ModelViewSet): """ - /shows/ returns all shows (GET, POST) - /shows/?active=true returns all active shows (= currently running) (GET) - /shows/?active=false returns all inactive shows (= past or upcoming) (GET) - /shows/?public=true returns all public shows (GET) - /shows/?public=false returns all non-public shows (GET) - /shows/?host={host_pk} returns shows assigned to a given host (GET) - /shows/?owner={owner_pk} returns shows of a given owner (GET) - /shows/?language={language_pk} returns shows in a given language (GET) - /shows/?type={type_pk} returns shows of a given type (GET) - /shows/?category={category_pk} returns shows of a given category (GET) - /shows/?topic={topic_pk} returns shows of a given topic (GET) - /shows/?musicfocus={musicfocus_pk} returns shows of a given music focus (GET) - /shows/{pk|slug} retrieves or updates (if owned) a single show (GET, PUT). - - Only superusers may add and delete shows + Returns a list of available shows. + + Only superusers may add and delete shows. """ - queryset = Show.objects.none() + queryset = Show.objects.all() serializer_class = ShowSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] pagination_class = LimitOffsetPagination - - def get_queryset(self): - shows = Show.objects.all() - - # Filters - if ( - self.request.query_params.get("active") == "true" - or self.request.query_params.get("active") == "false" - ): - # Filter currently running shows - # Get currently running schedules to filter by first - # For single dates we test if there'll be one in the future (and ignore the until date) - # TODO: Really consider first_date? (=currently active, not just upcoming ones) - # Add limit for future? - show_ids = ( - Schedule.objects.filter( - Q( - rrule_id__gt=1, - first_date__lte=timezone.now(), - last_date__gte=timezone.now(), - ) - | Q(rrule_id=1, first_date__gte=timezone.now()) - ) - .distinct() - .values_list("show_id", flat=True) - ) - - # Filter active shows based on timeslots as well as on the is_active flag - # Even if there are future timeslots but is_active=True the show will be considered as - # inactive - shows = Show.objects.filter(id__in=show_ids, is_active=True) - - if self.request.query_params.get("active") == "false": - # Return all shows except those which are running - shows = Show.objects.exclude(id__in=show_ids, is_active=True) - - if self.request.query_params.get("public") == "true": - # Return all public shows - shows = shows.filter(is_public=True) - - if self.request.query_params.get("public") == "false": - # Return all public shows - shows = shows.filter(is_public=False) - - if owner := self.request.query_params.get("owner"): - if owner != "undefined": - shows = shows.filter(owners__in=[int(owner)]) - - if host := self.request.query_params.get("host"): - if host != "undefined": - shows = shows.filter(hosts__in=[int(host)]) - - if language := self.request.query_params.get("language"): - if language != "undefined": - shows = shows.filter(language__in=[int(language)]) - - if type_ := self.request.query_params.get("type"): - if type_ != "undefined": - shows = shows.filter(type__in=[int(type_)]) - - if category := self.request.query_params.get("category"): - if category != "undefined": - shows = shows.filter(category__in=[int(category)]) - - if topic := self.request.query_params.get("topic"): - if topic != "undefined": - shows = shows.filter(topic__in=[int(topic)]) - - # TODO: replace `musicfocus` with `music_focus` when dashboard is updated - if music_focus := self.request.query_params.get("musicfocus"): - if music_focus != "undefined": - shows = shows.filter(music_focus__in=[int(music_focus)]) - - return shows + filterset_class = filters.ShowFilterSet def create(self, request, *args, **kwargs): """ - Create a show - Only superusers may create a show + Create a show. + + Only superusers may create a show. """ if not request.user.is_superuser: @@ -396,8 +320,9 @@ class APIShowViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """ - Update a show - Common users may only update shows they own + Update a show. + + Common users may only update shows they own. """ pk = get_values(self.kwargs, "pk") @@ -423,8 +348,9 @@ class APIShowViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """ - Delete a show - Only superusers may delete shows + Delete a show. + + Only superusers may delete shows. """ if not request.user.is_superuser: @@ -439,25 +365,24 @@ class APIShowViewSet(viewsets.ModelViewSet): class APIScheduleViewSet(viewsets.ModelViewSet): """ - /schedules/ returns all schedules (GET) - /schedules/{pk} returns the given schedule (GET) - /shows/{show_pk}/schedules returns schedules of the show (GET, POST) - /shows/{show_pk}/schedules/{pk} returns schedules by its ID (GET, PUT, DELETE) + Returns a list of schedules. - Only superusers may create and update schedules + Only superusers may create and update schedules. """ - queryset = Schedule.objects.none() + queryset = Schedule.objects.all() serializer_class = ScheduleSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] def get_queryset(self): - show_pk = get_values(self.kwargs, "show_pk") + queryset = super().get_queryset() + # subroute filters + show_pk = get_values(self.kwargs, "show_pk") if show_pk: - return Schedule.objects.filter(show=show_pk) + queryset = queryset.filter(show=show_pk) - return Schedule.objects.all() + return queryset def retrieve(self, request, *args, **kwargs): pk, show_pk = get_values(self.kwargs, "pk", "show_pk") @@ -476,8 +401,7 @@ class APIScheduleViewSet(viewsets.ModelViewSet): """ Create a schedule, generate timeslots, test for collisions and resolve them including notes - Only superusers may add schedules - TODO: Perhaps directly insert into database if no conflicts found + Only superusers may add schedules. """ if not request.user.is_superuser: @@ -491,6 +415,7 @@ class APIScheduleViewSet(viewsets.ModelViewSet): 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: return Response( Schedule.make_conflicts(request.data["schedule"], pk, show_pk), @@ -509,9 +434,10 @@ class APIScheduleViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """ - Update a schedule, generate timeslots, test for collisions and resolve them including notes + Update a schedule, generate timeslots, test for collisions and resolve + them including notes. - Only superusers may update schedules + Only superusers may update schedules. """ if not request.user.is_superuser: @@ -562,8 +488,9 @@ class APIScheduleViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """ - Delete a schedule - Only superusers may delete schedules + Delete a schedule. + + Only superusers may delete schedules. """ if not request.user.is_superuser: @@ -580,112 +507,42 @@ class APIScheduleViewSet(viewsets.ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class APITimeSlotViewSet(viewsets.ModelViewSet): +# 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( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): """ - /timeslots returns timeslots of the next 60 days (GET). Timeslots may only be added by - creating/updating a schedule - /timeslots/{pk} eturns the given timeslot (GET) - /timeslots/?start={start_date}&end={end_date} returns timeslots within the time range (GET) - /shows/{show_pk}/timeslots returns timeslots of the show (GET, POST) - /shows/{show_pk}/timeslots?surrounding returns the 10 nearest timeslots for the current date - (GET) - /shows/{show_pk}/timeslots/{pk} returns a timeslots by its ID (GET, PUT, DELETE) - /shows/{show_pk}/timeslots/?start={start_date}&end={end_date} returns timeslots of the show - within the time range - /shows/{show_pk}/schedules/{schedule_pk}/timeslots returns all timeslots of the schedule (GET, - POST) - /shows/{show_pk}/schedules/{schedule_pk}/timeslots/{pk} returns a timeslot by its ID (GET, - DELETE). If PUT, the next repetition is returned or nothing if the next timeslot isn't one - /shows/{show_pk}/schedules/{schedule_pk}/timeslots?start={start_date}&end={end_date} returns - all timeslots of the schedule within the time range + 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. """ permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] serializer_class = TimeSlotSerializer pagination_class = LimitOffsetPagination - queryset = TimeSlot.objects.none() + queryset = TimeSlot.objects.all().order_by("-start") + filterset_class = filters.TimeSlotFilterSet def get_queryset(self): - show_pk, schedule_pk = get_values(self.kwargs, "show_pk", "schedule_pk") - # Filters - - # Return next 60 days by default - start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0))) - end = start + timedelta(days=60) - - if ("start" in self.request.query_params) and ( - "end" in self.request.query_params - ): - start = timezone.make_aware( - datetime.combine( - parse_date(self.request.query_params.get("start")), time(0, 0) - ) - ) - end = timezone.make_aware( - datetime.combine( - parse_date(self.request.query_params.get("end")), time(23, 59) - ) - ) - - default_order = "-start" - order = self.request.query_params.get("order", default_order) - - # If someone tries to sort by a field that isn't available on the model - # we silently ignore that and use the default sort order. - model_fields = [field.name for field in TimeSlot._meta.get_fields()] - if order.lstrip("-") not in model_fields: - order = default_order - - if "surrounding" in self.request.query_params: - now = timezone.now() - - nearest_timeslots_in_future = ( - TimeSlot.objects.filter(start__gte=now) - .order_by("start") - .values_list("id", flat=True)[:5] - ) - nearest_timeslots_in_past = ( - TimeSlot.objects.filter(start__lt=now) - .order_by("-start") - .values_list("id", flat=True)[:5] - ) - relevant_timeslot_ids = list(nearest_timeslots_in_future) + list( - nearest_timeslots_in_past - ) - - return TimeSlot.objects.filter(id__in=relevant_timeslot_ids).order_by(order) - - # Endpoints - - # - # /shows/1/schedules/1/timeslots/ - # - # Returns timeslots of the given show and schedule - # - if show_pk and schedule_pk: - return TimeSlot.objects.filter( - show=show_pk, schedule=schedule_pk, start__gte=start, end__lte=end - ).order_by(order) + queryset = super().get_queryset() - # - # /shows/1/timeslots/ - # - # Returns timeslots of the show - # - elif show_pk and schedule_pk is None: - return TimeSlot.objects.filter( - show=show_pk, start__gte=start, end__lte=end - ).order_by(order) + # subroute filters + show_pk, schedule_pk = get_values(self.kwargs, "show_pk", "schedule_pk") + if show_pk: + queryset = queryset.filter(show=show_pk) + if schedule_pk: + queryset = queryset.filter(schedule=schedule_pk) - # - # /timeslots/ - # - # Returns all timeslots - # - else: - return TimeSlot.objects.filter(start__gte=start, end__lte=end).order_by( - order - ) + return queryset def retrieve(self, request, *args, **kwargs): pk, show_pk = get_values(self.kwargs, "pk", "show_pk") @@ -698,13 +555,6 @@ class APITimeSlotViewSet(viewsets.ModelViewSet): serializer = TimeSlotSerializer(timeslot) return Response(serializer.data) - def create(self, request, *args, **kwargs): - """ - Timeslots may only be created by adding/updating schedules - TODO: Adding single timeslot which fits to schedule? - """ - return Response(status=status.HTTP_400_BAD_REQUEST) - def update(self, request, *args, **kwargs): """Link a playlist_id to a timeslot""" @@ -746,8 +596,9 @@ class APITimeSlotViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """ - Delete a timeslot - Only superusers may delete timeslots + Deletes a timeslot. + + Only superusers may delete timeslots. """ if not request.user.is_superuser: @@ -766,86 +617,34 @@ class APITimeSlotViewSet(viewsets.ModelViewSet): class APINoteViewSet(viewsets.ModelViewSet): """ - /notes/ returns all notes (GET) - /notes/{pk} returns a single note (if owned) (GET) - /notes/?ids={...} returns given notes (if owned) (GET) - /notes/?host={host} returns notes assigned to a given host (GET) - /notes/?owner={owner} returns notes editable by a given user (GET) - /notes/?user={user} returns notes created by a given user (GET) - /shows/{show_pk}/notes returns all notes of a show (GET) - /shows/{show_pk}/notes/{pk} returns a note by its ID (GET) - /shows/{show_pk}/timeslots/{timeslot_pk}/note/ returns a note of the timeslot (GET) - /shows/{show_pk}/timeslots/{timeslot_pk}/note/{pk} returns a note by its ID (GET) - /shows/{show_pk}/schedules/{schedule_pk}/timeslots/{timeslot_pk}/note returns a note to the - timeslot (GET, POST). - /shows/{show_pk}/schedules/{schedule_pk}/timeslots/{timeslot_pk}/note/{pk} returns a note by - its ID (GET, PUT, DELETE) - - Superusers may access and update all notes + Returns a list of notes. + + Superusers may access and update all notes. """ - queryset = Note.objects.none() + queryset = Note.objects.all() serializer_class = NoteSerializer permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] pagination_class = LimitOffsetPagination + filter_class = filters.NoteFilterSet def get_queryset(self): - timeslot_pk, show_pk = get_values(self.kwargs, "timeslot_pk", "show_pk") + queryset = super().get_queryset() - # Endpoints - - # - # /shows/1/schedules/1/timeslots/1/note - # /shows/1/timeslots/1/note - # - # Return a note to the timeslot - # - if show_pk and timeslot_pk: - notes = Note.objects.filter(show=show_pk, timeslot=timeslot_pk) - - # - # /shows/1/notes - # - # Returns notes to the show - # - elif show_pk and timeslot_pk is None: - notes = Note.objects.filter(show=show_pk) - - # - # /notes - # - # Returns all notes - # - else: - notes = Note.objects.all() - - # Filters - - if ids := self.request.query_params.get("ids"): - # Filter notes by their IDs - note_ids = list(map(int, ids.split(","))) - notes = notes.filter(id__in=note_ids) - - if host := self.request.query_params.get("host"): - # Filter notes by host - notes = notes.filter(host=int(host)) - - if owner := self.request.query_params.get("owner"): - # Filter notes by show owner: all notes the user may edit - shows = Show.objects.filter(owners=int(owner)) - notes = notes.filter(show__in=shows) - - if user := self.request.query_params.get("user"): - # Filter notes by their creator - notes = notes.filter(user=int(user)) + # subroute filters + show_pk, timeslot_pk = get_values(self.kwargs, "show_pk", "timeslot_pk") + if show_pk: + queryset = queryset.filter(show=show_pk) + if timeslot_pk: + queryset = queryset.filter(timeslot=timeslot_pk) - return notes + return queryset def create(self, request, *args, **kwargs): """Create a note""" show_pk, schedule_pk, timeslot_pk = get_values( - self.kwargs, "show_pk", "schedule_pk", "timelost_pk" + self.kwargs, "show_pk", "schedule_pk", "timeslot_pk" ) if ( @@ -859,7 +658,8 @@ class APINoteViewSet(viewsets.ModelViewSet): return Response(status=status.HTTP_400_BAD_REQUEST) serializer = NoteSerializer( - data=request.data, context={"user_id": request.user.id} + data={"show": show_pk, "timeslot": timeslot_pk} | request.data, + context={"user_id": request.user.id}, ) if serializer.is_valid(): @@ -970,99 +770,67 @@ class APINoteViewSet(viewsets.ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class ActiveInactiveViewSet(viewsets.ModelViewSet): - permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] - - def get_queryset(self: viewsets.ModelViewSet): - """Filters""" - - if self.request.query_params.get("active") == "true": - return self.queryset.model.objects.filter(is_active=True) - - if self.request.query_params.get("active") == "false": - return self.queryset.model.objects.filter(is_active=False) - - return self.queryset.model.objects.all() +class ActiveFilterMixin: + filter_class = filters.ActiveFilterSet -class APICategoryViewSet(ActiveInactiveViewSet): +class APICategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /categories/ returns all categories (GET, POST) - /categories/?active=true returns all active categories (GET) - /categories/?active=false returns all inactive categories (GET) - /categories/{pk} Returns a category by its ID (GET, PUT, DELETE) + Returns a list of categories. """ queryset = Category.objects.all() serializer_class = CategorySerializer -class APITypeViewSet(ActiveInactiveViewSet): +class APITypeViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /types/ returns all types (GET, POST) - /types/?active=true returns all active types (GET) - /types/?active=false returns all inactive types (GET) - /types/{pk} returns a type by its ID (GET, PUT, DELETE) + Returns a list of types. """ queryset = Type.objects.all() serializer_class = TypeSerializer -class APITopicViewSet(ActiveInactiveViewSet): +class APITopicViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /topics/: Returns all topics (GET, POST) - /topics/?active=true Returns all active topics (GET) - /topics/?active=false Returns all inactive topics (GET) - /topics/{pk}: Returns a topic by its ID (GET, PUT, DELETE) + Returns a list of topics. """ queryset = Topic.objects.all() serializer_class = TopicSerializer -class APIMusicFocusViewSet(ActiveInactiveViewSet): +class APIMusicFocusViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /musicfocus/ returns all music focuses (GET, POST) - /musicfocus/?active=true: returns all active music focuses (GET) - /musicfocus/?active=false: returns all inactive music focuses (GET) - /musicfocus/{pk}: returns a music focus by its ID (GET, PUT, DELETE) + Returns a list of music focuses. """ queryset = MusicFocus.objects.all() serializer_class = MusicFocusSerializer -class APIFundingCategoryViewSet(ActiveInactiveViewSet): +class APIFundingCategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /fundingcategories/: returns all funding categories (GET, POST) - /fundingcategories/?active=true returns all active funding categories (GET) - /fundingcategories/?active=false returns all inactive funding categories (GET) - /fundingcategories/{pk} returns a funding category by its ID (GET, PUT, DELETE) + Returns a list of funding categories. """ queryset = FundingCategory.objects.all() serializer_class = FundingCategorySerializer -class APILanguageViewSet(ActiveInactiveViewSet): +class APILanguageViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /languages/ returns all languages (GET, POST) - /languages/?active=true returns all active languages (GET) - /languages/?active=false returns all inactive languages (GET) - /languages/{pk} returns a language by its ID (GET, PUT, DELETE) + Returns a list of languages. """ queryset = Language.objects.all() serializer_class = LanguageSerializer -class APIHostViewSet(ActiveInactiveViewSet): +class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet): """ - /hosts/ returns all hosts (GET, POST) - /hosts/?active=true returns all active hosts (GET) - /hosts/?active=false returns all inactive hosts (GET) - /hosts/{pk} returns a host by its ID (GET, PUT, DELETE) + Returns a list of hosts. """ queryset = Host.objects.all() diff --git a/requirements.txt b/requirements.txt index dddc32ab81e92d44d75f26c07a5f0236a4311e9c..8ef0a542428224ef5041843867732511557ee527 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ charset-normalizer==2.0.12 Django==3.2.12 django-cors-headers==3.11.0 django-environ==0.8.1 +django-filter==21.1 django-oidc-provider==0.7.0 django-versatileimagefield==2.2 djangorestframework==3.13.1 diff --git a/steering/settings.py b/steering/settings.py index 0bf1ecb629b30eb235ae28949f780cb37e2e6a81..16eb0590c4253180e16d823442f84b957627526e 100644 --- a/steering/settings.py +++ b/steering/settings.py @@ -108,6 +108,7 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "program.auth.OidcOauth2Auth", ], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } INSTALLED_APPS = ( @@ -123,6 +124,7 @@ INSTALLED_APPS = ( "versatileimagefield", "rest_framework", "rest_framework_nested", + "django_filters", "oidc_provider", "corsheaders", )