diff --git a/.gitignore b/.gitignore
index 69da0bf513c4e8ebdb04cd38baa98e3ca5d75a58..76aa668227b7fddd47fc0d70d92af05afc97820c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,6 @@ db.sqlite3
 .mypy_cache
 *.pyc
 .pytest_cache
+.cache/
 static/
 steering_data_model.png
diff --git a/fixtures/program/show.json b/fixtures/program/show.json
index 429f422c32df80a2cef1a002465abb0c0d33b8b8..597026872db92c22c309727ae0f7671cf6eccacc 100644
--- a/fixtures/program/show.json
+++ b/fixtures/program/show.json
@@ -9,7 +9,7 @@
       "name": "Musikprogramm",
       "slug": "musikprogramm",
       "image": null,
-      "logo": "",
+      "logo": null,
       "short_description": "Unmoderiertes Musikprogramm",
       "description": "Unmoderiertes Musikprogramm",
       "email": "musikredaktion@helsinki.at",
diff --git a/poetry.lock b/poetry.lock
index 5432cf2b013dbcc6a69dc4f234854858596ae538..09f26e2d25aa9611e4006fe7f2b8f7e92af46b78 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -402,18 +402,18 @@ test = ["pytest (>=6)"]
 
 [[package]]
 name = "filelock"
-version = "3.10.7"
+version = "3.11.0"
 description = "A platform independent file lock."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "filelock-3.10.7-py3-none-any.whl", hash = "sha256:bde48477b15fde2c7e5a0713cbe72721cb5a5ad32ee0b8f419907960b9d75536"},
-    {file = "filelock-3.10.7.tar.gz", hash = "sha256:892be14aa8efc01673b5ed6589dbccb95f9a8596f0507e232626155495c18105"},
+    {file = "filelock-3.11.0-py3-none-any.whl", hash = "sha256:f08a52314748335c6460fc8fe40cd5638b85001225db78c2aa01c8c0db83b318"},
+    {file = "filelock-3.11.0.tar.gz", hash = "sha256:3618c0da67adcc0506b015fd11ef7faf1b493f0b40d87728e19986b536890c37"},
 ]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
+docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
 testing = ["covdefaults (>=2.3)", "coverage (>=7.2.2)", "diff-cover (>=7.5)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"]
 
 [[package]]
diff --git a/program/filters.py b/program/filters.py
index e263f095a9e821439cf82da9524a7158a9b122da..298111c8b04302ad5ba4ca41981af0b80e6e563f 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -231,12 +231,20 @@ class TimeSlotFilterSet(filters.FilterSet):
 
 
 class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
+    show = IntegerInFilter(
+        field_name="timeslot__show",
+        help_text="Return only notes that belong to the specified show(s).",
+    )
+    timeslot = IntegerInFilter(
+        field_name="timeslot",
+        help_text="Return only notes that belong to the specified timeslot(s).",
+    )
     ids = IntegerInFilter(
         field_name="id",
         help_text="Return only notes matching the specified id(s).",
     )
     show_owner = IntegerInFilter(
-        field_name="show__owners",
+        field_name="timeslot__show__owners",
         help_text="Return only notes by show the specified owner(s): all notes the user may edit.",
     )
 
@@ -245,7 +253,7 @@ class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
         help_texts = {
             "owner": "Return only notes created by the specified user.",
         }
-        fields = ["ids", "owner", "show_owner"]
+        fields = ["ids", "owner", "show", "timeslot", "show_owner"]
 
 
 class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
