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",
 )