diff --git a/program/migrations/0051_remove_show_logo.py b/program/migrations/0051_remove_show_logo.py
new file mode 100644
index 0000000000000000000000000000000000000000..9130f374d3cd8df948d146b08e325c698d3befe2
--- /dev/null
+++ b/program/migrations/0051_remove_show_logo.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.18 on 2023-04-11 15:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('program', '0050_auto_20230404_0037'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='show',
+            name='logo',
+        ),
+    ]
diff --git a/program/migrations/0052_show_logo.py b/program/migrations/0052_show_logo.py
new file mode 100644
index 0000000000000000000000000000000000000000..ba7a4c67358e11a8b341cc714d325a4bba3bbf4d
--- /dev/null
+++ b/program/migrations/0052_show_logo.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.18 on 2023-04-11 15:46
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('program', '0051_remove_show_logo'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='show',
+            name='logo',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='logo_shows', to='program.image'),
+        ),
+    ]
diff --git a/program/migrations/0053_auto_20230411_1855.py b/program/migrations/0053_auto_20230411_1855.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a9ad1de44ffd19cfb123d0efc4f3fb844229e6e
--- /dev/null
+++ b/program/migrations/0053_auto_20230411_1855.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.18 on 2023-04-11 16:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('program', '0052_show_logo'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='image',
+            name='alt_text',
+            field=models.TextField(blank=True, default=''),
+        ),
+        migrations.AlterField(
+            model_name='image',
+            name='credits',
+            field=models.TextField(blank=True, default=''),
+        ),
+    ]
diff --git a/program/models.py b/program/models.py
index 2fb10313d894dc8f6f72ce4c27b76f87411ba168..ae93101d1f2430574e71ee2052f04fdc2e23e651 100644
--- a/program/models.py
+++ b/program/models.py
@@ -131,8 +131,8 @@ class Language(models.Model):
 
 
 class Image(models.Model):
-    alt_text = models.TextField(blank=True, null=True)
-    credits = models.TextField(blank=True, null=True)
+    alt_text = models.TextField(blank=True, default="")
+    credits = models.TextField(blank=True, default="")
     height = models.PositiveIntegerField(blank=True, null=True, editable=False)
     image = VersatileImageField(
         blank=True,
@@ -230,7 +230,14 @@ class Show(ModelWithCreatedUpdatedFields):
     is_active = models.BooleanField(default=True)
     is_public = models.BooleanField(default=False)
     language = models.ManyToManyField(Language, blank=True, related_name="shows")
-    logo = models.ImageField(blank=True, null=True, upload_to="show_images")
+    # TODO: is this really necessary?
+    logo = models.ForeignKey(
+        Image,
+        blank=True,
+        null=True,
+        on_delete=models.CASCADE,
+        related_name="logo_shows",
+    )
     music_focus = models.ManyToManyField(MusicFocus, blank=True, related_name="shows")
     name = models.CharField(max_length=255)
     owners = models.ManyToManyField(User, blank=True, related_name="shows")
diff --git a/program/serializers.py b/program/serializers.py
index af0ee1d812b8e92727b7f15c4a8982e0aae47842..9336939785ddcb67ada771c3f9a93da710f666c0 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -18,7 +18,8 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
-from typing import List
+import re
+from typing import List, TypedDict
 
 from rest_framework import serializers
 
@@ -197,17 +198,47 @@ class HostLinkSerializer(serializers.ModelSerializer):
         fields = ("type", "url")
 
 
+class PPOIField(serializers.CharField):
+    def validate_format(self, value: str):
+        if not re.match(r"\d(?:\.\d+)?x\d(?:\.\d+)?", value):
+            raise serializers.ValidationError("PPOI must match format: ${float}x${float}")
+
+    def __init__(self, **kwargs):
+        kwargs["max_length"] = 20
+        kwargs.setdefault("validators", [])
+        kwargs["validators"].append(self.validate_format)
+        super().__init__(**kwargs)
+
+    def to_representation(self, value: tuple[float, float]):
+        [left, top] = value
+        return f"{left}x{top}"
+
+
+class Thumbnail(TypedDict):
+    width: float
+    height: float
+    url: str
+
+
 class ImageSerializer(serializers.ModelSerializer):
+    ppoi = PPOIField()
     thumbnails = serializers.SerializerMethodField()
 
     @staticmethod
-    def get_thumbnails(instance) -> List[str]:
+    def get_thumbnails(instance) -> List[Thumbnail]:
         """Returns thumbnails"""
         thumbnails = []
 
         if instance.image.name and THUMBNAIL_SIZES:
             for size in THUMBNAIL_SIZES:
-                thumbnails.append(instance.image.crop[size].name)
+                [width, height] = size.split("x")
+                thumbnails.append(
+                    {
+                        "width": int(width),
+                        "height": int(height),
+                        "url": instance.image.crop[size].url,
+                    }
+                )
 
         return thumbnails
 
@@ -216,7 +247,6 @@ class ImageSerializer(serializers.ModelSerializer):
         read_only_fields = (
             "height",
             "id",
-            "ppoi",
             "thumbnails",
             "width",
         )
@@ -224,6 +254,7 @@ class ImageSerializer(serializers.ModelSerializer):
             "alt_text",
             "credits",
             "image",
+            "ppoi",
         ) + read_only_fields
 
     def create(self, validated_data):
@@ -237,9 +268,10 @@ class ImageSerializer(serializers.ModelSerializer):
     def update(self, instance, validated_data):
         """Update and return an existing Image instance, given the validated data."""
 
-        # Only alt_text and credits can be updated.
+        # Only these fields can be updated.
         instance.alt_text = validated_data.get("alt_text", instance.alt_text)
         instance.credits = validated_data.get("credits", instance.credits)
+        instance.image.ppoi = validated_data.get("ppoi", instance.ppoi)
 
         instance.save()
 
@@ -247,7 +279,11 @@ class ImageSerializer(serializers.ModelSerializer):
 
 
 class HostSerializer(serializers.ModelSerializer):
-    image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False)
+    image = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=Image.objects.all(),
+        required=False,
+    )
     links = HostLinkSerializer(many=True, required=False)
 
     class Meta:
@@ -348,9 +384,18 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
     category = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True)
     funding_category = serializers.PrimaryKeyRelatedField(queryset=FundingCategory.objects.all())
     hosts = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True)
-    image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False)
+    image = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=Image.objects.all(),
+        required=False,
+    )
     language = serializers.PrimaryKeyRelatedField(queryset=Language.objects.all(), many=True)
     links = HostLinkSerializer(many=True, required=False)
+    logo = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=Image.objects.all(),
+        required=False,
+    )
     music_focus = serializers.PrimaryKeyRelatedField(queryset=MusicFocus.objects.all(), many=True)
     owners = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), many=True)
     predecessor = serializers.PrimaryKeyRelatedField(
@@ -629,11 +674,14 @@ class ScheduleDryRunResponseSerializer(serializers.Serializer):
 class TimeSlotSerializer(serializers.ModelSerializer):
     show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all())
     schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all())
-    repetition_of = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all())
+    repetition_of = serializers.PrimaryKeyRelatedField(
+        queryset=TimeSlot.objects.all(), allow_null=True
+    )
 
     class Meta:
         model = TimeSlot
         fields = (
+            "id",
             "end",
             "memo",
             "note_id",
@@ -667,13 +715,16 @@ class NoteLinkSerializer(serializers.ModelSerializer):
 
 class NoteSerializer(serializers.ModelSerializer):
     contributors = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True)
-    image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False)
+    image = serializers.PrimaryKeyRelatedField(
+        queryset=Image.objects.all(), required=False, allow_null=True
+    )
     links = NoteLinkSerializer(many=True, required=False)
-    timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all())
+    timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), required=False)
 
     class Meta:
         model = Note
         read_only_fields = (
+            "id",
             "created_at",
             "created_by",
             "updated_at",
@@ -683,6 +734,7 @@ class NoteSerializer(serializers.ModelSerializer):
             "cba_id",
             "content",
             "contributors",
+            "id",
             "image",
             "links",
             "owner",
@@ -701,9 +753,10 @@ class NoteSerializer(serializers.ModelSerializer):
         contributors = validated_data.pop("contributors", [])
 
         # the creator of the note is the owner
-        validated_data["owner"] = self.context["user_id"]
-
-        note = Note.objects.create(**validated_data | self.context)  # created_by
+        validated_data["owner"] = self.context["request"].user
+        note = Note.objects.create(
+            created_by=self.context["request"].user.username, **validated_data
+        )
 
         note.contributors.set(contributors)
 
@@ -733,7 +786,6 @@ class NoteSerializer(serializers.ModelSerializer):
         instance.cba_id = validated_data.get("cba_id", instance.cba_id)
         instance.content = validated_data.get("content", instance.content)
         instance.image = validated_data.get("image", instance.image)
-        instance.show = validated_data.get("show", instance.show)
         instance.slug = validated_data.get("slug", instance.slug)
         instance.summary = validated_data.get("summary", instance.summary)
         instance.timeslot = validated_data.get("timeslot", instance.timeslot)
@@ -751,7 +803,7 @@ class NoteSerializer(serializers.ModelSerializer):
             for link_data in links_data:
                 NoteLink.objects.create(note=instance, **link_data)
 
-        instance.updated_by = self.context.get("updated_by")
+        instance.updated_by = self.context.get("request").user.username
 
         instance.save()
 
diff --git a/program/tests/__init__.py b/program/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..eeef18a2d317dfa39aea5752ac257bc20d0e04bd
--- /dev/null
+++ b/program/tests/__init__.py
@@ -0,0 +1,100 @@
+import datetime
+
+from django.contrib.auth.models import User
+from django.utils.text import slugify
+from django.utils.timezone import now
+from program.models import Note, RRule, Schedule, Show, TimeSlot
+
+
+class SteeringTestCaseMixin:
+    base_url = "/api/v1"
+
+    def _url(self, *paths, **kwargs):
+        url = "/".join(str(p) for p in paths) + "/"
+        return f"{self.base_url}/{url.format(**kwargs)}"
+
+    def _get_client(self, user=None):
+        client = self.client_class()
+        if user:
+            client.force_authenticate(user=user)
+        return client
+
+
+class UserMixin:
+    user_admin: User
+    user_common: User
+
+    def setUp(self):
+        self.user_admin = User.objects.create_superuser(
+            "admin", "admin@aura.radio", password="admin"
+        )
+        self.user_common = User.objects.create_user(
+            "herbert", "herbert@aura.radio", password="herbert"
+        )
+
+
+class ShowMixin:
+    def _create_show(self, name: str, **kwargs):
+        kwargs["name"] = name
+        kwargs.setdefault("slug", slugify(name))
+        kwargs.setdefault("short_description", f"The {name} show")
+        owners = kwargs.pop("owners", [])
+        show = Show.objects.create(**kwargs)
+        if owners:
+            show.owners.set(owners)
+        return show
+
+
+class ScheduleMixin:
+    def _get_rrule(self):
+        rrule = RRule.objects.first()
+        if rrule is None:
+            rrule = RRule.objects.create(name="once", freq=0)
+        return rrule
+
+    def _create_schedule(self, show: Show, **kwargs):
+        _first_date = kwargs.get("first_date", now().date())
+        kwargs["show"] = show
+        kwargs.setdefault("first_date", _first_date)
+        kwargs.setdefault("start_time", "08:00")
+        kwargs.setdefault("last_date", _first_date + datetime.timedelta(days=365))
+        kwargs.setdefault("end_time", "09:00")
+        kwargs.setdefault("rrule", self._get_rrule())
+        return Schedule.objects.create(**kwargs)
+
+
+class TimeSlotMixin:
+    def _create_timeslot(self, schedule: Schedule, **kwargs):
+        _start = kwargs.get("start", now())
+        kwargs.setdefault("schedule", schedule)
+        kwargs.setdefault("show", schedule.show)
+        kwargs.setdefault("start", _start)
+        kwargs.setdefault("end", _start + datetime.timedelta(hours=1))
+        return TimeSlot.objects.create(**kwargs)
+
+
+class NoteMixin:
+    def _create_note(self, timeslot: TimeSlot, **kwargs):
+        note_count = Note.objects.all().count()
+        _title = kwargs.get("title", f"a random note #{note_count}")
+        kwargs["timeslot"] = timeslot
+        kwargs["title"] = _title
+        kwargs.setdefault("slug", slugify(_title))
+        return Note.objects.create(**kwargs)
+
+    def _create_random_note_content(self, **kwargs):
+        note_count = Note.objects.all().count()
+        _title = kwargs.get("title", f"a random note #{note_count}")
+        kwargs["title"] = _title
+        kwargs.setdefault("slug", slugify(_title))
+        kwargs.setdefault("content", "some random content")
+        kwargs.setdefault("contributors", [])
+        return kwargs
+
+
+class ProgramModelMixin(ShowMixin, ScheduleMixin, TimeSlotMixin, NoteMixin):
+    pass
+
+
+class BaseMixin(UserMixin, ProgramModelMixin, SteeringTestCaseMixin):
+    pass
diff --git a/program/tests/test_notes.py b/program/tests/test_notes.py
new file mode 100644
index 0000000000000000000000000000000000000000..d21fdadcaabcc7bca81efd6da49eda400df4e325
--- /dev/null
+++ b/program/tests/test_notes.py
@@ -0,0 +1,124 @@
+from rest_framework.test import APITransactionTestCase
+
+from program import tests
+from program.models import Schedule, Show
+
+
+class NoteViewTestCase(tests.BaseMixin, APITransactionTestCase):
+    reset_sequences = True
+
+    show_beatbetrieb: Show
+    schedule_beatbetrieb: Schedule
+    show_musikrotation: Show
+    schedule_musikrotation: Schedule
+
+    def setUp(self) -> None:
+        super().setUp()
+        self.show_beatbetrieb = self._create_show("Beatbetrieb")
+        self.schedule_beatbetrieb = self._create_schedule(self.show_beatbetrieb)
+        self.show_musikrotation = self._create_show("Musikrotation", owners=[self.user_common])
+        self.schedule_musikrotation = self._create_schedule(
+            self.show_musikrotation, start_time="10:00", end_time="12:00"
+        )
+
+    def test_everyone_can_read_notes(self):
+        self._create_note(self._create_timeslot(schedule=self.schedule_beatbetrieb))
+        self._create_note(self._create_timeslot(schedule=self.schedule_musikrotation))
+        res = self._get_client().get(self._url("notes"))
+        self.assertEqual(len(res.data), 2)
+
+    def test_common_users_can_create_notes_for_owned_shows(self):
+        ts = self._create_timeslot(schedule=self.schedule_musikrotation)
+        client = self._get_client(self.user_common)
+        endpoint = self._url("notes")
+        res = client.post(
+            endpoint, self._create_random_note_content(timeslot=ts.id), format="json"
+        )
+        self.assertEqual(res.status_code, 201)
+
+    def test_common_users_cannot_create_notes_for_foreign_shows(self):
+        ts = self._create_timeslot(schedule=self.schedule_beatbetrieb)
+        client = self._get_client(self.user_common)
+        endpoint = self._url("notes")
+        res = client.post(
+            endpoint, self._create_random_note_content(timeslot=ts.id), format="json"
+        )
+        self.assertEqual(res.status_code, 404)
+
+    def test_common_user_can_update_owned_shows(self):
+        ts = self._create_timeslot(schedule=self.schedule_musikrotation)
+        note = self._create_note(ts)
+        client = self._get_client(self.user_common)
+        new_note_content = self._create_random_note_content(title="meh")
+        res = client.put(self._url("notes", note.id), new_note_content, format="json")
+        self.assertEqual(res.status_code, 200)
+
+    def test_common_user_cannot_update_notes_of_foreign_shows(self):
+        ts = self._create_timeslot(schedule=self.schedule_beatbetrieb)
+        note = self._create_note(ts)
+        client = self._get_client(self.user_common)
+        new_note_content = self._create_random_note_content(title="meh")
+        res = client.put(self._url("notes", note.id), new_note_content, format="json")
+        self.assertEqual(res.status_code, 404)
+
+    def test_admin_can_create_notes_for_all_timeslots(self):
+        timeslot = self._create_timeslot(schedule=self.schedule_musikrotation)
+        client = self._get_client(self.user_admin)
+        res = client.post(
+            self._url("notes"),
+            self._create_random_note_content(timeslot=timeslot.id),
+            format="json",
+        )
+        self.assertEqual(res.status_code, 201)
+
+    def test_notes_can_be_created_through_nested_routes(self):
+        client = self._get_client(self.user_admin)
+
+        # /shows/{pk}/notes/
+        ts1 = self._create_timeslot(schedule=self.schedule_musikrotation)
+        url = self._url("shows", self.show_musikrotation.id, "notes")
+        note = self._create_random_note_content(title="meh", timeslot=ts1.id)
+        res = client.post(url, note, format="json")
+        self.assertEqual(res.status_code, 201)
+
+        # /shows/{pk}/timeslots/{pk}/note/
+        ts2 = self._create_timeslot(schedule=self.schedule_musikrotation)
+        url = self._url("shows", self.show_musikrotation, "timeslots", ts2.id, "note")
+        note = self._create_random_note_content(title="cool")
+        res = client.post(url, note, format="json")
+        self.assertEqual(res.status_code, 201)
+
+    def test_notes_can_be_filtered_through_nested_routes_and_query_params(self):
+        client = self._get_client()
+
+        ts1 = self._create_timeslot(schedule=self.schedule_musikrotation)
+        ts2 = self._create_timeslot(schedule=self.schedule_beatbetrieb)
+        ts3 = self._create_timeslot(schedule=self.schedule_beatbetrieb)
+        n1 = self._create_note(timeslot=ts1)
+        n2 = self._create_note(timeslot=ts2)
+        n3 = self._create_note(timeslot=ts3)
+
+        def _get_ids(res):
+            return set(ts["id"] for ts in res.data)
+
+        # /shows/{pk}/notes/
+        query_res = client.get(self._url("notes") + f"?show={self.show_beatbetrieb.id}")
+        route_res = client.get(self._url("shows", self.show_beatbetrieb.id, "notes"))
+        ids = {n2.id, n3.id}
+        self.assertEqual(_get_ids(query_res), ids)
+        self.assertEqual(_get_ids(route_res), ids)
+
+        query_res = client.get(self._url("notes") + f"?show={self.show_musikrotation.id}")
+        route_res = client.get(self._url("shows", self.show_musikrotation.id, "notes"))
+        ids = {n1.id}
+        self.assertEqual(_get_ids(query_res), ids)
+        self.assertEqual(_get_ids(route_res), ids)
+
+        # /shows/{pk}/timeslots/{pk}/note/
+        query_res = client.get(self._url("notes") + f"?timeslot={ts2.id}")
+        route_res = client.get(
+            self._url("shows", self.show_beatbetrieb.id, "timeslots", ts2.id, "note")
+        )
+        ids = {n2.id}
+        self.assertEqual(_get_ids(query_res), ids)
+        self.assertEqual(_get_ids(route_res), ids)
diff --git a/program/views.py b/program/views.py
index 7e07607ecba358d15cb288394c0f2c83709b3fd0..441f8e955a8739753a98b4aaa851452aa6c4818f 100644
--- a/program/views.py
+++ b/program/views.py
@@ -26,12 +26,13 @@ from textwrap import dedent
 
 from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
 from rest_framework import mixins, permissions, status, viewsets
+from rest_framework.exceptions import ValidationError
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.response import Response
 
 from django.contrib.auth.models import User
 from django.core.exceptions import FieldError
-from django.http import HttpResponse
+from django.http import Http404, HttpResponse
 from django.shortcuts import get_list_or_404, get_object_or_404
 from django.utils import timezone
 from django.utils.translation import gettext as _
@@ -773,87 +774,45 @@ class APINoteViewSet(
     viewsets.ModelViewSet,
 ):
     ROUTE_FILTER_LOOKUPS = {
-        "show_pk": "show",
+        "show_pk": "timeslot__show",
         "timeslot_pk": "timeslot",
     }
-
     queryset = Note.objects.all()
     serializer_class = NoteSerializer
-    permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
+    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
     pagination_class = LimitOffsetPagination
-    filter_class = filters.NoteFilterSet
-
-    def create(self, request, *args, **kwargs):
-        """
-        Only admins 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, "created_by": request.user.username},
-        )
-
-        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 admins 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, context={"updated_by": request.user.username}
-        )
-
-        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 partial_update(self, request, *args, **kwargs):
-        kwargs["partial"] = True
-        return self.update(request, *args, **kwargs)
-
-    def destroy(self, request, *args, **kwargs):
-        """
-        Only admins 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()
+    filterset_class = filters.NoteFilterSet
 
-        return Response(status=status.HTTP_204_NO_CONTENT)
+    def get_queryset(self):
+        qs = super().get_queryset().order_by("slug")
+        # Users should always be able to see notes
+        if self.request.method not in permissions.SAFE_METHODS:
+            # If the request is not by an admin,
+            # check that the timeslot is owned by the current user.
+            if not self.request.user.is_superuser:
+                qs = qs.filter(timeslot__show__owners=self.request.user)
+        return qs
+
+    def _get_timeslot(self):
+        # TODO: Once we remove nested routes, timeslot ownership
+        #       should be checked in a permission class.
+        timeslot_pk = self.request.data.get("timeslot", None)
+        if timeslot_pk is None:
+            timeslot_pk = get_values(self.kwargs, "timeslot_pk")
+        if timeslot_pk is None:
+            raise ValidationError({"timeslot": [_("This field is required.")]}, code="required")
+        qs = TimeSlot.objects.all()
+        if not self.request.user.is_superuser:
+            qs = qs.filter(show__owners=self.request.user)
+        try:
+            return qs.get(pk=timeslot_pk)
+        except TimeSlot.DoesNotExist:
+            raise Http404()
+
+    def perform_create(self, serializer):
+        # TODO: Once we remove nested routes, this should be removed
+        #       and timeslot should be required in the serializer again.
+        serializer.save(timeslot=self._get_timeslot())
 
 
 class ActiveFilterMixin:
diff --git a/steering/urls.py b/steering/urls.py
index 46d0e99c8710b23fef890ea9a6688ac93f4487d5..70df93704acabc6716f9789ba03adba5a8f0155e 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -58,11 +58,11 @@ router.register(r"notes", APINoteViewSet)
 router.register(r"categories", APICategoryViewSet)
 router.register(r"topics", APITopicViewSet)
 router.register(r"types", APITypeViewSet)
-router.register(r"music_focus", APIMusicFocusViewSet)
-router.register(r"funding_categories", APIFundingCategoryViewSet)
+router.register(r"music-focus", APIMusicFocusViewSet)
+router.register(r"funding-categories", APIFundingCategoryViewSet)
 router.register(r"languages", APILanguageViewSet)
-router.register(r"license_types", APILicenseTypeViewSet)
-router.register(r"link_types", APILinkTypeViewSet)
+router.register(r"license-types", APILicenseTypeViewSet)
+router.register(r"link-types", APILinkTypeViewSet)
 router.register(r"rrules", APIRRuleViewSet)
 router.register(r"images", APIImageViewSet)