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/CHANGELOG.md b/CHANGELOG.md
index 1c5baf8e119b0c1c6791e37442746d705796ee89..1f345d81c5f6312582d8972518e7a85b7276ee99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
-- ...
+- `Image` concrete model to handle all instances.
+- API endpoint `/api/v1/images/` to add, update and delete images.
 
 ### Changed
 
diff --git a/fixtures/program/category.json b/fixtures/program/category.json
index d41ea21d6cef91f13a5b17d8fdee26f605b0a825..7dd5d90b3fc1edbd2fbb16e6acae841c0c129d39 100644
--- a/fixtures/program/category.json
+++ b/fixtures/program/category.json
@@ -4,7 +4,6 @@
     "pk": 1,
     "fields": {
       "name": "Interkulturell",
-      "abbrev": "I",
       "slug": "interkulturell",
       "is_active": true,
       "description": ""
@@ -15,7 +14,6 @@
     "pk": 2,
     "fields": {
       "name": "Lokalbezug",
-      "abbrev": "L",
       "slug": "lokalbezug",
       "is_active": true,
       "description": ""
@@ -26,7 +24,6 @@
     "pk": 3,
     "fields": {
       "name": "Minderheiten",
-      "abbrev": "Mi",
       "slug": "minderheiten",
       "is_active": true,
       "description": ""
@@ -37,7 +34,6 @@
     "pk": 4,
     "fields": {
       "name": "Wiederholung",
-      "abbrev": "W",
       "slug": "wiederholung",
       "is_active": true,
       "description": ""
@@ -48,7 +44,6 @@
     "pk": 5,
     "fields": {
       "name": "Mehr-/Fremdsprachig",
-      "abbrev": "M",
       "slug": "mehr-fremdsprachig",
       "is_active": true,
       "description": ""
@@ -59,7 +54,6 @@
     "pk": 6,
     "fields": {
       "name": "Frauenschwerpunkt",
-      "abbrev": "F",
       "slug": "frauenschwerpunkt",
       "is_active": true,
       "description": ""
@@ -70,7 +64,6 @@
     "pk": 7,
     "fields": {
       "name": "Österreichische Musik",
-      "abbrev": "Ö",
       "slug": "osterreichische-musik",
       "is_active": true,
       "description": ""
@@ -81,7 +74,6 @@
     "pk": 8,
     "fields": {
       "name": "Sendungsübernahme",
-      "abbrev": "U",
       "slug": "sendungsubernahme",
       "is_active": true,
       "description": ""
diff --git a/fixtures/program/fundingcategory.json b/fixtures/program/fundingcategory.json
index 8e4b28f5d8f86fef8626a008778932a6c7e45cab..a8c71fb09406f6a5c402202b110c5e5abbd89568 100644
--- a/fixtures/program/fundingcategory.json
+++ b/fixtures/program/fundingcategory.json
@@ -4,7 +4,6 @@
     "pk": 1,
     "fields": {
       "name": "Standard",
-      "abbrev": "S",
       "slug": "standard",
       "is_active": true
     }
diff --git a/fixtures/program/host.json b/fixtures/program/host.json
index d4c3989ab68ddfcdfdc8b83a79a0458810e49e58..bd70ac3765af7bfecaeb223aeebead8cf3b65134 100644
--- a/fixtures/program/host.json
+++ b/fixtures/program/host.json
@@ -6,12 +6,10 @@
       "name": "Musikredaktion",
       "is_active": true,
       "email": "",
-      "website": "",
       "biography": null,
-      "ppoi": "0.5x0.5",
-      "height": null,
-      "width": null,
-      "image": ""
+      "image": null,
+      "created_at": "2000-06-01 00:00Z",
+      "created_by": "loaddata"
     }
   }
 ]
diff --git a/fixtures/program/licensetype.json b/fixtures/program/licensetype.json
new file mode 100644
index 0000000000000000000000000000000000000000..8661b1d2bf3a7ca57c44e079251e56e325f1fa83
--- /dev/null
+++ b/fixtures/program/licensetype.json
@@ -0,0 +1,66 @@
+[
+  {
+    "model": "program.licensetype",
+    "pk": 1,
+    "fields": {
+      "name": "pd",
+      "type": "Public Domain"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 2,
+    "fields": {
+      "name": "cc-by",
+      "type": "Creative Commons Attribution"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 3,
+    "fields": {
+      "name": "cc-by-sa",
+      "type": "Creative Commons Attribution-ShareAlike"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 4,
+    "fields": {
+      "name": "cc-by-nc",
+      "type": "Creative Commons Attribution-NonCommercial"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 5,
+    "fields": {
+      "name": "cc-by-nc-sa",
+      "type": "Creative Commons Attribution-NonCommercial-ShareAlike"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 6,
+    "fields": {
+      "name": "cc-by-nd",
+      "type": "Creative Commons Attribution-NoDerivatives"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 7,
+    "fields": {
+      "name": "cc-by-nc-nd",
+      "type": "Creative Commons Attribution-NonCommercial-NoDerivatives"
+    }
+  },
+  {
+    "model": "program.licensetype",
+    "pk": 8,
+    "fields": {
+      "name": "gfdl",
+      "type": "GNU Free Documentation License"
+    }
+  }
+]
\ No newline at end of file
diff --git a/fixtures/program/linktype.json b/fixtures/program/linktype.json
new file mode 100644
index 0000000000000000000000000000000000000000..c5b6968193551fee00e8c45bb80d5d4ad993560d
--- /dev/null
+++ b/fixtures/program/linktype.json
@@ -0,0 +1,42 @@
+[
+  {
+    "model": "program.linktype",
+    "pk": 1,
+    "fields": {
+      "name": "homepage",
+      "type": "Homepage"
+    }
+  },
+  {
+    "model": "program.linktype",
+    "pk": 2,
+    "fields": {
+      "name": "website",
+      "type": "Website"
+    }
+  },
+  {
+    "model": "program.linktype",
+    "pk": 3,
+    "fields": {
+      "name": "cba-podcast",
+      "type": "Cultural Broadcasting Archive Podcast"
+    }
+  },
+  {
+    "model": "program.linktype",
+    "pk": 4,
+    "fields": {
+      "name": "cba-post",
+      "type": "Cultural Broadcasting Archive Post"
+    }
+  },
+  {
+    "model": "program.linktype",
+    "pk": 5,
+    "fields": {
+      "name": "soundcloud",
+      "type": "SoundCloud"
+    }
+  }
+]
\ No newline at end of file
diff --git a/fixtures/program/musicfocus.json b/fixtures/program/musicfocus.json
index 26a5922f56d08304406fd2cf12796f6fb5c57d63..f85c89981088f1f40a429437c514331000b211f4 100644
--- a/fixtures/program/musicfocus.json
+++ b/fixtures/program/musicfocus.json
@@ -4,7 +4,6 @@
     "pk": 1,
     "fields": {
       "name": "Jazz",
-      "abbrev": "J",
       "slug": "jazz",
       "is_active": true
     }
@@ -14,7 +13,6 @@
     "pk": 2,
     "fields": {
       "name": "Volksmusik/Folk",
-      "abbrev": "V",
       "slug": "volksmusik-folk",
       "is_active": true
     }
@@ -24,7 +22,6 @@
     "pk": 3,
     "fields": {
       "name": "Experimentelle Musik",
-      "abbrev": "Ex",
       "slug": "expermentelle-musik",
       "is_active": true
     }
@@ -34,7 +31,6 @@
     "pk": 4,
     "fields": {
       "name": "Rock/Indie",
-      "abbrev": "R",
       "slug": "rock-indie",
       "is_active": true
     }
@@ -44,7 +40,6 @@
     "pk": 5,
     "fields": {
       "name": "Metal/Hardrock",
-      "abbrev": "M",
       "slug": "metal-hardrock",
       "is_active": true
     }
@@ -54,7 +49,6 @@
     "pk": 6,
     "fields": {
       "name": "Electronic",
-      "abbrev": "E",
       "slug": "electronic",
       "is_active": true
     }
@@ -64,7 +58,6 @@
     "pk": 7,
     "fields": {
       "name": "Klassik",
-      "abbrev": "K",
       "slug": "klassik",
       "is_active": true
     }
@@ -74,7 +67,6 @@
     "pk": 8,
     "fields": {
       "name": "Oldies",
-      "abbrev": "O",
       "slug": "oldies",
       "is_active": true
     }
@@ -84,7 +76,6 @@
     "pk": 9,
     "fields": {
       "name": "Reggae/Ska",
-      "abbrev": "Re",
       "slug": "reggae-ska",
       "is_active": true
     }
@@ -94,7 +85,6 @@
     "pk": 10,
     "fields": {
       "name": "Hiphop",
-      "abbrev": "H",
       "slug": "hiphop",
       "is_active": true
     }
diff --git a/fixtures/program/rrule.json b/fixtures/program/rrule.json
index 365374e972b8e9922416aadd18b49b6ac55ffb1a..dc348f6e38dbb51bf71c2128babc2beddd184112 100644
--- a/fixtures/program/rrule.json
+++ b/fixtures/program/rrule.json
@@ -130,5 +130,245 @@
       "by_weekdays": null,
       "count": null
     }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 110,
+    "fields": {
+      "name": "monatlich am letzten",
+      "freq": 1,
+      "interval": 1,
+      "by_set_pos": -1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 120,
+    "fields": {
+      "name": "zwei-monatlich am letzten",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": -1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 121,
+    "fields": {
+      "name": "zwei-monatlich am ersten",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": 1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 122,
+    "fields": {
+      "name": "zwei-monatlich am zweiten",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": 2,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 123,
+    "fields": {
+      "name": "zwei-monatlich am dritten",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": 3,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 124,
+    "fields": {
+      "name": "zwei-monatlich am vierten",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": 4,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 125,
+    "fields": {
+      "name": "zwei-monatlich am fünften",
+      "freq": 1,
+      "interval": 2,
+      "by_set_pos": 5,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 130,
+    "fields": {
+      "name": "drei-monatlich am letzten",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": -1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 131,
+    "fields": {
+      "name": "drei-monatlich am ersten",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": 1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 132,
+    "fields": {
+      "name": "drei-monatlich am zweiten",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": 2,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 133,
+    "fields": {
+      "name": "drei-monatlich am dritten",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": 3,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 134,
+    "fields": {
+      "name": "drei-monatlich am vierten",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": 4,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 135,
+    "fields": {
+      "name": "drei-monatlich am fünften",
+      "freq": 1,
+      "interval": 3,
+      "by_set_pos": 5,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 140,
+    "fields": {
+      "name": "vier-monatlich am letzten",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": -1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 141,
+    "fields": {
+      "name": "vier-monatlich am ersten",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": 1,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 142,
+    "fields": {
+      "name": "vier-monatlich am zweiten",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": 2,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 143,
+    "fields": {
+      "name": "vier-monatlich am dritten",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": 3,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 144,
+    "fields": {
+      "name": "vier-monatlich am vierten",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": 4,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 145,
+    "fields": {
+      "name": "vier-monatlich am fünften",
+      "freq": 1,
+      "interval": 4,
+      "by_set_pos": 5,
+      "by_weekdays": null,
+      "count": null
+    }
+  },
+  {
+    "model": "program.rrule",
+    "pk": 2111,
+    "fields": {
+      "name": "am Wochenende",
+      "freq": 2,
+      "interval": 1,
+      "by_set_pos": null,
+      "by_weekdays": "5,6",
+      "count": null
+    }
   }
 ]
diff --git a/fixtures/program/show.json b/fixtures/program/show.json
index 778075b642628d57792d4c88036a2a066fac7eea..597026872db92c22c309727ae0f7671cf6eccacc 100644
--- a/fixtures/program/show.json
+++ b/fixtures/program/show.json
@@ -8,15 +8,11 @@
       "funding_category": 1,
       "name": "Musikprogramm",
       "slug": "musikprogramm",
-      "ppoi": "0.5x0.5",
-      "height": null,
-      "width": null,
-      "image": "",
-      "logo": "",
+      "image": null,
+      "logo": null,
       "short_description": "Unmoderiertes Musikprogramm",
       "description": "Unmoderiertes Musikprogramm",
       "email": "musikredaktion@helsinki.at",
-      "website": null,
       "cba_series_id": null,
       "default_playlist_id": null,
       "is_active": true,
@@ -28,7 +24,9 @@
       "language": [],
       "category": [],
       "topic": [],
-      "music_focus": []
+      "music_focus": [],
+      "created_at": "2000-06-01 00:00Z",
+      "created_by": "loaddata"
     }
   }
 ]
diff --git a/fixtures/program/topic.json b/fixtures/program/topic.json
index 0d69ffbc56abeb1ca598f1e7ba91260a265c3ce1..0aec75a3cba0a5d1815e04148f6d75e9c8bf0589 100644
--- a/fixtures/program/topic.json
+++ b/fixtures/program/topic.json
@@ -4,7 +4,6 @@
     "pk": 1,
     "fields": {
       "name": "Politik/Gesellschaft",
-      "abbrev": "P",
       "slug": "politik-gesellschaft",
       "is_active": true
     }
@@ -14,7 +13,6 @@
     "pk": 2,
     "fields": {
       "name": "Natur/Klima/Tiere",
-      "abbrev": "N",
       "slug": "natur-klima-tiere",
       "is_active": true
     }
@@ -24,7 +22,6 @@
     "pk": 3,
     "fields": {
       "name": "Kultur/Kunst",
-      "abbrev": "K",
       "slug": "kultur-kunst",
       "is_active": true
     }
@@ -34,7 +31,6 @@
     "pk": 4,
     "fields": {
       "name": "Soziales",
-      "abbrev": "S",
       "slug": "soziales",
       "is_active": true
     }
@@ -44,7 +40,6 @@
     "pk": 5,
     "fields": {
       "name": "Wissenschaft/Philosophie",
-      "abbrev": "W",
       "slug": "wissenschaft-philosophie",
       "is_active": true
     }
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/profile/__init__.py b/profile/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/profile/admin.py b/profile/admin.py
deleted file mode 100644
index 0a93c66103e1e3aec33629c013b9597d77cb5c86..0000000000000000000000000000000000000000
--- a/profile/admin.py
+++ /dev/null
@@ -1,70 +0,0 @@
-#
-# steering, Programme/schedule management for AURA
-#
-# Copyright (C) 2017-2018, Ingo Leindecker
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
-
-from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.models import User
-
-from .models import Profile
-
-
-class ProfileInline(admin.StackedInline):
-    model = Profile
-    can_delete = False
-    verbose_name_plural = "Profile"
-    fk_name = "user"
-
-
-class ProfileUserAdmin(UserAdmin):
-    inlines = (ProfileInline,)
-
-    def get_queryset(self, request):
-        """Let common users only edit their own profile"""
-        if not request.user.is_superuser:
-            return (
-                super(UserAdmin, self).get_queryset(request).filter(pk=request.user.id)
-            )
-
-        return super(UserAdmin, self).get_queryset(request)
-
-    def get_readonly_fields(self, request, obj=None):
-        """Limit field access for common users"""
-        if not request.user.is_superuser:
-            return (
-                "username",
-                "is_staff",
-                "is_superuser",
-                "is_active",
-                "date_joined",
-                "last_login",
-                "groups",
-                "user_permissions",
-            )
-        return list()
-
-    def get_inline_instances(self, request, obj=None):
-        """Append profile fields to UserAdmin"""
-        if not obj:
-            return list()
-
-        return super(ProfileUserAdmin, self).get_inline_instances(request, obj)
-
-
-admin.site.unregister(User)
-admin.site.register(User, ProfileUserAdmin)
diff --git a/profile/migrations/0001_initial.py b/profile/migrations/0001_initial.py
deleted file mode 100644
index ad7c45b8559e5f0f484406bfd52958667e21d2cc..0000000000000000000000000000000000000000
--- a/profile/migrations/0001_initial.py
+++ /dev/null
@@ -1,122 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.3 on 2017-11-09 18:42
-from __future__ import unicode_literals
-
-import versatileimagefield.fields
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="Profile",
-            fields=[
-                (
-                    "id",
-                    models.AutoField(
-                        auto_created=True,
-                        primary_key=True,
-                        serialize=False,
-                        verbose_name="ID",
-                    ),
-                ),
-                (
-                    "biography",
-                    models.TextField(blank=True, null=True, verbose_name="Biography"),
-                ),
-                ("website", models.URLField(blank=True, verbose_name="Website")),
-                (
-                    "googleplus_url",
-                    models.URLField(blank=True, verbose_name="Google+ URL"),
-                ),
-                (
-                    "facebook_url",
-                    models.URLField(blank=True, verbose_name="Facebook URL"),
-                ),
-                (
-                    "twitter_url",
-                    models.URLField(blank=True, verbose_name="Twitter URL"),
-                ),
-                (
-                    "linkedin_url",
-                    models.URLField(blank=True, verbose_name="LinkedIn URL"),
-                ),
-                (
-                    "youtube_url",
-                    models.URLField(blank=True, verbose_name="Youtube URL"),
-                ),
-                ("dorftv_url", models.URLField(blank=True, verbose_name="DorfTV URL")),
-                ("cba_url", models.URLField(blank=True, verbose_name="CBA URL")),
-                (
-                    "cba_username",
-                    models.CharField(
-                        blank=True, max_length=60, verbose_name="CBA Username"
-                    ),
-                ),
-                (
-                    "cba_user_token",
-                    models.CharField(
-                        blank=True, max_length=255, verbose_name="CBA Token"
-                    ),
-                ),
-                (
-                    "ppoi",
-                    versatileimagefield.fields.PPOIField(
-                        default="0.5x0.5",
-                        editable=False,
-                        max_length=20,
-                        verbose_name="Image PPOI",
-                    ),
-                ),
-                (
-                    "height",
-                    models.PositiveIntegerField(
-                        blank=True,
-                        editable=False,
-                        null=True,
-                        verbose_name="Image Height",
-                    ),
-                ),
-                (
-                    "width",
-                    models.PositiveIntegerField(
-                        blank=True,
-                        editable=False,
-                        null=True,
-                        verbose_name="Image Width",
-                    ),
-                ),
-                (
-                    "image",
-                    versatileimagefield.fields.VersatileImageField(
-                        blank=True,
-                        height_field="height",
-                        null=True,
-                        upload_to="user_images",
-                        verbose_name="Profile picture",
-                        width_field="width",
-                    ),
-                ),
-                (
-                    "user",
-                    models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to=settings.AUTH_USER_MODEL,
-                    ),
-                ),
-            ],
-            options={
-                "db_table": "profile",
-            },
-        ),
-    ]
diff --git a/profile/migrations/0001_squashed.py b/profile/migrations/0001_squashed.py
deleted file mode 100644
index 1313caa100beedfb221a769bf81943e0f62e0541..0000000000000000000000000000000000000000
--- a/profile/migrations/0001_squashed.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# Generated by Django 2.2.12 on 2020-11-21 01:34
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    replaces = [
-        ("profile", "0001_initial"),
-        ("profile", "0002_auto_20171129_1828"),
-        ("profile", "0003_auto_20171213_1737"),
-    ]
-
-    initial = True
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="Profile",
-            fields=[
-                (
-                    "id",
-                    models.AutoField(
-                        auto_created=True,
-                        primary_key=True,
-                        serialize=False,
-                        verbose_name="ID",
-                    ),
-                ),
-                (
-                    "cba_username",
-                    models.CharField(
-                        blank=True,
-                        help_text="Your username in CBA. This is necessary for uploading files to"
-                        " your account.",
-                        max_length=60,
-                        verbose_name="CBA Username",
-                    ),
-                ),
-                (
-                    "cba_user_token",
-                    models.CharField(
-                        blank=True,
-                        help_text="The CBA upload token for your account. This is NOT your"
-                        " password which you use to log into CBA!",
-                        max_length=255,
-                        verbose_name="CBA Token",
-                    ),
-                ),
-                (
-                    "user",
-                    models.OneToOneField(
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        related_name="profile",
-                        to=settings.AUTH_USER_MODEL,
-                    ),
-                ),
-            ],
-            options={
-                "db_table": "profile",
-            },
-        ),
-    ]
diff --git a/profile/migrations/0002_auto_20171129_1828.py b/profile/migrations/0002_auto_20171129_1828.py
deleted file mode 100644
index 389554246f612dd8ce7674c8f2e7722fac746b21..0000000000000000000000000000000000000000
--- a/profile/migrations/0002_auto_20171129_1828.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.3 on 2017-11-29 18:28
-from __future__ import unicode_literals
-
-import versatileimagefield.fields
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ("profile", "0001_initial"),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name="profile",
-            name="biography",
-            field=models.TextField(
-                blank=True,
-                help_text="Describe yourself and your fields of interest in a few sentences.",
-                null=True,
-                verbose_name="Biography",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="cba_url",
-            field=models.URLField(
-                blank=True, help_text="URL to your CBA profile.", verbose_name="CBA URL"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="cba_user_token",
-            field=models.CharField(
-                blank=True,
-                help_text="The CBA upload token for your account. This is NOT your password which"
-                " you use to log into CBA!",
-                max_length=255,
-                verbose_name="CBA Token",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="cba_username",
-            field=models.CharField(
-                blank=True,
-                help_text="Your username in CBA. This is necessary for uploading files to your"
-                " account.",
-                max_length=60,
-                verbose_name="CBA Username",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="dorftv_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your dorfTV channel.",
-                verbose_name="DorfTV URL",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="facebook_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your Facebook profile.",
-                verbose_name="Facebook URL",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="googleplus_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your Google+ profile.",
-                verbose_name="Google+ URL",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="image",
-            field=versatileimagefield.fields.VersatileImageField(
-                blank=True,
-                height_field="height",
-                help_text="Upload a picture of yourself. Images are automatically cropped around"
-                " the 'Primary Point of Interest'. Click in the image to change it and"
-                " press Save.",
-                null=True,
-                upload_to="user_images",
-                verbose_name="Profile picture",
-                width_field="width",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="linkedin_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your LinkedIn profile.",
-                verbose_name="LinkedIn URL",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="twitter_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your Twitter profile.",
-                verbose_name="Twitter URL",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="user",
-            field=models.OneToOneField(
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="profile",
-                to=settings.AUTH_USER_MODEL,
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="website",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your personal website.",
-                verbose_name="Website",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="youtube_url",
-            field=models.URLField(
-                blank=True,
-                help_text="URL to your Youtube channel.",
-                verbose_name="Youtube URL",
-            ),
-        ),
-    ]
diff --git a/profile/migrations/0002_auto_20220117_1721.py b/profile/migrations/0002_auto_20220117_1721.py
deleted file mode 100644
index e209efdea852e921a271540fe56c4e686a9c7f75..0000000000000000000000000000000000000000
--- a/profile/migrations/0002_auto_20220117_1721.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 2.2.25 on 2022-01-17 16:21
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ("profile", "0001_squashed"),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name="profile",
-            name="cba_user_token",
-            field=models.CharField(
-                blank=True, max_length=255, verbose_name="CBA Token"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="profile",
-            name="cba_username",
-            field=models.CharField(
-                blank=True, max_length=60, verbose_name="CBA Username"
-            ),
-        ),
-    ]
diff --git a/profile/migrations/0003_auto_20171213_1737.py b/profile/migrations/0003_auto_20171213_1737.py
deleted file mode 100644
index 8a4b11031e742c40aec3dd314efbf7e8018c19c0..0000000000000000000000000000000000000000
--- a/profile/migrations/0003_auto_20171213_1737.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.3 on 2017-12-13 17:37
-from __future__ import unicode_literals
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ("profile", "0002_auto_20171129_1828"),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name="profile",
-            name="biography",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="cba_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="dorftv_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="facebook_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="googleplus_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="height",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="image",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="linkedin_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="ppoi",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="twitter_url",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="website",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="width",
-        ),
-        migrations.RemoveField(
-            model_name="profile",
-            name="youtube_url",
-        ),
-    ]
diff --git a/profile/migrations/__init__.py b/profile/migrations/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/profile/models.py b/profile/models.py
deleted file mode 100644
index 743639222894e49eaadaf7539e780d9497a3ade0..0000000000000000000000000000000000000000
--- a/profile/models.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#
-# steering, Programme/schedule management for AURA
-#
-# Copyright (C) 2017-2018, Ingo Leindecker
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
-
-from django.contrib.auth.models import User
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-
-class Profile(models.Model):
-    user = models.OneToOneField(
-        User, on_delete=models.CASCADE, related_name="profile", editable=False
-    )
-    cba_username = models.CharField(_("CBA Username"), blank=True, max_length=60)
-    cba_user_token = models.CharField(_("CBA Token"), blank=True, max_length=255)
-
-    def __str__(self):
-        return self.user.username
-
-    class Meta:
-        db_table = "profile"
-
-    def save(self, *args, **kwargs):
-        super(Profile, self).save(*args, **kwargs)
diff --git a/profile/serializers.py b/profile/serializers.py
deleted file mode 100644
index efff1c2f0ee98b5b086d3c5fa069cd8869a3b72c..0000000000000000000000000000000000000000
--- a/profile/serializers.py
+++ /dev/null
@@ -1,28 +0,0 @@
-#
-# steering, Programme/schedule management for AURA
-#
-# Copyright (C) 2017-2018, Ingo Leindecker
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
-
-from profile.models import Profile
-
-from rest_framework import serializers
-
-
-class ProfileSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = Profile
-        fields = "__all__"
diff --git a/program/admin.py b/program/admin.py
index 3b7d2892dcf06f220a255db9f6d978fb69779700..d12297dba6abd6e9d5356e6596463ccffa41f3ed 100644
--- a/program/admin.py
+++ b/program/admin.py
@@ -1,12 +1,17 @@
 from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.models import User
 from program.models import (
     Category,
     FundingCategory,
     Language,
+    LicenseType,
+    LinkType,
     MusicFocus,
     RRule,
     Topic,
     Type,
+    UserProfile,
 )
 
 
@@ -14,6 +19,10 @@ class AdminWithNameSlugIsActive(admin.ModelAdmin):
     list_display = ("name", "slug", "is_active")
 
 
+class AdminWithNameType(admin.ModelAdmin):
+    list_display = ("name", "type")
+
+
 class LanguageAdmin(admin.ModelAdmin):
     list_display = ("name", "is_active")
 
@@ -22,9 +31,55 @@ class RRuleAdmin(admin.ModelAdmin):
     list_display = ("name", "freq", "interval", "by_set_pos", "by_weekdays", "count")
 
 
+class UserProfileInline(admin.StackedInline):
+    model = UserProfile
+    fields = ("cba_username", "cba_user_token")
+    can_delete = False
+    verbose_name_plural = "Profile"
+    fk_name = "user"
+
+
+class UserProfileUserAdmin(UserAdmin):
+    inlines = (UserProfileInline,)
+
+    def get_queryset(self, request):
+        """Let common users only edit their own profile"""
+        if not request.user.is_superuser:
+            return super(UserAdmin, self).get_queryset(request).filter(pk=request.user.id)
+
+        return super(UserAdmin, self).get_queryset(request)
+
+    def get_readonly_fields(self, request, obj=None):
+        """Limit field access for common users"""
+        if not request.user.is_superuser:
+            return (
+                "username",
+                "is_staff",
+                "is_superuser",
+                "is_active",
+                "date_joined",
+                "last_login",
+                "groups",
+                "user_permissions",
+            )
+        return list()
+
+    def get_inline_instances(self, request, obj=None):
+        """Append profile fields to UserAdmin"""
+        if not obj:
+            return list()
+
+        return super(UserProfileUserAdmin, self).get_inline_instances(request, obj)
+
+
+admin.site.unregister(User)
+admin.site.register(User, UserProfileUserAdmin)
+
 admin.site.register(Category, AdminWithNameSlugIsActive)
 admin.site.register(FundingCategory, AdminWithNameSlugIsActive)
 admin.site.register(Language, LanguageAdmin)
+admin.site.register(LinkType, AdminWithNameType)
+admin.site.register(LicenseType, AdminWithNameType)
 admin.site.register(MusicFocus, AdminWithNameSlugIsActive)
 admin.site.register(RRule, RRuleAdmin)
 admin.site.register(Topic, AdminWithNameSlugIsActive)
diff --git a/program/filters.py b/program/filters.py
index b218b426a36c6693c1ba7c0fdc93e575361f77cd..298111c8b04302ad5ba4ca41981af0b80e6e563f 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -51,11 +51,13 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
         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 = IntegerInFilter(
+    music_focus = IntegerInFilter(
         field_name="music_focus",
         help_text="Return only shows with given music focus(es).",
     )
+    music_focus__slug = filters.CharFilter(
+        field_name="music_focus", help_text="Return only shows with the give music focus slug."
+    )
     owner = IntegerInFilter(
         field_name="owners",
         help_text="Return only shows that belong to the given owner(s).",
@@ -63,15 +65,24 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
     category = IntegerInFilter(
         help_text="Return only shows of the given category or categories.",
     )
+    category__slug = filters.CharFilter(
+        field_name="category", help_text="Return only shows of the given category slug."
+    )
     language = IntegerInFilter(
         help_text="Return only shows of the given language(s).",
     )
     topic = IntegerInFilter(
         help_text="Return only shows of the given topic(s).",
     )
+    topic__slug = filters.CharFilter(
+        field_name="topic", help_text="Return only shows of the given topic slug."
+    )
     type = IntegerInFilter(
         help_text="Return only shows of a given type.",
     )
+    type__slug = filters.CharFilter(
+        field_name="type", help_text="Return only shows of the given type slug."
+    )
     public = filters.BooleanFilter(
         field_name="is_public",
         help_text="Return only shows that are public/non-public.",
@@ -121,7 +132,7 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
             "category",
             "host",
             "language",
-            "musicfocus",
+            "music_focus",
             "owner",
             "public",
             "topic",
@@ -220,27 +231,29 @@ 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).",
     )
-    owner = IntegerInFilter(
-        field_name="show__owners",
+    show_owner = IntegerInFilter(
+        field_name="timeslot__show__owners",
         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.",
+            "owner": "Return only notes created by the specified user.",
         }
-        fields = [
-            "host",
-            "ids",
-            "owner",
-            "user",
-        ]
+        fields = ["ids", "owner", "show", "timeslot", "show_owner"]
 
 
 class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
diff --git a/program/migrations/0023_auto_20220722_1747.py b/program/migrations/0023_auto_20220722_1747.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b5b68ce6ab8acea94ff9a57b2b772cf777df4ab
--- /dev/null
+++ b/program/migrations/0023_auto_20220722_1747.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.14 on 2022-07-22 15:47
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0022_auto_20220516_2245"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="category",
+            name="abbrev",
+        ),
+        migrations.RemoveField(
+            model_name="fundingcategory",
+            name="abbrev",
+        ),
+        migrations.RemoveField(
+            model_name="musicfocus",
+            name="abbrev",
+        ),
+        migrations.RemoveField(
+            model_name="topic",
+            name="abbrev",
+        ),
+    ]
diff --git a/program/migrations/0024_category_subtitle.py b/program/migrations/0024_category_subtitle.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c30430787b15b719f9b5b514530801cb84476af
--- /dev/null
+++ b/program/migrations/0024_category_subtitle.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.14 on 2022-07-22 16:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0023_auto_20220722_1747"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="category",
+            name="subtitle",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/program/migrations/0025_auto_20220728_1625.py b/program/migrations/0025_auto_20220728_1625.py
new file mode 100644
index 0000000000000000000000000000000000000000..4253773240e31bc67f52d5d9d92430c81d518a17
--- /dev/null
+++ b/program/migrations/0025_auto_20220728_1625.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.14 on 2022-07-28 14:25
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0024_category_subtitle"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="timeslot",
+            name="is_repetition",
+        ),
+        migrations.AddField(
+            model_name="timeslot",
+            name="repetition_of",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="repetitions",
+                to="program.timeslot",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0026_auto_20220728_2227.py b/program/migrations/0026_auto_20220728_2227.py
new file mode 100644
index 0000000000000000000000000000000000000000..3175410369f43716fbad5fd0cf3416168b0826d0
--- /dev/null
+++ b/program/migrations/0026_auto_20220728_2227.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.14 on 2022-07-28 20:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0025_auto_20220728_1625"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="host",
+            name="website",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="website",
+        ),
+    ]
diff --git a/program/migrations/0027_show_internal_note.py b/program/migrations/0027_show_internal_note.py
new file mode 100644
index 0000000000000000000000000000000000000000..25434e5500df3ab4d39ebf7c59faf8da2fca56d7
--- /dev/null
+++ b/program/migrations/0027_show_internal_note.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.14 on 2022-08-01 15:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0026_auto_20220728_2227"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="show",
+            name="internal_note",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/program/migrations/0028_auto_20220801_1713.py b/program/migrations/0028_auto_20220801_1713.py
new file mode 100644
index 0000000000000000000000000000000000000000..05c1f1feb99ca1cc21f5c080774d1fc7083a8e46
--- /dev/null
+++ b/program/migrations/0028_auto_20220801_1713.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.14 on 2022-08-01 15:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0027_show_internal_note"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="language",
+            options={"ordering": ("name",)},
+        ),
+        migrations.AlterField(
+            model_name="show",
+            name="language",
+            field=models.ManyToManyField(
+                blank=True, related_name="shows", to="program.Language"
+            ),
+        ),
+    ]
diff --git a/program/migrations/0029_auto_20220801_2057.py b/program/migrations/0029_auto_20220801_2057.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6c5aa463c051b1390c6735977c5b5344ed66820
--- /dev/null
+++ b/program/migrations/0029_auto_20220801_2057.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.14 on 2022-08-01 18:57
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0028_auto_20220801_1713"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="note",
+            name="host",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="show",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="start",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="status",
+        ),
+    ]
diff --git a/program/migrations/0030_auto_20220803_2217.py b/program/migrations/0030_auto_20220803_2217.py
new file mode 100644
index 0000000000000000000000000000000000000000..83d3cf964045d849d59b7830bedb4962a055e0e1
--- /dev/null
+++ b/program/migrations/0030_auto_20220803_2217.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.14 on 2022-08-03 20:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0029_auto_20220801_2057"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="note",
+            name="contributors",
+            field=models.ManyToManyField(
+                related_name="contributions", to="program.Host"
+            ),
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="tags",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/program/migrations/0031_auto_20220803_2226.py b/program/migrations/0031_auto_20220803_2226.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4072b02431cfb8751ee82b03e81c8589b9de3cd
--- /dev/null
+++ b/program/migrations/0031_auto_20220803_2226.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.14 on 2022-08-03 20:26
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ("program", "0030_auto_20220803_2217"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="note",
+            name="user",
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="owner",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="notes",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/program/migrations/0032_auto_20220803_2312.py b/program/migrations/0032_auto_20220803_2312.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb5d6b915caefb69e4f3849b86e65c194ec23468
--- /dev/null
+++ b/program/migrations/0032_auto_20220803_2312.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.2.14 on 2022-08-03 21:12
+
+import versatileimagefield.fields
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0031_auto_20220803_2226"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="host",
+            old_name="height",
+            new_name="image_height",
+        ),
+        migrations.RenameField(
+            model_name="host",
+            old_name="ppoi",
+            new_name="image_ppoi",
+        ),
+        migrations.RenameField(
+            model_name="host",
+            old_name="width",
+            new_name="image_width",
+        ),
+        migrations.AddField(
+            model_name="host",
+            name="image_alt_text",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="host",
+            name="image_credits",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="host",
+            name="image",
+            field=versatileimagefield.fields.VersatileImageField(
+                blank=True,
+                height_field="image_height",
+                null=True,
+                upload_to="images",
+                width_field="image_width",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0033_auto_20220803_2331.py b/program/migrations/0033_auto_20220803_2331.py
new file mode 100644
index 0000000000000000000000000000000000000000..2fd4b3ecd2b8da4a15a8c8281d7d72f151134c61
--- /dev/null
+++ b/program/migrations/0033_auto_20220803_2331.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.2.14 on 2022-08-03 21:31
+
+import versatileimagefield.fields
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0032_auto_20220803_2312"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="show",
+            old_name="height",
+            new_name="image_height",
+        ),
+        migrations.RenameField(
+            model_name="show",
+            old_name="ppoi",
+            new_name="image_ppoi",
+        ),
+        migrations.RenameField(
+            model_name="show",
+            old_name="width",
+            new_name="image_width",
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="image_alt_text",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="image_credits",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="show",
+            name="image",
+            field=versatileimagefield.fields.VersatileImageField(
+                blank=True,
+                height_field="image_height",
+                null=True,
+                upload_to="images",
+                width_field="image_width",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0034_auto_20220803_2336.py b/program/migrations/0034_auto_20220803_2336.py
new file mode 100644
index 0000000000000000000000000000000000000000..877286968cac9e3f544feff9a6de0abd8b392657
--- /dev/null
+++ b/program/migrations/0034_auto_20220803_2336.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.2.14 on 2022-08-03 21:36
+
+import versatileimagefield.fields
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0033_auto_20220803_2331"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="note",
+            old_name="height",
+            new_name="image_height",
+        ),
+        migrations.RenameField(
+            model_name="note",
+            old_name="ppoi",
+            new_name="image_ppoi",
+        ),
+        migrations.RenameField(
+            model_name="note",
+            old_name="width",
+            new_name="image_width",
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="image_alt_text",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="image_credits",
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="note",
+            name="image",
+            field=versatileimagefield.fields.VersatileImageField(
+                blank=True,
+                height_field="image_height",
+                null=True,
+                upload_to="images",
+                width_field="image_width",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0035_auto_20220807_2312.py b/program/migrations/0035_auto_20220807_2312.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7caec3af006bd02090d711aca2029949b9c7b33
--- /dev/null
+++ b/program/migrations/0035_auto_20220807_2312.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.14 on 2022-08-07 21:12
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0034_auto_20220803_2336"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="host",
+            name="created_at",
+            field=models.DateTimeField(
+                auto_now_add=True, default=django.utils.timezone.now
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="host",
+            name="created_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="host",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.AddField(
+            model_name="host",
+            name="updated_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+    ]
diff --git a/program/migrations/0036_auto_20220807_2318.py b/program/migrations/0036_auto_20220807_2318.py
new file mode 100644
index 0000000000000000000000000000000000000000..9070d28c567019f5fabad9026c86aa4c2e189e70
--- /dev/null
+++ b/program/migrations/0036_auto_20220807_2318.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.14 on 2022-08-07 21:18
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0035_auto_20220807_2312"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="show",
+            name="created_at",
+            field=models.DateTimeField(
+                auto_now_add=True, default=django.utils.timezone.now
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="created_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.AddField(
+            model_name="show",
+            name="updated_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+    ]
diff --git a/program/migrations/0037_auto_20220807_2321.py b/program/migrations/0037_auto_20220807_2321.py
new file mode 100644
index 0000000000000000000000000000000000000000..dafff82f1f2f6f3823e1d57b7dbb7129f2328e73
--- /dev/null
+++ b/program/migrations/0037_auto_20220807_2321.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.14 on 2022-08-07 21:21
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0036_auto_20220807_2318"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="note",
+            name="created_at",
+            field=models.DateTimeField(
+                auto_now_add=True, default=django.utils.timezone.now
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="created_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.AddField(
+            model_name="note",
+            name="updated_by",
+            field=models.CharField(default="root", max_length=150),
+            preserve_default=False,
+        ),
+    ]
diff --git a/program/migrations/0038_auto_20220817_2132.py b/program/migrations/0038_auto_20220817_2132.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c6ace5fe149f1b7f81e69c27bd7285d0ad4bfd8
--- /dev/null
+++ b/program/migrations/0038_auto_20220817_2132.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.2.15 on 2022-08-17 19:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0037_auto_20220807_2321"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="host",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="host",
+            name="updated_by",
+            field=models.CharField(blank=True, max_length=150, null=True),
+        ),
+        migrations.AlterField(
+            model_name="note",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="note",
+            name="updated_by",
+            field=models.CharField(blank=True, max_length=150, null=True),
+        ),
+        migrations.AlterField(
+            model_name="show",
+            name="updated_at",
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="show",
+            name="updated_by",
+            field=models.CharField(blank=True, max_length=150, null=True),
+        ),
+    ]
diff --git a/program/migrations/0039_auto_20221002_2307.py b/program/migrations/0039_auto_20221002_2307.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06d3e562774548d613511241006a1cae0fe63c0
--- /dev/null
+++ b/program/migrations/0039_auto_20221002_2307.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.2.15 on 2022-10-02 21:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0038_auto_20220817_2132"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="hostlink",
+            name="description",
+        ),
+        migrations.RemoveField(
+            model_name="notelink",
+            name="description",
+        ),
+        migrations.RemoveField(
+            model_name="showlink",
+            name="description",
+        ),
+        migrations.AddField(
+            model_name="hostlink",
+            name="type",
+            field=models.CharField(default="Link", max_length=32),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="notelink",
+            name="type",
+            field=models.CharField(default="Link", max_length=32),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="showlink",
+            name="type",
+            field=models.CharField(default="Link", max_length=32),
+            preserve_default=False,
+        ),
+    ]
diff --git a/program/migrations/0040_linktype.py b/program/migrations/0040_linktype.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e775313b50b81e0cd34d6c86c22a524881b6115
--- /dev/null
+++ b/program/migrations/0040_linktype.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.15 on 2022-10-02 21:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0039_auto_20221002_2307"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="LinkType",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("name", models.CharField(help_text="Name of the link type", max_length=16)),
+                ("type", models.CharField(help_text="Type of the link", max_length=32)),
+            ],
+            options={
+                "ordering": ("name",),
+            },
+        ),
+    ]
diff --git a/program/migrations/0041_licensetype.py b/program/migrations/0041_licensetype.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff2521bfa14dc8528f2e638764d05189a1f00ac9
--- /dev/null
+++ b/program/migrations/0041_licensetype.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.15 on 2022-10-02 21:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0040_linktype"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="LicenseType",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("name", models.CharField(help_text="Name of the license type", max_length=16)),
+                ("type", models.CharField(help_text="Type of the license", max_length=64)),
+            ],
+            options={
+                "ordering": ("name",),
+            },
+        ),
+    ]
diff --git a/program/migrations/0042_userprofile.py b/program/migrations/0042_userprofile.py
new file mode 100644
index 0000000000000000000000000000000000000000..27fb6012ee7690bbae797dafd7c7ac56fa347ac7
--- /dev/null
+++ b/program/migrations/0042_userprofile.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.2.16 on 2022-10-11 22:00
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ("program", "0041_licensetype"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="UserProfile",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("created_at", models.DateTimeField(auto_now_add=True)),
+                ("created_by", models.CharField(max_length=150)),
+                ("updated_at", models.DateTimeField(auto_now=True, null=True)),
+                ("updated_by", models.CharField(blank=True, max_length=150, null=True)),
+                (
+                    "cba_username",
+                    models.CharField(blank=True, max_length=60, verbose_name="CBA Username"),
+                ),
+                (
+                    "cba_user_token",
+                    models.CharField(blank=True, max_length=255, verbose_name="CBA Token"),
+                ),
+                (
+                    "user",
+                    models.OneToOneField(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="profile",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+    ]
diff --git a/program/migrations/0043_note_playlist.py b/program/migrations/0043_note_playlist.py
new file mode 100644
index 0000000000000000000000000000000000000000..52b126e0398152f1da393de3ffeadb2434e71e61
--- /dev/null
+++ b/program/migrations/0043_note_playlist.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-10-19 19:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0042_userprofile"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="note",
+            name="playlist",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/program/migrations/0044_alter_linktype_type.py b/program/migrations/0044_alter_linktype_type.py
new file mode 100644
index 0000000000000000000000000000000000000000..163060fbf8dd0f493af2de3b5eeb77d46ff7eb63
--- /dev/null
+++ b/program/migrations/0044_alter_linktype_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.16 on 2022-10-19 21:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0043_note_playlist"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="linktype",
+            name="type",
+            field=models.CharField(help_text="Type of the link", max_length=64),
+        ),
+    ]
diff --git a/program/migrations/0045_auto_20221021_2008.py b/program/migrations/0045_auto_20221021_2008.py
new file mode 100644
index 0000000000000000000000000000000000000000..7fec78e26cb5530aae80d7e9330ee2f55dbb0e9e
--- /dev/null
+++ b/program/migrations/0045_auto_20221021_2008.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.16 on 2022-10-21 18:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0044_alter_linktype_type"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="hostlink",
+            name="type",
+            field=models.CharField(max_length=64),
+        ),
+        migrations.AlterField(
+            model_name="notelink",
+            name="type",
+            field=models.CharField(max_length=64),
+        ),
+        migrations.AlterField(
+            model_name="showlink",
+            name="type",
+            field=models.CharField(max_length=64),
+        ),
+    ]
diff --git a/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py b/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py
new file mode 100644
index 0000000000000000000000000000000000000000..68c0513fa867879c806071e66852a05382e06451
--- /dev/null
+++ b/program/migrations/0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.18 on 2023-03-27 15:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('program', '0025_auto_20230326_2211'),
+        ('program', '0045_auto_20221021_2008'),
+    ]
+
+    operations = [
+    ]
diff --git a/program/migrations/0047_image.py b/program/migrations/0047_image.py
new file mode 100644
index 0000000000000000000000000000000000000000..92c1d5fb87be122cc0fdaa9e186a0443f0d88073
--- /dev/null
+++ b/program/migrations/0047_image.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.2.18 on 2023-03-29 01:33
+
+import versatileimagefield.fields
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("program", "0046_merge_0025_auto_20230326_2211_0045_auto_20221021_2008"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Image",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("alt_text", models.TextField(blank=True, null=True)),
+                ("credits", models.TextField(blank=True, null=True)),
+                ("height", models.PositiveIntegerField(blank=True, editable=False, null=True)),
+                (
+                    "image",
+                    versatileimagefield.fields.VersatileImageField(
+                        blank=True,
+                        height_field="height",
+                        null=True,
+                        upload_to="images",
+                        width_field="width",
+                    ),
+                ),
+                ("owner", models.CharField(max_length=150)),
+                (
+                    "ppoi",
+                    versatileimagefield.fields.PPOIField(
+                        default="0.5x0.5", editable=False, max_length=20
+                    ),
+                ),
+                ("width", models.PositiveIntegerField(blank=True, editable=False, null=True)),
+            ],
+        ),
+    ]
diff --git a/program/migrations/0048_auto_20230403_2228.py b/program/migrations/0048_auto_20230403_2228.py
new file mode 100644
index 0000000000000000000000000000000000000000..88b38cfb3888867d7d0d1b51cb2f8317aea7f949
--- /dev/null
+++ b/program/migrations/0048_auto_20230403_2228.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.18 on 2023-04-03 20:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("program", "0047_image"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="host",
+            name="image_alt_text",
+        ),
+        migrations.RemoveField(
+            model_name="host",
+            name="image_credits",
+        ),
+        migrations.RemoveField(
+            model_name="host",
+            name="image_height",
+        ),
+        migrations.RemoveField(
+            model_name="host",
+            name="image_ppoi",
+        ),
+        migrations.RemoveField(
+            model_name="host",
+            name="image_width",
+        ),
+        migrations.RemoveField(model_name="host", name="image"),
+        migrations.AddField(
+            model_name="host",
+            name="image",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="hosts",
+                to="program.image",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0049_auto_20230404_0020.py b/program/migrations/0049_auto_20230404_0020.py
new file mode 100644
index 0000000000000000000000000000000000000000..27e28e6c0ae999629ac6e627d72d50c3037fb141
--- /dev/null
+++ b/program/migrations/0049_auto_20230404_0020.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.18 on 2023-04-03 22:20
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("program", "0048_auto_20230403_2228"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="note",
+            name="image_alt_text",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="image_credits",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="image_height",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="image_ppoi",
+        ),
+        migrations.RemoveField(
+            model_name="note",
+            name="image_width",
+        ),
+        migrations.RemoveField(model_name="note", name="image"),
+        migrations.AddField(
+            model_name="note",
+            name="image",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="notes",
+                to="program.image",
+            ),
+        ),
+    ]
diff --git a/program/migrations/0050_auto_20230404_0037.py b/program/migrations/0050_auto_20230404_0037.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e13018a67d05129a5fb056418c437d156103eb4
--- /dev/null
+++ b/program/migrations/0050_auto_20230404_0037.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.18 on 2023-04-03 22:37
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("program", "0049_auto_20230404_0020"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="show",
+            name="image_alt_text",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="image_credits",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="image_height",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="image_ppoi",
+        ),
+        migrations.RemoveField(
+            model_name="show",
+            name="image_width",
+        ),
+        migrations.RemoveField(model_name="show", name="image"),
+        migrations.AddField(
+            model_name="show",
+            name="image",
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="shows",
+                to="program.image",
+            ),
+        ),
+    ]
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 5457ea2c51b72b23753b0881d5939abb066effdc..15ff88631c07996c03bf95c54718d591b0ab0dfd 100644
--- a/program/models.py
+++ b/program/models.py
@@ -18,26 +18,17 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
-from datetime import datetime, time, timedelta
+from datetime import datetime
 
-from dateutil.relativedelta import relativedelta
-from dateutil.rrule import rrule
 from rest_framework.exceptions import ValidationError
 from versatileimagefield.fields import PPOIField, VersatileImageField
 
 from django.contrib.auth.models import User
-from django.core.exceptions import ObjectDoesNotExist
 from django.db import models
 from django.db.models import Q, QuerySet
-from django.forms.models import model_to_dict
-from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
-from program.utils import parse_date, parse_datetime, parse_time
-from steering.settings import (
-    AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE,
-    AUTO_SET_LAST_DATE_TO_END_OF_YEAR,
-    THUMBNAIL_SIZES,
-)
+from program.utils import parse_datetime
+from steering.settings import THUMBNAIL_SIZES
 
 
 class ScheduleConflictError(ValidationError):
@@ -47,9 +38,9 @@ class ScheduleConflictError(ValidationError):
 
 
 class Type(models.Model):
+    is_active = models.BooleanField(default=True)
     name = models.CharField(max_length=32)
     slug = models.SlugField(max_length=32, unique=True)
-    is_active = models.BooleanField(default=True)
 
     class Meta:
         ordering = ("name",)
@@ -59,11 +50,11 @@ class Type(models.Model):
 
 
 class Category(models.Model):
+    description = models.TextField(blank=True)
+    is_active = models.BooleanField(default=True)
     name = models.CharField(max_length=32)
-    abbrev = models.CharField(max_length=4, unique=True)
     slug = models.SlugField(max_length=32, unique=True)
-    is_active = models.BooleanField(default=True)
-    description = models.TextField(blank=True)
+    subtitle = models.TextField(blank=True, null=True)
 
     class Meta:
         ordering = ("name",)
@@ -74,10 +65,9 @@ class Category(models.Model):
 
 
 class Topic(models.Model):
+    is_active = models.BooleanField(default=True)
     name = models.CharField(max_length=32)
-    abbrev = models.CharField(max_length=4, unique=True)
     slug = models.SlugField(max_length=32, unique=True)
-    is_active = models.BooleanField(default=True)
 
     class Meta:
         ordering = ("name",)
@@ -87,10 +77,9 @@ class Topic(models.Model):
 
 
 class MusicFocus(models.Model):
+    is_active = models.BooleanField(default=True)
     name = models.CharField(max_length=32)
-    abbrev = models.CharField(max_length=4, unique=True)
     slug = models.SlugField(max_length=32, unique=True)
-    is_active = models.BooleanField(default=True)
 
     class Meta:
         ordering = ("name",)
@@ -101,10 +90,9 @@ class MusicFocus(models.Model):
 
 
 class FundingCategory(models.Model):
+    is_active = models.BooleanField(default=True)
     name = models.CharField(max_length=32)
-    abbrev = models.CharField(max_length=4, unique=True)
     slug = models.SlugField(max_length=32, unique=True)
-    is_active = models.BooleanField(default=True)
 
     class Meta:
         ordering = ("name",)
@@ -115,33 +103,56 @@ class FundingCategory(models.Model):
 
 
 class Language(models.Model):
-    name = models.CharField(max_length=32)
     is_active = models.BooleanField(default=True)
+    name = models.CharField(max_length=32)
 
     class Meta:
-        ordering = ("language",)
+        ordering = ("name",)
 
     def __str__(self):
         return self.name
 
 
-class Host(models.Model):
-    name = models.CharField(max_length=128)
-    is_active = models.BooleanField(default=True)
-    email = models.EmailField(blank=True)
-    website = models.URLField(blank=True)
-    biography = models.TextField(blank=True, null=True)
-    ppoi = PPOIField()
+class Image(models.Model):
+    alt_text = models.TextField(blank=True, default="")
+    credits = models.TextField(blank=True, default="")
     height = models.PositiveIntegerField(blank=True, null=True, editable=False)
-    width = models.PositiveIntegerField(blank=True, null=True, editable=False)
     image = VersatileImageField(
         blank=True,
-        null=True,
-        upload_to="host_images",
-        width_field="width",
         height_field="height",
+        null=True,
         ppoi_field="ppoi",
+        upload_to="images",
+        width_field="width",
     )
+    owner = models.CharField(max_length=150)
+    ppoi = PPOIField()
+    width = models.PositiveIntegerField(blank=True, null=True, editable=False)
+
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+
+        if self.image.name and THUMBNAIL_SIZES:
+            for size in THUMBNAIL_SIZES:
+                self.image.thumbnail = self.image.crop[size].name
+
+    def delete(self, using=None, keep_parents=False):
+        self.image.delete_all_created_images()
+        self.image.delete(save=False)
+
+        super().delete(using, keep_parents)
+
+
+class Host(models.Model):
+    biography = models.TextField(blank=True, null=True)
+    created_at = models.DateTimeField(auto_now_add=True)
+    created_by = models.CharField(max_length=150)
+    email = models.EmailField(blank=True)
+    image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="hosts")
+    is_active = models.BooleanField(default=True)
+    name = models.CharField(max_length=128)
+    updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+    updated_by = models.CharField(blank=True, max_length=150, null=True)
 
     class Meta:
         ordering = ("name",)
@@ -149,17 +160,20 @@ class Host(models.Model):
     def __str__(self):
         return self.name
 
-    def save(self, *args, **kwargs):
-        super(Host, self).save(*args, **kwargs)
 
-        # Generate thumbnails
-        if self.image.name and THUMBNAIL_SIZES:
-            for size in THUMBNAIL_SIZES:
-                self.image.thumbnail = self.image.crop[size].name
+class LinkType(models.Model):
+    name = models.CharField(max_length=16, help_text="Name of the link type")
+    type = models.CharField(max_length=64, help_text="Type of the link")
+
+    class Meta:
+        ordering = ("name",)
+
+    def __str__(self):
+        return self.type
 
 
 class Link(models.Model):
-    description = models.CharField(max_length=16)
+    type = models.CharField(max_length=64)
     url = models.URLField()
 
     class Meta:
@@ -173,21 +187,25 @@ class HostLink(Link):
     host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="links")
 
 
+class LicenseType(models.Model):
+    name = models.CharField(max_length=16, help_text="Name of the license type")
+    type = models.CharField(max_length=64, help_text="Type of the license")
+
+    class Meta:
+        ordering = ("name",)
+
+    def __str__(self):
+        return self.type
+
+
 class Show(models.Model):
-    predecessor = models.ForeignKey(
-        "self",
-        blank=True,
-        null=True,
-        on_delete=models.CASCADE,
-        related_name="successors",
-    )
-    hosts = models.ManyToManyField(Host, blank=True, related_name="shows")
-    owners = models.ManyToManyField(User, blank=True, related_name="shows")
-    language = models.ManyToManyField(Language, blank=True, related_name="language")
-    type = models.ForeignKey(
-        Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows"
-    )
     category = models.ManyToManyField(Category, blank=True, related_name="shows")
+    cba_series_id = models.IntegerField(blank=True, null=True)
+    created_at = models.DateTimeField(auto_now_add=True)
+    created_by = models.CharField(max_length=150)
+    default_playlist_id = models.IntegerField(blank=True, null=True)
+    description = models.TextField(blank=True, null=True)
+    email = models.EmailField(blank=True, null=True)
     funding_category = models.ForeignKey(
         FundingCategory,
         null=True,
@@ -195,30 +213,38 @@ class Show(models.Model):
         blank=True,
         related_name="shows",
     )
-    topic = models.ManyToManyField(Topic, blank=True, related_name="shows")
+    hosts = models.ManyToManyField(Host, blank=True, related_name="shows")
+    image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="shows")
+    internal_note = models.TextField(blank=True, null=True)
+    is_active = models.BooleanField(default=True)
+    is_public = models.BooleanField(default=False)
+    language = models.ManyToManyField(Language, blank=True, related_name="shows")
+    # 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)
-    slug = models.CharField(max_length=255, unique=True)
-    ppoi = PPOIField()
-    height = models.PositiveIntegerField(blank=True, null=True, editable=False)
-    width = models.PositiveIntegerField(blank=True, null=True, editable=False)
-    image = VersatileImageField(
+    owners = models.ManyToManyField(User, blank=True, related_name="shows")
+    predecessor = models.ForeignKey(
+        "self",
         blank=True,
         null=True,
-        upload_to="show_images",
-        width_field="width",
-        height_field="height",
-        ppoi_field="ppoi",
+        on_delete=models.CASCADE,
+        related_name="successors",
     )
-    logo = models.ImageField(blank=True, null=True, upload_to="show_images")
     short_description = models.TextField()
-    description = models.TextField(blank=True, null=True)
-    email = models.EmailField(blank=True, null=True)
-    website = models.URLField(blank=True, null=True)
-    cba_series_id = models.IntegerField(blank=True, null=True)
-    default_playlist_id = models.IntegerField(blank=True, null=True)
-    is_active = models.BooleanField(default=True)
-    is_public = models.BooleanField(default=False)
+    slug = models.CharField(max_length=255, unique=True)
+    topic = models.ManyToManyField(Topic, blank=True, related_name="shows")
+    type = models.ForeignKey(
+        Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows"
+    )
+    updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+    updated_by = models.CharField(blank=True, max_length=150, null=True)
 
     class Meta:
         ordering = ("slug",)
@@ -232,19 +258,6 @@ class ShowLink(Link):
 
 
 class RRule(models.Model):
-    name = models.CharField(max_length=32, unique=True)
-    freq = models.IntegerField(
-        choices=[
-            (0, "once"),
-            (1, "monthly"),
-            (2, "weekly"),
-            (3, "daily"),
-        ]
-    )
-    interval = models.IntegerField(
-        default=1,
-        help_text="The interval between each freq iteration.",
-    )
     by_set_pos = models.IntegerField(
         blank=True,
         choices=[
@@ -272,6 +285,19 @@ class RRule(models.Model):
         null=True,
         help_text="How many occurrences should be generated.",
     )
+    freq = models.IntegerField(
+        choices=[
+            (0, "once"),
+            (1, "monthly"),
+            (2, "weekly"),
+            (3, "daily"),
+        ]
+    )
+    interval = models.IntegerField(
+        default=1,
+        help_text="The interval between each freq iteration.",
+    )
+    name = models.CharField(max_length=32, unique=True)
 
     class Meta:
         ordering = ("pk",)
@@ -283,17 +309,20 @@ class RRule(models.Model):
 
 
 class Schedule(models.Model):
-    rrule = models.ForeignKey(
-        RRule,
-        on_delete=models.CASCADE,
-        related_name="schedules",
-        help_text="A recurrence rule.",
+    add_business_days_only = models.BooleanField(
+        default=False,
+        help_text=(
+            "Whether to add add_days_no but skipping the weekends. "
+            "E.g. if weekday is Friday, the date returned will be the next Monday."
+        ),
     )
-    show = models.ForeignKey(
-        Show,
-        on_delete=models.CASCADE,
-        related_name="schedules",
-        help_text="Show the schedule belongs to.",
+    add_days_no = models.IntegerField(
+        blank=True,
+        null=True,
+        help_text=(
+            "Add a number of days to the generated dates. "
+            "This can be useful for repetitions, like 'On the following day'."
+        ),
     )
     by_weekday = models.IntegerField(
         help_text="Number of the Weekday.",
@@ -308,719 +337,35 @@ class Schedule(models.Model):
         ],
         null=True,
     )
-    first_date = models.DateField(help_text="Start date of schedule.")
-    start_time = models.TimeField(help_text="Start time of schedule.")
+    default_playlist_id = models.IntegerField(
+        blank=True,
+        null=True,
+        help_text="A tank ID in case the timeslot's playlist_id is empty.",
+    )
     end_time = models.TimeField(help_text="End time of schedule.")
-    last_date = models.DateField(help_text="End date of schedule.")
+    first_date = models.DateField(help_text="Start date of schedule.")
     is_repetition = models.BooleanField(
         default=False,
         help_text="Whether the schedule is a repetition.",
     )
-    add_days_no = models.IntegerField(
-        blank=True,
-        null=True,
-        help_text=(
-            "Add a number of days to the generated dates. "
-            "This can be useful for repetitions, like 'On the following day'."
-        ),
-    )
-    add_business_days_only = models.BooleanField(
-        default=False,
-        help_text=(
-            "Whether to add add_days_no but skipping the weekends. "
-            "E.g. if weekday is Friday, the date returned will be the next Monday."
-        ),
+    last_date = models.DateField(help_text="End date of schedule.")
+    rrule = models.ForeignKey(
+        RRule,
+        on_delete=models.CASCADE,
+        related_name="schedules",
+        help_text="A recurrence rule.",
     )
-    default_playlist_id = models.IntegerField(
-        blank=True,
-        null=True,
-        help_text="A tank ID in case the timeslot's playlist_id is empty.",
+    show = models.ForeignKey(
+        Show,
+        on_delete=models.CASCADE,
+        related_name="schedules",
+        help_text="Show the schedule belongs to.",
     )
+    start_time = models.TimeField(help_text="Start time of schedule.")
 
     class Meta:
         ordering = ("first_date", "start_time")
 
-    # FIXME: this does not belong here
-    @staticmethod
-    def instantiate_upcoming(sdl, show_pk, pk=None):
-        """Returns an upcoming schedule instance for conflict resolution"""
-        pk = int(pk) if pk is not None else None
-        rrule = RRule.objects.get(pk=int(sdl["rrule"]))
-        show = Show.objects.get(pk=int(show_pk))
-
-        is_repetition = True if sdl.get("is_repetition") is True else False
-        default_playlist_id = (
-            int(sdl["default_playlist_id"]) if sdl.get("default_playlist_id") else None
-        )
-        add_days_no = int(sdl["add_days_no"]) if sdl.get("add_days_no") else None
-        add_business_days_only = True if sdl.get("add_business_days_only") is True else False
-
-        first_date = parse_date(str(sdl["first_date"]))
-        start_time = (
-            sdl["start_time"] + ":00" if len(str(sdl["start_time"])) == 5 else sdl["start_time"]
-        )
-        end_time = sdl["end_time"] + ":00" if len(str(sdl["end_time"])) == 5 else sdl["end_time"]
-
-        start_time = parse_time(str(start_time))
-        end_time = parse_time(str(end_time))
-
-        if sdl["last_date"]:
-            last_date = parse_date(str(sdl["last_date"]))
-        else:
-            # If last_date was not set, set it to the end of the year or add x days
-            if AUTO_SET_LAST_DATE_TO_END_OF_YEAR:
-                year = timezone.now().year
-                last_date = parse_date(f"{year}-12-31")
-            else:
-                last_date = first_date + timedelta(days=+AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE)
-
-        schedule = Schedule(
-            pk=pk,
-            by_weekday=sdl["by_weekday"],
-            rrule=rrule,
-            first_date=first_date,
-            start_time=start_time,
-            end_time=end_time,
-            last_date=last_date,
-            is_repetition=is_repetition,
-            default_playlist_id=default_playlist_id,
-            show=show,
-            add_days_no=add_days_no,
-            add_business_days_only=add_business_days_only,
-        )
-
-        return schedule
-
-    # FIXME: this does not belong here
-    @staticmethod
-    def generate_timeslots(schedule):
-        """
-        Returns a list of timeslot objects based on a schedule and its rrule
-        Returns past timeslots as well, starting from first_date (not today)
-        """
-        timeslots = []
-
-        # adjust last_date if end_time is after midnight
-        if schedule.end_time < schedule.start_time:
-            last_date = schedule.first_date + timedelta(days=+1)
-        else:
-            last_date = schedule.first_date
-
-        if schedule.rrule.freq == 3:  # daily: Ignore schedule.by_weekday to set by_weekday
-            by_weekday_start = by_weekday_end = (0, 1, 2, 3, 4, 5, 6)
-        elif (
-            schedule.rrule.freq == 2
-            and schedule.rrule.interval == 1
-            and schedule.rrule.by_weekdays is None
-        ):  # weekly: Use schedule.by_weekday for by_weekday
-            by_weekday_start = by_weekday_end = int(schedule.by_weekday)
-
-            # adjust by_weekday_end if end_time is after midnight
-            if schedule.end_time < schedule.start_time:
-                by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0
-        elif (
-            schedule.rrule.freq == 2
-            and schedule.rrule.interval == 1
-            and schedule.rrule.by_weekdays == "0,1,2,3,4"
-        ):  # weekly on business days: Use schedule.rrule.by_weekdays to set by_weekday
-            by_weekday_start = by_weekday_end = [
-                int(wd) for wd in schedule.rrule.by_weekdays.split(",")
-            ]
-
-            # adjust by_weekday_end if end_time is after midnight
-            if schedule.end_time < schedule.start_time:
-                by_weekday_end = (1, 2, 3, 4, 5)
-        elif (
-            schedule.rrule.freq == 2
-            and schedule.rrule.interval == 1
-            and schedule.rrule.by_weekdays == "5,6"
-        ):  # weekly on weekends: Use schedule.rrule.by_weekdays to set by_weekday
-            by_weekday_start = by_weekday_end = [
-                int(wd) for wd in schedule.rrule.by_weekdays.split(",")
-            ]
-
-            # adjust by_weekday_end if end_time is after midnight
-            if schedule.end_time < schedule.start_time:
-                by_weekday_end = (6, 0)
-        elif schedule.rrule.freq == 0:  # once: Ignore schedule.by_weekday to set by_weekday
-            by_weekday_start = by_weekday_end = None
-        else:
-            by_weekday_start = by_weekday_end = (
-                int(schedule.by_weekday) if schedule.by_weekday is not None else None
-            )
-
-            # adjust by_weekday_end if end_time is after midnight
-            if schedule.end_time < schedule.start_time:
-                by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0
-
-        if schedule.rrule.freq == 0:  # once:
-            starts = [datetime.combine(schedule.first_date, schedule.start_time)]
-            ends = [datetime.combine(last_date, schedule.end_time)]
-        else:
-            starts = list(
-                rrule(
-                    freq=schedule.rrule.freq,
-                    dtstart=datetime.combine(schedule.first_date, schedule.start_time),
-                    interval=schedule.rrule.interval,
-                    until=schedule.last_date + relativedelta(days=+1),
-                    bysetpos=schedule.rrule.by_set_pos,
-                    byweekday=by_weekday_start,
-                )
-            )
-            ends = list(
-                rrule(
-                    freq=schedule.rrule.freq,
-                    dtstart=datetime.combine(last_date, schedule.end_time),
-                    interval=schedule.rrule.interval,
-                    until=schedule.last_date + relativedelta(days=+1),
-                    bysetpos=schedule.rrule.by_set_pos,
-                    byweekday=by_weekday_end,
-                )
-            )
-
-        for k in range(min(len(starts), len(ends))):
-            # Correct dates for the (relatively seldom) case if:
-            # E.g.: 1st Monday from 23:00:00 to 1st Tuesday 00:00:00
-            #       produces wrong end dates if the 1st Tuesday is before the 1st Monday
-            #       In this case we take the next day instead of rrule's calculated end
-            if starts[k] > ends[k]:
-                ends[k] = datetime.combine(starts[k] + relativedelta(days=+1), schedule.end_time)
-
-            """
-            Add a number of days to the generated dates?
-
-            This can be helpful for repetitions:
-
-            Examples:
-
-              1. If RRule is "Every 1st Monday" and we want its repetition alyways to be on the
-                 following day, the repetition's RRule is the same but add_days_no is 1
-
-                 If we would set the repetition to "Every 1st Tuesday" instead
-                 we will get unmeant results if the 1st Tuesday is before the 1st Monday
-                 (e.g. 1st Tue = May 1 2018, 1st Mon = May 7 2018)
-
-              2. If RRule is "Every 1st Friday" and we want its repetition always to be on the
-                 following business day, the repetition's RRule is the same but add_days_no is 1
-                 and add_business_days_only is True (e.g. original date = Fri, March 2 2018;
-                 generated date = Mon, March 5 2018)
-
-            In the UI these can be presets:
-                "On the following day" (add_days_no=1,add_business_days_only=False) or
-                "On the following business day" (add_days_no=1,add_business_days_only=True)
-
-            """
-            if schedule.add_days_no is not None and schedule.add_days_no > 0:
-                # If only business days and weekday is Fri, Sat or Sun: add add_days_no beginning
-                # from Sunday
-                weekday = datetime.date(starts[k]).weekday()
-                if schedule.add_business_days_only and weekday > 3:
-                    days_until_sunday = 6 - weekday
-                    starts[k] = starts[k] + relativedelta(
-                        days=+days_until_sunday + schedule.add_days_no
-                    )
-                    ends[k] = ends[k] + relativedelta(
-                        days=+days_until_sunday + schedule.add_days_no
-                    )
-                else:
-                    starts[k] = starts[k] + relativedelta(days=+schedule.add_days_no)
-                    ends[k] = ends[k] + relativedelta(days=+schedule.add_days_no)
-
-                if ends[k].date() > schedule.last_date:
-                    schedule.last_date = ends[k].date()
-            timeslots.append(
-                TimeSlot(
-                    schedule=schedule,
-                    start=timezone.make_aware(starts[k], is_dst=True),
-                    end=timezone.make_aware(ends[k], is_dst=True),
-                )
-            )
-
-        return timeslots
-
-    # FIXME: this does not belong here
-    @staticmethod
-    def get_collisions(timeslots):
-        """
-        Tests a list of timeslot objects for colliding timeslots in the database
-        Returns a list of collisions, containing colliding timeslot IDs or None
-        Keeps indices from input list for later comparison
-        """
-
-        collisions = []
-
-        for ts in timeslots:
-            collision = TimeSlot.objects.get_colliding_timeslots(ts)
-
-            if collision:
-                collisions.append(collision[0])  # TODO: Do we really always retrieve one?
-            else:
-                collisions.append(None)
-
-        return collisions
-
-    # FIXME: this does not belong here
-    @staticmethod
-    def generate_conflicts(timeslots):
-        """
-        Tests a list of timeslot objects for colliding timeslots in the database
-        Returns a list of conflicts containing dicts of projected timeslots, collisions and
-        solutions
-        """
-
-        conflicts = {}
-        projected = []
-        solutions = {}
-
-        # Cycle each timeslot
-        for ts in timeslots:
-            # Contains collisions
-            collisions = []
-
-            # Contains possible solutions
-            solution_choices = set()
-
-            # Get collisions for each timeslot
-            collision_list = list(TimeSlot.objects.get_colliding_timeslots(ts).order_by("start"))
-
-            # Add the projected timeslot
-            projected_entry = {
-                "hash": ts.hash,
-                "start": str(ts.start),
-                "end": str(ts.end),
-            }
-
-            for c in collision_list:
-                # Add the collision
-                collision = {
-                    "id": c.id,
-                    "start": str(c.start),
-                    "end": str(c.end),
-                    "playlist_id": c.playlist_id,
-                    "show": c.show.id,
-                    "show_name": c.show.name,
-                    "is_repetition": c.is_repetition,
-                    "schedule": c.schedule_id,
-                    "memo": c.memo,
-                }
-
-                # Get note
-                try:
-                    note = Note.objects.get(timeslot=c.id)
-                    collision["note_id"] = note.pk
-                except ObjectDoesNotExist:
-                    collision["note_id"] = None
-
-                collisions.append(collision)
-
-                """Determine acceptable solutions"""
-
-                if len(collision_list) > 1:
-                    # If there is more than one collision: Only these two are supported at the
-                    # moment
-                    solution_choices.add("theirs")
-                    solution_choices.add("ours")
-                else:
-                    # These two are always possible: Either keep theirs and remove ours or vice
-                    # versa
-                    solution_choices.add("theirs")
-                    solution_choices.add("ours")
-
-                    # Partly overlapping: projected starts earlier than existing and ends earlier
-                    #
-                    #    ex.  pr.
-                    #        +--+
-                    #        |  |
-                    #   +--+ |  |
-                    #   |  | +--+
-                    #   |  |
-                    #   +--+
-                    #
-                    if ts.end > c.start > ts.start <= c.end:
-                        solution_choices.add("theirs-end")
-                        solution_choices.add("ours-end")
-
-                    # Partly overlapping: projected starts later than existing and ends later
-                    #
-                    #    ex.  pr.
-                    #   +--+
-                    #   |  |
-                    #   |  | +--+
-                    #   +--+ |  |
-                    #        |  |
-                    #        +--+
-                    #
-                    if c.start <= ts.start < c.end < ts.end:
-                        solution_choices.add("theirs-start")
-                        solution_choices.add("ours-start")
-
-                    # Fully overlapping: projected starts earlier and ends later than existing
-                    #
-                    #    ex.  pr.
-                    #        +--+
-                    #   +--+ |  |
-                    #   |  | |  |
-                    #   +--+ |  |
-                    #        +--+
-                    #
-                    if ts.start < c.start and ts.end > c.end:
-                        solution_choices.add("theirs-end")
-                        solution_choices.add("theirs-start")
-                        solution_choices.add("theirs-both")
-
-                    # Fully overlapping: projected starts later and ends earlier than existing
-                    #
-                    #    ex.  pr.
-                    #   +--+
-                    #   |  | +--+
-                    #   |  | |  |
-                    #   |  | +--+
-                    #   +--+
-                    #
-                    if ts.start > c.start and ts.end < c.end:
-                        solution_choices.add("ours-end")
-                        solution_choices.add("ours-start")
-                        solution_choices.add("ours-both")
-
-            if len(collisions) > 0:
-                solutions[ts.hash] = ""
-
-            projected_entry["collisions"] = collisions
-            projected_entry["solution_choices"] = solution_choices
-            projected_entry["error"] = None
-            projected.append(projected_entry)
-
-        conflicts["projected"] = projected
-        conflicts["solutions"] = solutions
-        conflicts["notes"] = {}
-        conflicts["playlists"] = {}
-
-        return conflicts
-
-    # FIXME: this does not belong here
-    @staticmethod
-    def make_conflicts(sdl, schedule_pk, show_pk):
-        """
-        Retrieves POST vars
-        Generates a schedule
-        Generates conflicts: Returns timeslots, collisions, solutions as JSON
-        Returns conflicts dict
-        """
-
-        # Generate schedule to be saved
-        schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
-
-        # Copy if first_date changes for generating timeslots
-        gen_schedule = schedule
-
-        # Generate timeslots
-
-        # If extending: Get last timeslot and start generating from that date on
-        if schedule_pk is not None:
-            existing_schedule = Schedule.objects.get(pk=int(schedule_pk))
-
-            if schedule.last_date > existing_schedule.last_date:
-                last_timeslot = (
-                    TimeSlot.objects.filter(schedule=existing_schedule)
-                    .order_by("start")
-                    .reverse()[0]
-                )
-                gen_schedule.first_date = last_timeslot.start.date() + timedelta(days=1)
-
-        timeslots = Schedule.generate_timeslots(gen_schedule)
-
-        # Generate conflicts and add schedule
-        conflicts = Schedule.generate_conflicts(timeslots)
-        conflicts["schedule"] = model_to_dict(schedule)
-
-        return conflicts
-
-    # FIXME: this does not belong here
-    @staticmethod
-    def resolve_conflicts(data, schedule_pk, show_pk):
-        """
-        Resolves conflicts
-        Expects JSON POST/PUT data from /shows/1/schedules/
-
-        Returns a list of dicts if errors were found
-        Returns an empty list if resolution was successful
-        """
-
-        sdl = data["schedule"]
-        solutions = data.get("solutions", [])
-
-        # Regenerate conflicts
-        schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk)
-        show = schedule.show
-        conflicts = Schedule.make_conflicts(sdl, schedule_pk, show_pk)
-
-        if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date:
-            raise ValidationError(
-                _("Start and end dates must not be the same."),
-                code="no-same-day-start-and-end",
-            )
-
-        if schedule.last_date < schedule.first_date:
-            raise ValidationError(
-                _("End date mustn't be before start."),
-                code="no-start-after-end",
-            )
-
-        num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0])
-
-        if len(solutions) != num_conflicts:
-            raise ScheduleConflictError(
-                _("Numbers of conflicts and solutions don't match."),
-                code="one-solution-per-conflict",
-                conflicts=conflicts,
-            )
-
-        # Projected timeslots to create
-        create = []
-
-        # Existing timeslots to update
-        update = []
-
-        # Existing timeslots to delete
-        delete = []
-
-        # Error messages
-        errors = {}
-
-        for ts in conflicts["projected"]:
-            # If no solution necessary
-            #
-            #     - Create the projected timeslot and skip
-            #
-            if "solution_choices" not in ts or len(ts["collisions"]) < 1:
-                projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show)
-                create.append(projected_ts)
-                continue
-
-            # Check hash (if start, end, rrule or byweekday changed)
-            if not ts["hash"] in solutions:
-                errors[ts["hash"]] = _("This change on the timeslot is not allowed.")
-                continue
-
-            # If no resolution given
-            #
-            #     - Skip
-            #
-            if solutions[ts["hash"]] == "":
-                errors[ts["hash"]] = _("No solution given.")
-                continue
-
-            # If resolution is not accepted for this conflict
-            #
-            #     - Skip
-            #
-            if not solutions[ts["hash"]] in ts["solution_choices"]:
-                errors[ts["hash"]] = _("Given solution is not accepted for this conflict.")
-                continue
-
-            """Conflict resolution"""
-
-            existing = ts["collisions"][0]
-            solution = solutions[ts["hash"]]
-
-            # theirs
-            #
-            #     - Discard the projected timeslot
-            #     - Keep the existing collision(s)
-            #
-            if solution == "theirs":
-                continue
-
-            # ours
-            #
-            #     - Create the projected timeslot
-            #     - Delete the existing collision(s)
-            #
-            if solution == "ours":
-                projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show)
-                create.append(projected_ts)
-
-                # Delete collision(s)
-                for ex in ts["collisions"]:
-                    try:
-                        existing_ts = TimeSlot.objects.get(pk=ex["id"])
-                        delete.append(existing_ts)
-                    except ObjectDoesNotExist:
-                        pass
-
-            # theirs-end
-            #
-            #     - Keep the existing timeslot
-            #     - Create projected with end of existing start
-            #
-            if solution == "theirs-end":
-                projected_ts = TimeSlot.objects.instantiate(
-                    ts["start"], existing["start"], schedule, show
-                )
-                create.append(projected_ts)
-
-            # ours-end
-            #
-            #     - Create the projected timeslot
-            #     - Change the start of the existing collision to projected end
-            #
-            if solution == "ours-end":
-                projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show)
-                create.append(projected_ts)
-
-                existing_ts = TimeSlot.objects.get(pk=existing["id"])
-                existing_ts.start = parse_datetime(ts["end"])
-                update.append(existing_ts)
-
-            # theirs-start
-            #
-            #     - Keep existing
-            #     - Create projected with start time of existing end
-            #
-            if solution == "theirs-start":
-                projected_ts = TimeSlot.objects.instantiate(
-                    existing["end"], ts["end"], schedule, show
-                )
-                create.append(projected_ts)
-
-            # ours-start
-            #
-            #     - Create the projected timeslot
-            #     - Change end of existing to projected start
-            #
-            if solution == "ours-start":
-                projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show)
-                create.append(projected_ts)
-
-                existing_ts = TimeSlot.objects.get(pk=existing["id"])
-                existing_ts.end = parse_datetime(ts["start"])
-                update.append(existing_ts)
-
-            # theirs-both
-            #
-            #     - Keep existing
-            #     - Create two projected timeslots with end of existing start and start of existing
-            #       end
-            #
-            if solution == "theirs-both":
-                projected_ts = TimeSlot.objects.instantiate(
-                    ts["start"], existing["start"], schedule, show
-                )
-                create.append(projected_ts)
-
-                projected_ts = TimeSlot.objects.instantiate(
-                    existing["end"], ts["end"], schedule, show
-                )
-                create.append(projected_ts)
-
-            # ours-both
-            #
-            #     - Create projected
-            #     - Split existing into two:
-            #       - Set existing end time to projected start
-            #       - Create another one with start = projected end and end = existing end
-            #
-            if solution == "ours-both":
-                projected_ts = TimeSlot.objects.instantiate(ts["start"], ts["end"], schedule, show)
-                create.append(projected_ts)
-
-                existing_ts = TimeSlot.objects.get(pk=existing["id"])
-                existing_ts.end = parse_datetime(ts["start"])
-                update.append(existing_ts)
-
-                projected_ts = TimeSlot.objects.instantiate(
-                    ts["end"], existing["end"], schedule, show
-                )
-                create.append(projected_ts)
-
-        # If there were any errors, don't make any db changes yet
-        # but add error messages and return already chosen solutions
-        if len(errors) > 0:
-            conflicts = Schedule.make_conflicts(model_to_dict(schedule), schedule.pk, show.pk)
-
-            partly_resolved = conflicts["projected"]
-            saved_solutions = {}
-
-            # Add already chosen resolutions and error message to conflict
-            for index, c in enumerate(conflicts["projected"]):
-                # The element should only exist if there was a collision
-                if len(c["collisions"]) > 0:
-                    saved_solutions[c["hash"]] = ""
-
-                if c["hash"] in solutions and solutions[c["hash"]] in c["solution_choices"]:
-                    saved_solutions[c["hash"]] = solutions[c["hash"]]
-
-                if c["hash"] in errors:
-                    partly_resolved[index]["error"] = errors[c["hash"]]
-
-            # Re-insert post data
-            conflicts["projected"] = partly_resolved
-            conflicts["solutions"] = saved_solutions
-            conflicts["notes"] = data.get("notes")
-            conflicts["playlists"] = data.get("playlists")
-
-            raise ScheduleConflictError(
-                _("Not all conflicts have been resolved."),
-                code="unresolved-conflicts",
-                conflicts=conflicts,
-            )
-
-        # Collect upcoming timeslots to delete which might still remain
-        del_timeslots = TimeSlot.objects.filter(
-            schedule=schedule,
-            start__gt=timezone.make_aware(datetime.combine(schedule.last_date, time(0, 0))),
-        )
-        for del_ts in del_timeslots:
-            delete.append(del_ts)
-
-        # If 'dryrun' is true, just return the projected changes instead of executing them
-        if "dryrun" in sdl and sdl["dryrun"]:
-            return {
-                "create": [model_to_dict(ts) for ts in create],
-                "update": [model_to_dict(ts) for ts in update],
-                "delete": [model_to_dict(ts) for ts in delete],
-            }
-
-        """Database changes if no errors found"""
-
-        # Only save schedule if timeslots were created
-        if create:
-            # Create or update schedule
-            schedule.save()
-
-        # Update timeslots
-        for ts in update:
-            ts.save(update_fields=["start", "end"])
-
-        # Create timeslots
-        for ts in create:
-            ts.schedule = schedule
-
-            # Reassign playlists
-            if "playlists" in data and ts.hash in data["playlists"]:
-                ts.playlist_id = int(data["playlists"][ts.hash])
-
-            ts.save()
-
-            # Reassign notes
-            if "notes" in data and ts.hash in data["notes"]:
-                try:
-                    note = Note.objects.get(pk=int(data["notes"][ts.hash]))
-                    note.timeslot_id = ts.id
-                    note.save(update_fields=["timeslot_id"])
-
-                    timeslot = TimeSlot.objects.get(pk=ts.id)
-                    timeslot.note_id = note.id
-                    timeslot.save(update_fields=["note_id"])
-                except ObjectDoesNotExist:
-                    pass
-
-        # Delete manually resolved timeslots and those after until
-        for dl in delete:
-            dl.delete()
-
-        return model_to_dict(schedule)
-
 
 class TimeSlotManager(models.Manager):
     @staticmethod
@@ -1029,7 +374,6 @@ class TimeSlotManager(models.Manager):
             start=parse_datetime(start),
             end=parse_datetime(end),
             show=show,
-            is_repetition=schedule.is_repetition,
             schedule=schedule,
         )
 
@@ -1057,16 +401,22 @@ class TimeSlotManager(models.Manager):
 
 
 class TimeSlot(models.Model):
+    end = models.DateTimeField()
+    memo = models.TextField(blank=True)
+    note_id = models.IntegerField(null=True, editable=False)
+    playlist_id = models.IntegerField(null=True)
+    repetition_of = models.ForeignKey(
+        "self",
+        blank=True,
+        null=True,
+        on_delete=models.CASCADE,
+        related_name="repetitions",
+    )
     schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name="timeslots")
     show = models.ForeignKey(
         Show, editable=False, on_delete=models.CASCADE, related_name="timeslots"
     )
     start = models.DateTimeField()
-    end = models.DateTimeField()
-    memo = models.TextField(blank=True)
-    is_repetition = models.BooleanField(default=False)
-    playlist_id = models.IntegerField(null=True)
-    note_id = models.IntegerField(null=True, editable=False)
 
     objects = TimeSlotManager()
 
@@ -1104,30 +454,27 @@ class TimeSlot(models.Model):
 
 
 class Note(models.Model):
-    timeslot = models.OneToOneField(TimeSlot, on_delete=models.CASCADE, unique=True)
-    title = models.CharField(max_length=128)
-    slug = models.SlugField(max_length=32, unique=True)
-    summary = models.TextField(blank=True)
-    content = models.TextField()
-    ppoi = PPOIField()
-    height = models.PositiveIntegerField(blank=True, null=True, editable=False)
-    width = models.PositiveIntegerField(blank=True, null=True, editable=False)
-    image = VersatileImageField(
-        blank=True,
-        null=True,
-        upload_to="note_images",
-        width_field="width",
-        height_field="height",
-        ppoi_field="ppoi",
-    )
-    status = models.IntegerField(default=1)
-    start = models.DateTimeField(editable=False)
-    show = models.ForeignKey(Show, on_delete=models.CASCADE, related_name="notes", editable=True)
     cba_id = models.IntegerField(blank=True, null=True)
-    user = models.ForeignKey(
-        User, editable=False, on_delete=models.CASCADE, related_name="users", default=1
+    content = models.TextField()
+    contributors = models.ManyToManyField(Host, related_name="contributions")
+    created_at = models.DateTimeField(auto_now_add=True)
+    created_by = models.CharField(max_length=150)
+    image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="notes")
+    owner = models.ForeignKey(
+        User,
+        editable=False,
+        on_delete=models.CASCADE,
+        related_name="notes",
+        default=1,
     )
-    host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="hosts", null=True)
+    playlist = models.TextField(blank=True, null=True)
+    slug = models.SlugField(max_length=32, unique=True)
+    summary = models.TextField(blank=True)
+    tags = models.TextField(blank=True, null=True)
+    timeslot = models.OneToOneField(TimeSlot, on_delete=models.CASCADE, unique=True)
+    title = models.CharField(max_length=128)
+    updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+    updated_by = models.CharField(blank=True, max_length=150, null=True)
 
     class Meta:
         ordering = ("timeslot",)
@@ -1136,20 +483,30 @@ class Note(models.Model):
         return self.title
 
     def save(self, *args, **kwargs):
-        self.start = self.timeslot.start
-        self.show = self.timeslot.schedule.show
-
         timeslot = TimeSlot.objects.get(pk=self.timeslot.id)
         timeslot.note_id = self.id
         timeslot.save()
 
         super(Note, self).save(*args, **kwargs)
 
-        # Generate thumbnails
-        if self.image.name and THUMBNAIL_SIZES:
-            for size in THUMBNAIL_SIZES:
-                self.image.thumbnail = self.image.crop[size].name
-
 
 class NoteLink(Link):
     note = models.ForeignKey(Note, on_delete=models.CASCADE, related_name="links")
+
+
+class UserProfile(models.Model):
+    cba_username = models.CharField("CBA Username", blank=True, max_length=60)
+    cba_user_token = models.CharField("CBA Token", blank=True, max_length=255)
+    created_at = models.DateTimeField(auto_now_add=True)
+    created_by = models.CharField(max_length=150)
+    updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+    updated_by = models.CharField(blank=True, max_length=150, null=True)
+    user = models.OneToOneField(
+        User,
+        on_delete=models.CASCADE,
+        related_name="profile",
+        editable=False,
+    )
+
+    def __str__(self):
+        return self.user.username
diff --git a/program/serializers.py b/program/serializers.py
index 260cc5161e4a4754cdfbbb73c55953f5e44857b2..9336939785ddcb67ada771c3f9a93da710f666c0 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -18,9 +18,8 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
-from profile.models import Profile
-from profile.serializers import ProfileSerializer
-from typing import List
+import re
+from typing import List, TypedDict
 
 from rest_framework import serializers
 
@@ -32,18 +31,23 @@ from program.models import (
     FundingCategory,
     Host,
     HostLink,
+    Image,
     Language,
+    LicenseType,
+    LinkType,
     MusicFocus,
     Note,
     NoteLink,
+    RRule,
     Schedule,
     Show,
     ShowLink,
     TimeSlot,
     Topic,
     Type,
+    UserProfile,
 )
-from program.utils import get_audio_url
+from program.utils import delete_links, get_audio_url
 from steering.settings import THUMBNAIL_SIZES
 
 SOLUTION_CHOICES = {
@@ -78,6 +82,26 @@ class ErrorSerializer(serializers.Serializer):
     code = serializers.CharField(allow_null=True)
 
 
+class ProfileSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = UserProfile
+        fields = (
+            "user",
+            "cba_username",
+            "cba_user_token",
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
+        read_only_fields = (
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
+
+
 class UserSerializer(serializers.ModelSerializer):
     # Add profile fields to JSON
     profile = ProfileSerializer(required=False)
@@ -85,16 +109,16 @@ class UserSerializer(serializers.ModelSerializer):
     class Meta:
         model = User
         fields = (
-            "id",
-            "username",
-            "first_name",
-            "last_name",
             "email",
-            "is_staff",
+            "first_name",
+            "id",
             "is_active",
+            "is_staff",
             "is_superuser",
+            "last_name",
             "password",
             "profile",
+            "username",
         )
 
     def create(self, validated_data):
@@ -102,9 +126,7 @@ class UserSerializer(serializers.ModelSerializer):
         Create and return a new User instance, given the validated data.
         """
 
-        profile_data = (
-            validated_data.pop("profile") if "profile" in validated_data else None
-        )
+        profile_data = validated_data.pop("profile") if "profile" in validated_data else None
 
         user = super(UserSerializer, self).create(validated_data)
         user.date_joined = timezone.now()
@@ -112,10 +134,11 @@ class UserSerializer(serializers.ModelSerializer):
         user.save()
 
         if profile_data:
-            profile = Profile(
+            profile = UserProfile(
                 user=user,
                 cba_username=profile_data.get("cba_username").strip(),
                 cba_user_token=profile_data.get("cba_user_token").strip(),
+                created_by=self.context["user"],
             )
             profile.save()
 
@@ -131,23 +154,20 @@ class UserSerializer(serializers.ModelSerializer):
         instance.email = validated_data.get("email", instance.email)
         instance.is_active = validated_data.get("is_active", instance.is_active)
         instance.is_staff = validated_data.get("is_staff", instance.is_staff)
-        instance.is_superuser = validated_data.get(
-            "is_superuser", instance.is_superuser
-        )
+        instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser)
 
-        profile_data = (
-            validated_data.pop("profile") if "profile" in validated_data else None
-        )
+        profile_data = validated_data.pop("profile") if "profile" in validated_data else None
 
         if profile_data:
             # TODO: How to hook into this from ProfileSerializer without having to call it here?
             try:
-                profile = Profile.objects.get(user=instance.id)
+                profile = UserProfile.objects.get(user=instance.id)
             except ObjectDoesNotExist:
-                profile = Profile.objects.create(user=instance, **profile_data)
+                profile = UserProfile.objects.create(user=instance, **profile_data)
 
             profile.cba_username = profile_data.get("cba_username")
             profile.cba_user_token = profile_data.get("cba_user_token")
+            profile.updated_by = self.context["user"]
             profile.save()
 
         instance.save()
@@ -155,47 +175,149 @@ class UserSerializer(serializers.ModelSerializer):
 
 
 class CategorySerializer(serializers.ModelSerializer):
-    # TODO: remove this when the dashboard is updated
-    category = serializers.CharField(source="name")
-
     class Meta:
         model = Category
-        # TODO: replace `category` with `name` when the dashboard is updated
-        fields = ("id", "category", "abbrev", "slug", "is_active", "description")
+        fields = ("description", "id", "is_active", "name", "slug", "subtitle")
+
+
+class LinkTypeSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = LinkType
+        fields = ("name", "type")
+
+
+class LicenseTypeSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = LicenseType
+        fields = ("name", "type")
 
 
 class HostLinkSerializer(serializers.ModelSerializer):
     class Meta:
         model = HostLink
-        fields = ("description", "url")
+        fields = ("type", "url")
 
 
-class HostSerializer(serializers.ModelSerializer):
-    links = HostLinkSerializer(many=True, required=False)
+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(host) -> List[str]:
+    def get_thumbnails(instance) -> List[Thumbnail]:
         """Returns thumbnails"""
         thumbnails = []
 
-        if host.image.name and THUMBNAIL_SIZES:
+        if instance.image.name and THUMBNAIL_SIZES:
             for size in THUMBNAIL_SIZES:
-                thumbnails.append(host.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
 
+    class Meta:
+        model = Image
+        read_only_fields = (
+            "height",
+            "id",
+            "thumbnails",
+            "width",
+        )
+        fields = (
+            "alt_text",
+            "credits",
+            "image",
+            "ppoi",
+        ) + read_only_fields
+
+    def create(self, validated_data):
+        """Create and return a new Image instance, given the validated data."""
+
+        image = Image.objects.create(**validated_data | self.context)
+        image.save()
+
+        return image
+
+    def update(self, instance, validated_data):
+        """Update and return an existing Image instance, given the validated data."""
+
+        # 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()
+
+        return instance
+
+
+class HostSerializer(serializers.ModelSerializer):
+    image = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=Image.objects.all(),
+        required=False,
+    )
+    links = HostLinkSerializer(many=True, required=False)
+
     class Meta:
         model = Host
-        fields = "__all__"
+        read_only_fields = (
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
+        fields = (
+            "biography",
+            "email",
+            "id",
+            "image",
+            "is_active",
+            "links",
+            "name",
+        ) + read_only_fields
 
     def create(self, validated_data):
+        """
+        Create and return a new Host instance, given the validated data.
+        """
+
         links_data = validated_data.pop("links", [])
-        host = Host.objects.create(**validated_data)
+
+        host = Host.objects.create(**validated_data | self.context)  # created_by
 
         for link_data in links_data:
             HostLink.objects.create(host=host, **link_data)
+
         host.save()
+
         return host
 
     def update(self, instance, validated_data):
@@ -203,22 +325,20 @@ class HostSerializer(serializers.ModelSerializer):
         Update and return an existing Host instance, given the validated data.
         """
 
-        instance.name = validated_data.get("name", instance.name)
-        instance.is_active = validated_data.get("is_active", instance.is_active)
-        instance.email = validated_data.get("email", instance.email)
-        instance.website = validated_data.get("website", instance.website)
         instance.biography = validated_data.get("biography", instance.biography)
+        instance.email = validated_data.get("email", instance.email)
         instance.image = validated_data.get("image", instance.image)
-        instance.ppoi = validated_data.get("ppoi", instance.ppoi)
-
-        if instance.links.count() > 0:
-            for link in instance.links.all():
-                link.delete(keep_parents=True)
+        instance.is_active = validated_data.get("is_active", instance.is_active)
+        instance.name = validated_data.get("name", instance.name)
 
         if links_data := validated_data.get("links"):
+            instance = delete_links(instance)
+
             for link_data in links_data:
                 HostLink.objects.create(host=instance, **link_data)
 
+        instance.updated_by = self.context.get("updated_by")
+
         instance.save()
 
         return instance
@@ -227,122 +347,96 @@ class HostSerializer(serializers.ModelSerializer):
 class LanguageSerializer(serializers.ModelSerializer):
     class Meta:
         model = Language
-        fields = ("id", "name", "is_active")
+        fields = ("id", "is_active", "name")
 
 
 class TopicSerializer(serializers.ModelSerializer):
-    # TODO: remove this when the dashboard is updated
-    topic = serializers.CharField(source="name")
-
     class Meta:
         model = Topic
-        # TODO: replace `topic` with `name` when the dashboard is updated
-        fields = ("id", "topic", "abbrev", "slug", "is_active")
+        fields = ("id", "is_active", "name", "slug")
 
 
 class MusicFocusSerializer(serializers.ModelSerializer):
-    # TODO: remove this when the dashboard is updated
-    focus = serializers.CharField(source="name")
-
     class Meta:
         model = MusicFocus
-        # TODO: replace `focus` with `name` when the dashboard is updated
-        fields = ("id", "focus", "abbrev", "slug", "is_active")
+        fields = ("id", "is_active", "name", "slug")
 
 
 class TypeSerializer(serializers.ModelSerializer):
-    # TODO: remove this when the dashboard is updated
-    type = serializers.CharField(source="name")
-
     class Meta:
         model = Type
-        # TODO: replace `type` with `name` when the dashboard is updated
-        fields = ("id", "type", "slug", "is_active")
+        fields = ("id", "is_active", "name", "slug")
 
 
 class FundingCategorySerializer(serializers.ModelSerializer):
-    # TODO: remove this when the dashboard is updated
-    fundingcategory = serializers.CharField(source="name")
-
     class Meta:
         model = FundingCategory
-        # TODO: replace `fundingcategory` with `name` when the dashboard is updated
-        fields = ("id", "fundingcategory", "abbrev", "slug", "is_active")
+        fields = ("id", "is_active", "name", "slug")
 
 
 class ShowLinkSerializer(serializers.ModelSerializer):
     class Meta:
         model = ShowLink
-        fields = ("description", "url")
+        fields = ("type", "url")
 
 
 class ShowSerializer(serializers.HyperlinkedModelSerializer):
-    links = HostLinkSerializer(many=True, required=False)
-    owners = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), many=True)
-    category = serializers.PrimaryKeyRelatedField(
-        queryset=Category.objects.all(), many=True
-    )
+    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)
-    language = serializers.PrimaryKeyRelatedField(
-        queryset=Language.objects.all(), many=True
-    )
-    topic = serializers.PrimaryKeyRelatedField(queryset=Topic.objects.all(), many=True)
-    # TODO: replace `musicfocs` with `music_focus` and remove the source when the dashboard is
-    #  updated
-    musicfocus = serializers.PrimaryKeyRelatedField(
-        queryset=MusicFocus.objects.all(), source="music_focus", many=True
+    image = serializers.PrimaryKeyRelatedField(
+        allow_null=True,
+        queryset=Image.objects.all(),
+        required=False,
     )
-    type = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all())
-    # TODO: replace `fundingcategory` with `funding_category` and remove the source when the
-    #  dashboard is updated
-    fundingcategory = serializers.PrimaryKeyRelatedField(
-        queryset=FundingCategory.objects.all(), source="funding_category"
+    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(
         queryset=Show.objects.all(), required=False, allow_null=True
     )
-    thumbnails = serializers.SerializerMethodField()
-
-    @staticmethod
-    def get_thumbnails(show) -> List[str]:
-        """Returns thumbnails"""
-        thumbnails = []
-
-        if show.image.name and THUMBNAIL_SIZES:
-            for size in THUMBNAIL_SIZES:
-                thumbnails.append(show.image.crop[size].name)
-
-        return thumbnails
+    topic = serializers.PrimaryKeyRelatedField(queryset=Topic.objects.all(), many=True)
+    type = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all())
 
     class Meta:
         model = Show
+        read_only_fields = (
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
         fields = (
-            "id",
-            "name",
-            "slug",
-            "image",
-            "ppoi",
-            "logo",
-            "short_description",
-            "description",
-            "email",
-            "website",
-            "type",
-            "fundingcategory",
-            "predecessor",
+            "category",
             "cba_series_id",
             "default_playlist_id",
-            "category",
+            "description",
+            "email",
+            "funding_category",
             "hosts",
-            "owners",
-            "language",
-            "topic",
-            "musicfocus",
-            "thumbnails",
+            "id",
+            "image",
+            "internal_note",
             "is_active",
             "is_public",
-            "links"
-        )
+            "language",
+            "links",
+            "logo",
+            "music_focus",
+            "name",
+            "owners",
+            "predecessor",
+            "short_description",
+            "slug",
+            "topic",
+            "type",
+        ) + read_only_fields
 
     def create(self, validated_data):
         """
@@ -357,7 +451,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
         music_focus = validated_data.pop("music_focus")
         links_data = validated_data.pop("links", [])
 
-        show = Show.objects.create(**validated_data)
+        show = Show.objects.create(**validated_data | self.context)  # created_by
 
         # Save many-to-many relationships
         show.owners.set(owners)
@@ -371,6 +465,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
             ShowLink.objects.create(show=show, **link_data)
 
         show.save()
+
         return show
 
     def update(self, instance, validated_data):
@@ -378,57 +473,75 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
         Update and return an existing Show instance, given the validated data.
         """
 
-        instance.name = validated_data.get("name", instance.name)
-        instance.slug = validated_data.get("slug", instance.slug)
-        instance.image = validated_data.get("image", instance.image)
-        instance.ppoi = validated_data.get("ppoi", instance.ppoi)
-        instance.logo = validated_data.get("logo", instance.logo)
-        instance.short_description = validated_data.get(
-            "short_description", instance.short_description
-        )
-        instance.description = validated_data.get("description", instance.description)
-        instance.email = validated_data.get("email", instance.email)
-        instance.website = validated_data.get("website", instance.website)
-        instance.cba_series_id = validated_data.get(
-            "cba_series_id", instance.cba_series_id
-        )
+        instance.cba_series_id = validated_data.get("cba_series_id", instance.cba_series_id)
         instance.default_playlist_id = validated_data.get(
             "default_playlist_id", instance.default_playlist_id
         )
-        instance.type = validated_data.get("type", instance.type)
+        instance.description = validated_data.get("description", instance.description)
+        instance.email = validated_data.get("email", instance.email)
         instance.funding_category = validated_data.get(
             "funding_category", instance.funding_category
         )
-        instance.predecessor = validated_data.get("predecessor", instance.predecessor)
+        instance.image = validated_data.get("image", instance.image)
+        instance.internal_note = validated_data.get("internal_note", instance.internal_note)
         instance.is_active = validated_data.get("is_active", instance.is_active)
         instance.is_public = validated_data.get("is_public", instance.is_public)
+        instance.logo = validated_data.get("logo", instance.logo)
+        instance.name = validated_data.get("name", instance.name)
+        instance.predecessor = validated_data.get("predecessor", instance.predecessor)
+        instance.short_description = validated_data.get(
+            "short_description", instance.short_description
+        )
+        instance.slug = validated_data.get("slug", instance.slug)
+        instance.type = validated_data.get("type", instance.type)
 
-        instance.owners.set(validated_data.get("owners", instance.owners))
         instance.category.set(validated_data.get("category", instance.category))
         instance.hosts.set(validated_data.get("hosts", instance.hosts))
         instance.language.set(validated_data.get("language", instance.language))
+        instance.music_focus.set(validated_data.get("music_focus", instance.music_focus))
+        instance.owners.set(validated_data.get("owners", instance.owners))
         instance.topic.set(validated_data.get("topic", instance.topic))
-        instance.music_focus.set(
-            validated_data.get("music_focus", instance.music_focus)
-        )
-
-        if instance.links.count() > 0:
-            for link in instance.links.all():
-                link.delete(keep_parents=True)
 
         if links_data := validated_data.get("links"):
+            instance = delete_links(instance)
+
             for link_data in links_data:
                 ShowLink.objects.create(show=instance, **link_data)
 
+        instance.updated_by = self.context.get("updated_by")
+
         instance.save()
 
         return instance
 
 
+class RRuleSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = RRule
+        fields = (
+            "id",
+            "name",
+        )
+        read_only_fields = fields
+
+
 class ScheduleSerializer(serializers.ModelSerializer):
     class Meta:
         model = Schedule
-        fields = "__all__"
+        fields = (
+            "add_business_days_only",
+            "add_days_no",
+            "by_weekday",
+            "default_playlist_id",
+            "end_time",
+            "first_date",
+            "id",
+            "is_repetition",
+            "last_date",
+            "rrule",
+            "show",
+            "start_time",
+        )
 
 
 class UnsavedScheduleSerializer(ScheduleSerializer):
@@ -448,7 +561,20 @@ class ScheduleInRequestSerializer(ScheduleSerializer):
 
     class Meta:
         model = Schedule
-        fields = "__all__"
+        fields = (
+            "add_business_days_only",
+            "add_days_no",
+            "by_weekday",
+            "default_playlist_id",
+            "dryrun",
+            "end_time",
+            "first_date",
+            "is_repetition",
+            "last_date",
+            "rrule",
+            "show",
+            "start_time",
+        )
 
     def create(self, validated_data):
         """Create and return a new Schedule instance, given the validated data."""
@@ -471,9 +597,7 @@ class ScheduleInRequestSerializer(ScheduleSerializer):
         instance.start_time = validated_data.get("start_time", instance.start_time)
         instance.end_time = validated_data.get("end_time", instance.end_time)
         instance.last_date = validated_data.get("last_date", instance.last_date)
-        instance.is_repetition = validated_data.get(
-            "is_repetition", instance.is_repetition
-        )
+        instance.is_repetition = validated_data.get("is_repetition", instance.is_repetition)
         instance.default_playlist_id = validated_data.get(
             "default_playlist_id", instance.default_playlist_id
         )
@@ -495,7 +619,7 @@ class CollisionSerializer(serializers.Serializer):
     playlist_id = serializers.IntegerField(allow_null=True)
     show = serializers.IntegerField()
     show_name = serializers.CharField()
-    is_repetition = serializers.BooleanField()
+    repetition_of = serializers.IntegerField(allow_null=True)
     schedule = serializers.IntegerField()
     memo = serializers.CharField()
     note_id = serializers.IntegerField(allow_null=True)
@@ -507,22 +631,16 @@ class ProjectedTimeSlotSerializer(serializers.Serializer):
     end = serializers.DateTimeField()
     collisions = CollisionSerializer(many=True)
     error = serializers.CharField(allow_null=True)
-    solution_choices = serializers.ListField(
-        child=serializers.ChoiceField(SOLUTION_CHOICES)
-    )
+    solution_choices = serializers.ListField(child=serializers.ChoiceField(SOLUTION_CHOICES))
 
 
 class DryRunTimeSlotSerializer(serializers.Serializer):
-    id = serializers.PrimaryKeyRelatedField(
-        queryset=TimeSlot.objects.all(), allow_null=True
-    )
-    schedule = serializers.PrimaryKeyRelatedField(
-        queryset=Schedule.objects.all(), allow_null=True
-    )
+    id = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), allow_null=True)
+    schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all(), allow_null=True)
     playlist_id = serializers.IntegerField(allow_null=True)
     start = serializers.DateField()
     end = serializers.DateField()
-    is_repetition = serializers.BooleanField()
+    repetition_of = serializers.IntegerField(allow_null=True)
     memo = serializers.CharField()
 
 
@@ -556,10 +674,23 @@ 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(), allow_null=True
+    )
 
     class Meta:
         model = TimeSlot
-        fields = "__all__"
+        fields = (
+            "id",
+            "end",
+            "memo",
+            "note_id",
+            "playlist_id",
+            "repetition_of",
+            "schedule",
+            "show",
+            "start",
+        )
 
     def create(self, validated_data):
         """Create and return a new TimeSlot instance, given the validated data."""
@@ -570,9 +701,7 @@ class TimeSlotSerializer(serializers.ModelSerializer):
 
         # Only save certain fields
         instance.memo = validated_data.get("memo", instance.memo)
-        instance.is_repetition = validated_data.get(
-            "is_repetition", instance.is_repetition
-        )
+        instance.repetition_of = validated_data.get("repetition_of", instance.repetition_of)
         instance.playlist_id = validated_data.get("playlist_id", instance.playlist_id)
         instance.save()
         return instance
@@ -581,43 +710,59 @@ class TimeSlotSerializer(serializers.ModelSerializer):
 class NoteLinkSerializer(serializers.ModelSerializer):
     class Meta:
         model = NoteLink
-        fields = ("description", "url")
+        fields = ("type", "url")
 
 
 class NoteSerializer(serializers.ModelSerializer):
+    contributors = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True)
+    image = serializers.PrimaryKeyRelatedField(
+        queryset=Image.objects.all(), required=False, allow_null=True
+    )
     links = NoteLinkSerializer(many=True, required=False)
-    show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all())
-    timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all())
-    host = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all())
-    thumbnails = serializers.SerializerMethodField()
-
-    @staticmethod
-    def get_thumbnails(note) -> List[str]:
-        """Returns thumbnails"""
-        thumbnails = []
-
-        if note.image.name and THUMBNAIL_SIZES:
-            for size in THUMBNAIL_SIZES:
-                thumbnails.append(note.image.crop[size].name)
-
-        return thumbnails
+    timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), required=False)
 
     class Meta:
         model = Note
-        fields = "__all__"
+        read_only_fields = (
+            "id",
+            "created_at",
+            "created_by",
+            "updated_at",
+            "updated_by",
+        )
+        fields = (
+            "cba_id",
+            "content",
+            "contributors",
+            "id",
+            "image",
+            "links",
+            "owner",
+            "playlist",
+            "slug",
+            "summary",
+            "tags",
+            "timeslot",
+            "title",
+        ) + read_only_fields
 
     def create(self, validated_data):
         """Create and return a new Note instance, given the validated data."""
+
         links_data = validated_data.pop("links", [])
+        contributors = validated_data.pop("contributors", [])
 
-        # Save the creator
-        validated_data["user_id"] = self.context["user_id"]
+        # the creator of the note is the owner
+        validated_data["owner"] = self.context["request"].user
+        note = Note.objects.create(
+            created_by=self.context["request"].user.username, **validated_data
+        )
 
-        note = Note.objects.create(**validated_data)
+        note.contributors.set(contributors)
 
         if cba_id := validated_data.get("cba_id"):
             if audio_url := get_audio_url(cba_id):
-                NoteLink.objects.create(note=note, description="CBA", url=audio_url)
+                NoteLink.objects.create(note=note, type="CBA", url=audio_url)
 
         for link_data in links_data:
             NoteLink.objects.create(note=note, **link_data)
@@ -638,30 +783,28 @@ class NoteSerializer(serializers.ModelSerializer):
     def update(self, instance, validated_data):
         """Update and return an existing Note instance, given the validated data."""
 
-        instance.show = validated_data.get("show", instance.show)
-        instance.timeslot = validated_data.get("timeslot", instance.timeslot)
-        instance.title = validated_data.get("title", instance.title)
-        instance.slug = validated_data.get("slug", instance.slug)
-        instance.summary = validated_data.get("summary", instance.summary)
+        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.ppoi = validated_data.get("ppoi", instance.ppoi)
-        instance.status = validated_data.get("status", instance.status)
-        instance.host = validated_data.get("host", instance.host)
-        instance.cba_id = validated_data.get("cba_id", instance.cba_id)
+        instance.slug = validated_data.get("slug", instance.slug)
+        instance.summary = validated_data.get("summary", instance.summary)
+        instance.timeslot = validated_data.get("timeslot", instance.timeslot)
+        instance.title = validated_data.get("title", instance.title)
 
-        if instance.links.count() > 0:
-            for link in instance.links.all():
-                link.delete(keep_parents=True)
+        instance.contributors.set(validated_data.get("contributors", instance.contributors))
 
         if cba_id := validated_data.get("cba_id"):
             if audio_url := get_audio_url(cba_id):
-                NoteLink.objects.create(note=instance, description="CBA", url=audio_url)
+                NoteLink.objects.create(note=instance, type="CBA", url=audio_url)
 
         if links_data := validated_data.get("links"):
+            instance = delete_links(instance)
+
             for link_data in links_data:
                 NoteLink.objects.create(note=instance, **link_data)
 
+        instance.updated_by = self.context.get("request").user.username
+
         instance.save()
 
         # Remove existing note connections from timeslots
diff --git a/program/services.py b/program/services.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc323d4120aa6ad729d280cd760270d6b708cff8
--- /dev/null
+++ b/program/services.py
@@ -0,0 +1,707 @@
+#
+# steering, Programme/schedule management for AURA
+#
+# Copyright (C) 2017, Ingo Leindecker
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+from datetime import datetime, time, timedelta
+from typing import TypedDict
+
+from dateutil.relativedelta import relativedelta
+from dateutil.rrule import rrule
+from rest_framework.exceptions import ValidationError
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.forms.models import model_to_dict
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from program.models import Note, RRule, Schedule, ScheduleConflictError, Show, TimeSlot
+from program.utils import parse_date, parse_datetime, parse_time
+from steering.settings import (
+    AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE,
+    AUTO_SET_LAST_DATE_TO_END_OF_YEAR,
+)
+
+
+class ScheduleData(TypedDict):
+    add_business_days_only: bool | None
+    add_days_no: int | None
+    by_weekday: int | None
+    default_playlist_id: int | None
+    dryrun: bool | None
+    end_time: str
+    first_date: str
+    id: int | None
+    is_repetition: bool
+    last_date: str | None
+    rrule: int
+    show: int | None
+    start_time: str
+
+
+class Collision(TypedDict):
+    end: str
+    id: int
+    memo: str
+    note_id: int | None
+    playlist_id: int | None
+    schedule: int
+    show: int
+    show_name: str
+    start: str
+
+
+class ProjectedEntry(TypedDict):
+    collisions: list[Collision]
+    end: str
+    error: str | None
+    hash: str
+    solution_choices: set[str]
+    start: str
+
+
+class Conflicts(TypedDict):
+    notes: dict
+    playlists: dict
+    projected: list[ProjectedEntry]
+    solutions: dict[str, str]
+
+
+class ScheduleCreateUpdateData(TypedDict):
+    notes: dict
+    playlists: dict
+    schedule: ScheduleData
+    solutions: dict[str, str]
+
+
+def resolve_conflicts(data: ScheduleCreateUpdateData, schedule_pk: int | None, show_pk: int):
+    """
+    Resolves conflicts
+    Expects JSON POST/PUT data from /shows/1/schedules/
+
+    Returns a list of dicts if errors were found
+    Returns an empty list if resolution was successful
+    """
+
+    schedule = data["schedule"]
+    solutions = data.get("solutions", [])   # only needed if conflicts exist
+
+    new_schedule = instantiate_upcoming_schedule(schedule, show_pk, schedule_pk)
+    show = new_schedule.show
+    conflicts = make_conflicts(schedule, schedule_pk, show_pk)
+
+    if new_schedule.rrule.freq > 0 and new_schedule.first_date == new_schedule.last_date:
+        raise ValidationError(
+            _("Start and end dates must not be the same."),
+            code="no-same-day-start-and-end",
+        )
+
+    if new_schedule.last_date < new_schedule.first_date:
+        raise ValidationError(
+            _("End date mustn't be before start."),
+            code="no-start-after-end",
+        )
+
+    num_conflicts = len([pr for pr in conflicts["projected"] if len(pr["collisions"]) > 0])
+
+    if len(solutions) != num_conflicts:
+        raise ScheduleConflictError(
+            _("Numbers of conflicts and solutions don't match."),
+            code="one-solution-per-conflict",
+            conflicts=conflicts,
+        )
+
+    to_create: list[TimeSlot] = []
+    to_update: list[TimeSlot] = []
+    to_delete: list[TimeSlot] = []
+
+    errors = {}
+
+    for timeslot in conflicts["projected"]:
+        # If no solution necessary: Create the projected timeslot and skip
+        if "solution_choices" not in timeslot or len(timeslot["collisions"]) == 0:
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], timeslot["end"], new_schedule, show
+                ),
+            )
+            continue
+
+        # Check hash (if start, end, rrule or by_weekday changed)
+        if not timeslot["hash"] in solutions:
+            errors[timeslot["hash"]] = _("This change on the timeslot is not allowed.")
+            continue
+
+        # If no resolution given: skip
+        if solutions[timeslot["hash"]] == "":
+            errors[timeslot["hash"]] = _("No solution given.")
+            continue
+
+        # If resolution is not accepted for this conflict: SKIP
+        if not solutions[timeslot["hash"]] in timeslot["solution_choices"]:
+            errors[timeslot["hash"]] = _("Given solution is not accepted for this conflict.")
+            continue
+
+        """Conflict resolution"""
+
+        existing = timeslot["collisions"][0]
+        solution = solutions[timeslot["hash"]]
+
+        if solution == "theirs":
+            # - Discard the projected timeslot
+            # - Keep the existing collision(s)
+            continue
+
+        if solution == "ours":
+            # - Create the projected timeslot
+            # - Delete the existing collision(s)
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], timeslot["end"], new_schedule, show
+                ),
+            )
+
+            # Delete collision(s)
+            for collision in timeslot["collisions"]:
+                try:
+                    to_delete.append(TimeSlot.objects.get(pk=collision["id"]))
+                except ObjectDoesNotExist:
+                    pass
+
+        if solution == "theirs-end":
+            # - Keep the existing timeslot
+            # - Create projected with end of existing start
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], existing["start"], new_schedule, show
+                ),
+            )
+
+        if solution == "ours-end":
+            # - Create the projected timeslot
+            # - Change the start of the existing collision to projected end
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], timeslot["end"], new_schedule, show
+                ),
+            )
+
+            existing_ts = TimeSlot.objects.get(pk=existing["id"])
+            existing_ts.start = parse_datetime(timeslot["end"])
+            to_update.append(existing_ts)
+
+        if solution == "theirs-start":
+            # - Keep existing
+            # - Create projected with start time of existing end
+            to_create.append(
+                TimeSlot.objects.instantiate(existing["end"], timeslot["end"], new_schedule, show),
+            )
+
+        if solution == "ours-start":
+            # - Create the projected timeslot
+            # - Change end of existing to projected start
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], timeslot["end"], new_schedule, show
+                ),
+            )
+
+            existing_ts = TimeSlot.objects.get(pk=existing["id"])
+            existing_ts.end = parse_datetime(timeslot["start"])
+            to_update.append(existing_ts)
+
+        if solution == "theirs-both":
+            # - Keep existing
+            # - Create two projected timeslots with end of existing start and start of existing end
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], existing["start"], new_schedule, show
+                ),
+            )
+
+            to_create.append(
+                TimeSlot.objects.instantiate(existing["end"], timeslot["end"], new_schedule, show),
+            )
+
+        if solution == "ours-both":
+            # - Create projected
+            # - Split existing into two
+            #   - Set existing end time to projected start
+            #   - Create another one with start = projected end and end = existing end
+            to_create.append(
+                TimeSlot.objects.instantiate(
+                    timeslot["start"], timeslot["end"], new_schedule, show
+                ),
+            )
+
+            existing_ts = TimeSlot.objects.get(pk=existing["id"])
+            existing_ts.end = parse_datetime(timeslot["start"])
+            to_update.append(existing_ts)
+
+            to_create.append(
+                TimeSlot.objects.instantiate(timeslot["end"], existing["end"], new_schedule, show),
+            )
+
+    # If there were any errors, don't make any db changes yet
+    # but add error messages and return already chosen solutions
+    if len(errors) > 0:
+        conflicts = make_conflicts(model_to_dict(new_schedule), new_schedule.pk, show.pk)
+
+        partly_resolved = conflicts["projected"]
+        saved_solutions = {}
+
+        # Add already chosen resolutions and error message to conflict
+        for index, projected_entry in enumerate(conflicts["projected"]):
+            # The element should only exist if there was a collision
+            if len(projected_entry["collisions"]) > 0:
+                saved_solutions[projected_entry["hash"]] = ""
+
+            if (
+                projected_entry["hash"] in solutions
+                and solutions[projected_entry["hash"]] in projected_entry["solution_choices"]
+            ):
+                saved_solutions[projected_entry["hash"]] = solutions[projected_entry["hash"]]
+
+            if projected_entry["hash"] in errors:
+                partly_resolved[index]["error"] = errors[projected_entry["hash"]]
+
+        # Re-insert post data
+        conflicts["projected"] = partly_resolved
+        conflicts["solutions"] = saved_solutions
+        conflicts["notes"] = data.get("notes")
+        conflicts["playlists"] = data.get("playlists")
+
+        raise ScheduleConflictError(
+            _("Not all conflicts have been resolved."),
+            code="unresolved-conflicts",
+            conflicts=conflicts,
+        )
+
+    remaining_timeslots = TimeSlot.objects.filter(
+        schedule=new_schedule,
+        start__gt=timezone.make_aware(datetime.combine(new_schedule.last_date, time(0, 0))),
+    )
+    for timeslot in remaining_timeslots:
+        to_delete.append(timeslot)
+
+    # If 'dryrun' is true, just return the projected changes instead of executing them
+    if "dryrun" in schedule and schedule["dryrun"]:
+        return {
+            "create": [model_to_dict(timeslot) for timeslot in to_create],
+            "update": [model_to_dict(timeslot) for timeslot in to_update],
+            "delete": [model_to_dict(timeslot) for timeslot in to_delete],
+        }
+
+    # Database changes if no errors found
+
+    if to_create:
+        new_schedule.save()
+
+    for timeslot in to_update:
+        timeslot.save(update_fields=["start", "end"])
+
+    for timeslot in to_create:
+        timeslot.schedule = new_schedule
+
+        # Reassign playlists
+        if "playlists" in data and timeslot.hash in data["playlists"]:
+            timeslot.playlist_id = int(data["playlists"][timeslot.hash])
+
+        timeslot.save()
+
+        # Reassign notes
+        if "notes" in data and timeslot.hash in data["notes"]:
+            try:
+                note = Note.objects.get(pk=int(data["notes"][timeslot.hash]))
+                note.timeslot_id = timeslot.id
+                note.save(update_fields=["timeslot_id"])
+
+                timeslot = TimeSlot.objects.get(pk=timeslot.id)
+                timeslot.note_id = note.id
+                timeslot.save(update_fields=["note_id"])
+            except ObjectDoesNotExist:
+                pass
+
+    for timeslot in to_delete:
+        timeslot.delete()
+
+    return model_to_dict(new_schedule)
+
+
+def instantiate_upcoming_schedule(
+    data: ScheduleData, show_pk: int, pk: int | None = None
+) -> Schedule:
+    """Returns an upcoming schedule instance for conflict resolution"""
+
+    rrule = RRule.objects.get(pk=data["rrule"])
+    show = Show.objects.get(pk=show_pk)
+
+    is_repetition = data["is_repetition"]
+
+    # default is `False`
+    add_business_days_only = (
+        data["add_business_days_only"] if "add_business_days_only" in data else False
+    )
+
+    # default is `None`
+    add_days_no = data.get("add_days_no")
+    by_weekday = data.get("by_weekday")
+    default_playlist_id = data.get("default_playlist_id")
+
+    first_date = parse_date(data["first_date"])
+    start_time = parse_time(data["start_time"])
+    end_time = parse_time(data["end_time"])
+
+    # last_date may not be present in data
+    if data.get("last_date") is not None:
+        last_date = parse_date(data["last_date"])
+    else:
+        # If last_date was not set, set it to the end of the year or add x days
+        if AUTO_SET_LAST_DATE_TO_END_OF_YEAR:
+            year = timezone.now().year
+            last_date = timezone.datetime(year, 12, 31).date()
+        else:
+            last_date = first_date + timedelta(days=+AUTO_SET_LAST_DATE_TO_DAYS_IN_FUTURE)
+
+    return Schedule(
+        pk=pk,
+        by_weekday=by_weekday,
+        rrule=rrule,
+        first_date=first_date,
+        start_time=start_time,
+        end_time=end_time,
+        last_date=last_date,
+        is_repetition=is_repetition,
+        default_playlist_id=default_playlist_id,
+        show=show,
+        add_days_no=add_days_no,
+        add_business_days_only=add_business_days_only,
+    )
+
+
+def make_conflicts(data: ScheduleData, schedule_pk: int | None, show_pk: int) -> Conflicts:
+    """
+    Retrieves POST vars
+    Generates a schedule
+    Generates conflicts: Returns timeslots, collisions, solutions as JSON
+    Returns conflicts dict
+    """
+
+    # Generate schedule to be saved
+    new_schedule = instantiate_upcoming_schedule(data, show_pk, schedule_pk)
+
+    # Copy if first_date changes for generating timeslots
+    schedule_copy = new_schedule
+
+    # Generate timeslots
+
+    # If extending: Get last timeslot and start generating from that date on
+    if schedule_pk is not None:
+        existing_schedule = Schedule.objects.get(pk=schedule_pk)
+
+        if new_schedule.last_date > existing_schedule.last_date:
+            last_timeslot = (
+                TimeSlot.objects.filter(schedule=existing_schedule).order_by("start").reverse()[0]
+            )
+            schedule_copy.first_date = last_timeslot.start.date() + timedelta(days=1)
+
+    timeslots = generate_timeslots(schedule_copy)
+
+    # Generate conflicts and add schedule
+    conflicts = generate_conflicts(timeslots)
+
+    # create a new dictionary by adding "schedule" to conflicts
+    return dict(conflicts, schedule=model_to_dict(new_schedule))
+
+
+def generate_timeslots(schedule: Schedule) -> list[TimeSlot]:
+    """
+    Returns a list of timeslot objects based on a schedule and its rrule
+    Returns past timeslots as well, starting from first_date (not today)
+    """
+    timeslots = []
+
+    # adjust last_date if end_time is after midnight
+    if schedule.end_time < schedule.start_time:
+        last_date = schedule.first_date + timedelta(days=+1)
+    else:
+        last_date = schedule.first_date
+
+    if schedule.rrule.freq == 3:  # daily: Ignore schedule.by_weekday to set by_weekday
+        by_weekday_start = by_weekday_end = (0, 1, 2, 3, 4, 5, 6)
+    elif (
+        schedule.rrule.freq == 2
+        and schedule.rrule.interval == 1
+        and schedule.rrule.by_weekdays is None
+    ):  # weekly: Use schedule.by_weekday for by_weekday
+        by_weekday_start = by_weekday_end = int(schedule.by_weekday)
+
+        # adjust by_weekday_end if end_time is after midnight
+        if schedule.end_time < schedule.start_time:
+            by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0
+    elif (
+        schedule.rrule.freq == 2
+        and schedule.rrule.interval == 1
+        and schedule.rrule.by_weekdays == "0,1,2,3,4"
+    ):  # weekly on business days: Use schedule.rrule.by_weekdays to set by_weekday
+        by_weekday_start = by_weekday_end = [
+            int(wd) for wd in schedule.rrule.by_weekdays.split(",")
+        ]
+
+        # adjust by_weekday_end if end_time is after midnight
+        if schedule.end_time < schedule.start_time:
+            by_weekday_end = (1, 2, 3, 4, 5)
+    elif (
+        schedule.rrule.freq == 2
+        and schedule.rrule.interval == 1
+        and schedule.rrule.by_weekdays == "5,6"
+    ):  # weekly on weekends: Use schedule.rrule.by_weekdays to set by_weekday
+        by_weekday_start = by_weekday_end = [
+            int(wd) for wd in schedule.rrule.by_weekdays.split(",")
+        ]
+
+        # adjust by_weekday_end if end_time is after midnight
+        if schedule.end_time < schedule.start_time:
+            by_weekday_end = (6, 0)
+    elif schedule.rrule.freq == 0:  # once: Ignore schedule.by_weekday to set by_weekday
+        by_weekday_start = by_weekday_end = None
+    else:
+        by_weekday_start = by_weekday_end = (
+            int(schedule.by_weekday) if schedule.by_weekday is not None else None
+        )
+
+        # adjust by_weekday_end if end_time is after midnight
+        if schedule.end_time < schedule.start_time:
+            by_weekday_end = by_weekday_start + 1 if by_weekday_start < 6 else 0
+
+    if schedule.rrule.freq == 0:  # once:
+        starts = [datetime.combine(schedule.first_date, schedule.start_time)]
+        ends = [datetime.combine(last_date, schedule.end_time)]
+    else:
+        starts = list(
+            rrule(
+                freq=schedule.rrule.freq,
+                dtstart=datetime.combine(schedule.first_date, schedule.start_time),
+                interval=schedule.rrule.interval,
+                until=schedule.last_date + relativedelta(days=+1),
+                bysetpos=schedule.rrule.by_set_pos,
+                byweekday=by_weekday_start,
+            )
+        )
+        ends = list(
+            rrule(
+                freq=schedule.rrule.freq,
+                dtstart=datetime.combine(last_date, schedule.end_time),
+                interval=schedule.rrule.interval,
+                until=schedule.last_date + relativedelta(days=+1),
+                bysetpos=schedule.rrule.by_set_pos,
+                byweekday=by_weekday_end,
+            )
+        )
+
+    for k in range(min(len(starts), len(ends))):
+        # Correct dates for the (relatively seldom) case if:
+        # E.g.: 1st Monday from 23:00:00 to 1st Tuesday 00:00:00
+        #       produces wrong end dates if the 1st Tuesday is before the 1st Monday
+        #       In this case we take the next day instead of rrule's calculated end
+        if starts[k] > ends[k]:
+            ends[k] = datetime.combine(starts[k] + relativedelta(days=+1), schedule.end_time)
+
+        """
+        Add a number of days to the generated dates?
+
+        This can be helpful for repetitions:
+
+        Examples:
+
+          1. If RRule is "Every 1st Monday" and we want its repetition always to be on the
+             following day, the repetition's RRule is the same but add_days_no is 1
+
+             If we would set the repetition to "Every 1st Tuesday" instead
+             we will get unmeant results if the 1st Tuesday is before the 1st Monday
+             (e.g. 1st Tue = May 1 2018, 1st Mon = May 7 2018)
+
+          2. If RRule is "Every 1st Friday" and we want its repetition always to be on the
+             following business day, the repetition's RRule is the same but add_days_no is 1
+             and add_business_days_only is True (e.g. original date = Fri, March 2 2018;
+             generated date = Mon, March 5 2018)
+
+        In the UI these can be presets:
+            "On the following day" (add_days_no=1,add_business_days_only=False) or
+            "On the following business day" (add_days_no=1,add_business_days_only=True)
+
+        """
+        if schedule.add_days_no is not None and schedule.add_days_no > 0:
+            # If only business days and weekday is Fri, Sat or Sun: add add_days_no beginning
+            # from Sunday
+            weekday = datetime.date(starts[k]).weekday()
+            if schedule.add_business_days_only and weekday > 3:
+                days_until_sunday = 6 - weekday
+                starts[k] = starts[k] + relativedelta(
+                    days=+days_until_sunday + schedule.add_days_no
+                )
+                ends[k] = ends[k] + relativedelta(days=+days_until_sunday + schedule.add_days_no)
+            else:
+                starts[k] = starts[k] + relativedelta(days=+schedule.add_days_no)
+                ends[k] = ends[k] + relativedelta(days=+schedule.add_days_no)
+
+            if ends[k].date() > schedule.last_date:
+                schedule.last_date = ends[k].date()
+        timeslots.append(
+            TimeSlot(
+                schedule=schedule,
+                start=timezone.make_aware(starts[k], is_dst=True),
+                end=timezone.make_aware(ends[k], is_dst=True),
+            )
+        )
+
+    return timeslots
+
+
+def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
+    """
+    Tests a list of timeslot objects for colliding timeslots in the database
+    Returns a list of conflicts containing dicts of projected timeslots, collisions and
+    solutions
+    """
+
+    conflicts: Conflicts = {}
+    projected = []
+    solutions = {}
+
+    # Cycle each timeslot
+    for ts in timeslots:
+        # Contains collisions
+        collisions = []
+
+        # Contains possible solutions
+        solution_choices = set()
+
+        # Get collisions for each timeslot
+        collision_list = list(TimeSlot.objects.get_colliding_timeslots(ts).order_by("start"))
+
+        # Add the projected timeslot
+        projected_entry = {
+            "hash": ts.hash,
+            "start": str(ts.start),
+            "end": str(ts.end),
+        }
+
+        for c in collision_list:
+            # Add the collision
+            collision = {
+                "id": c.id,
+                "start": str(c.start),
+                "end": str(c.end),
+                "playlist_id": c.playlist_id,
+                "show": c.show.id,
+                "show_name": c.show.name,
+                "schedule": c.schedule_id,
+                "memo": c.memo,
+            }
+
+            # Get note
+            try:
+                note = Note.objects.get(timeslot=c.id)
+                collision["note_id"] = note.pk
+            except ObjectDoesNotExist:
+                collision["note_id"] = None
+
+            collisions.append(collision)
+
+            """Determine acceptable solutions"""
+
+            if len(collision_list) > 1:
+                # If there is more than one collision: Only these two are supported at the
+                # moment
+                solution_choices.add("theirs")
+                solution_choices.add("ours")
+            else:
+                # These two are always possible: Either keep theirs and remove ours or vice
+                # versa
+                solution_choices.add("theirs")
+                solution_choices.add("ours")
+
+                # Partly overlapping: projected starts earlier than existing and ends earlier
+                #
+                #    ex.  pr.
+                #        +--+
+                #        |  |
+                #   +--+ |  |
+                #   |  | +--+
+                #   |  |
+                #   +--+
+                #
+                if ts.end > c.start > ts.start <= c.end:
+                    solution_choices.add("theirs-end")
+                    solution_choices.add("ours-end")
+
+                # Partly overlapping: projected starts later than existing and ends later
+                #
+                #    ex.  pr.
+                #   +--+
+                #   |  |
+                #   |  | +--+
+                #   +--+ |  |
+                #        |  |
+                #        +--+
+                #
+                if c.start <= ts.start < c.end < ts.end:
+                    solution_choices.add("theirs-start")
+                    solution_choices.add("ours-start")
+
+                # Fully overlapping: projected starts earlier and ends later than existing
+                #
+                #    ex.  pr.
+                #        +--+
+                #   +--+ |  |
+                #   |  | |  |
+                #   +--+ |  |
+                #        +--+
+                #
+                if ts.start < c.start and ts.end > c.end:
+                    solution_choices.add("theirs-end")
+                    solution_choices.add("theirs-start")
+                    solution_choices.add("theirs-both")
+
+                # Fully overlapping: projected starts later and ends earlier than existing
+                #
+                #    ex.  pr.
+                #   +--+
+                #   |  | +--+
+                #   |  | |  |
+                #   |  | +--+
+                #   +--+
+                #
+                if ts.start > c.start and ts.end < c.end:
+                    solution_choices.add("ours-end")
+                    solution_choices.add("ours-start")
+                    solution_choices.add("ours-both")
+
+        if len(collisions) > 0:
+            solutions[ts.hash] = ""
+
+        projected_entry["collisions"] = collisions
+        projected_entry["solution_choices"] = solution_choices
+        projected_entry["error"] = None
+        projected.append(projected_entry)
+
+    conflicts["projected"] = projected
+    conflicts["solutions"] = solutions
+    conflicts["notes"] = {}
+    conflicts["playlists"] = {}
+
+    return conflicts
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/utils.py b/program/utils.py
index d21eaaf81ea9f1e66d2b4551d59a190fdd1cc262..3295beb3d813eaf1adf42f84dfc52e3a0afa7d20 100644
--- a/program/utils.py
+++ b/program/utils.py
@@ -19,6 +19,7 @@
 #
 
 import json
+import typing
 from datetime import date, datetime, time
 from typing import Dict, Optional, Tuple, Union
 
@@ -28,6 +29,9 @@ from rest_framework import exceptions
 from django.utils import timezone
 from steering.settings import CBA_AJAX_URL, CBA_API_KEY, DEBUG
 
+if typing.TYPE_CHECKING:
+    from program.models import Host, Note, Show
+
 
 def parse_datetime(date_string: str) -> datetime:
     """
@@ -56,7 +60,11 @@ def parse_time(date_string: str) -> time:
     """
     parse a time string and return a time object
     """
-    return datetime.strptime(date_string, "%H:%M:%S").time()
+
+    if len(date_string) == 5:
+        return datetime.strptime(date_string, "%H:%M").time()
+    else:
+        return datetime.strptime(date_string, "%H:%M:%S").time()
 
 
 def get_audio_url(cba_id: Optional[int]) -> str:
@@ -110,6 +118,16 @@ def get_values(
         return int_if_digit(values[0])
 
 
+def delete_links(instance: Union["Host", "Note", "Show"]) -> Union["Host", "Note", "Show"]:
+    """Delete the links associated with the instance."""
+
+    if instance.links.count() > 0:
+        for link in instance.links.all():
+            link.delete(keep_parents=True)
+
+    return instance
+
+
 class DisabledObjectPermissionCheckMixin:
     """
     At the time of writing permission checks were entirely circumvented by manual
diff --git a/program/views.py b/program/views.py
index 66804e342b6a5d6743087b77d2efb1658a476acc..c4ece009fc5dc94685e94548d4905650c102ae39 100644
--- a/program/views.py
+++ b/program/views.py
@@ -26,12 +26,14 @@ 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.http import HttpResponse
-from django.shortcuts import get_object_or_404
+from django.core.exceptions import FieldError
+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 _
 from program import filters
@@ -39,9 +41,13 @@ from program.models import (
     Category,
     FundingCategory,
     Host,
+    Image,
     Language,
+    LicenseType,
+    LinkType,
     MusicFocus,
     Note,
+    RRule,
     Schedule,
     ScheduleConflictError,
     Show,
@@ -54,9 +60,13 @@ from program.serializers import (
     ErrorSerializer,
     FundingCategorySerializer,
     HostSerializer,
+    ImageSerializer,
     LanguageSerializer,
+    LicenseTypeSerializer,
+    LinkTypeSerializer,
     MusicFocusSerializer,
     NoteSerializer,
+    RRuleSerializer,
     ScheduleConflictResponseSerializer,
     ScheduleCreateUpdateRequestSerializer,
     ScheduleDryRunResponseSerializer,
@@ -68,6 +78,7 @@ from program.serializers import (
     TypeSerializer,
     UserSerializer,
 )
+from program.services import resolve_conflicts
 from program.utils import (
     DisabledObjectPermissionCheckMixin,
     NestedObjectFinderMixin,
@@ -94,7 +105,8 @@ def timeslot_entry(*, timeslot: TimeSlot) -> dict:
         "end": timezone.make_naive(timeslot.end).strftime("%Y-%m-%dT%H:%M:%S"),
         "title": title,
         "schedule_id": schedule.id,
-        "is_repetition": timeslot.is_repetition,
+        # `Timeslot.repetition_of` is a foreign key that can be null
+        "is_repetition": timeslot.repetition_of.id if timeslot.repetition_of else False,
         "playlist_id": playlist_id,
         "schedule_default_playlist_id": schedule.default_playlist_id,
         "show_default_playlist_id": show.default_playlist_id,
@@ -287,6 +299,63 @@ class APIUserViewSet(
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
+class APIImageViewSet(viewsets.ModelViewSet):
+    queryset = Image.objects.all()
+    serializer_class = ImageSerializer
+    permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
+    pagination_class = LimitOffsetPagination
+
+    def get_queryset(self):
+        """The queryset contains only images where the owner is the request's user."""
+
+        return Image.objects.filter(owner=self.request.user.username)
+
+    def create(self, request, *args, **kwargs):
+        """Create an Image instance. Any user can create an image."""
+
+        serializer = ImageSerializer(
+            data=request.data,
+            context={"owner": request.user.username},
+        )
+
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    def update(self, request, *args, **kwargs):
+        """Update an Image instance. Only the creator can update an image."""
+
+        image = self.get_object()
+
+        if image.owner != request.user.username:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        serializer = ImageSerializer(
+            image,
+            data=request.data,
+        )
+
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data)
+
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    def destroy(self, request, *args, **kwargs):
+        """Destroy an Image instance. Only the owner can delete an image."""
+
+        image = self.get_object()
+
+        if image.owner != request.user.username:
+            return Response(status=status.HTTP_401_UNAUTHORIZED)
+
+        image.delete()
+
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
 @extend_schema_view(
     create=extend_schema(summary="Create a new show."),
     retrieve=extend_schema(summary="Retrieve a single show."),
@@ -302,6 +371,23 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
     pagination_class = LimitOffsetPagination
     filterset_class = filters.ShowFilterSet
 
+    def list(self, request, *args, **kwargs):
+        filter_kwargs = {}
+        for key, value in request.query_params.items():
+            filter_kwargs[key] = value
+
+        try:
+            queryset = get_list_or_404(self.get_queryset(), **filter_kwargs)
+        except FieldError:
+            queryset = None
+
+        if page := self.paginate_queryset(queryset) is not None:
+            serializer = self.get_serializer(page, many=True)
+            return self.get_paginated_response(serializer.data)
+
+        serializer = self.get_serializer(queryset, many=True)
+        return Response(serializer.data)
+
     def get_object(self):
         queryset = self.filter_queryset(self.get_queryset())
         lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
@@ -323,7 +409,9 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
         if not request.user.is_superuser:
             return Response(status=status.HTTP_401_UNAUTHORIZED)
 
-        serializer = ShowSerializer(data=request.data)
+        serializer = ShowSerializer(
+            data=request.data, context={"created_by": request.user.username}
+        )
 
         if serializer.is_valid():
             serializer.save()
@@ -343,8 +431,14 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
         ):
             return Response(status=status.HTTP_401_UNAUTHORIZED)
 
+        partial = kwargs.get("partial", False)
         show = self.get_object()
-        serializer = ShowSerializer(show, data=request.data, context={"user": request.user})
+        serializer = ShowSerializer(
+            show,
+            data=request.data,
+            context={"updated_by": request.user.username},
+            partial=partial,
+        )
 
         if serializer.is_valid():
             # Common users mustn't edit the show's name
@@ -355,6 +449,10 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
 
         return Response(serializer.errors, 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 may delete shows.
@@ -368,6 +466,15 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
+@extend_schema_view(
+    retrieve=extend_schema(summary="Retrieve a single rrule."),
+    list=extend_schema(summary="List all rrule."),
+)
+class APIRRuleViewSet(viewsets.ModelViewSet):
+    queryset = RRule.objects.all()
+    serializer_class = RRuleSerializer
+
+
 @extend_schema_view(
     create=extend_schema(
         summary="Create a new schedule.",
@@ -425,7 +532,7 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
                     * 'This change on the timeslot is not allowed.'
                       When adding: There was a change in the schedule's data during conflict
                       resolution.
-                      When updating: Fields 'start', 'end', 'byweekday' or 'rrule' have changed,
+                      When updating: Fields 'start', 'end', 'by_weekday' or 'rrule' have changed,
                       which is not allowed.
                     * 'No solution given': No solution was provided for the conflict in
                       `solutions`. Provide a value of `solution_choices`.
@@ -510,7 +617,7 @@ class APIScheduleViewSet(
             return Response(status=status.HTTP_400_BAD_REQUEST)
 
         try:
-            resolution = Schedule.resolve_conflicts(request.data, pk, show_pk)
+            resolution = resolve_conflicts(request.data, pk, show_pk)
         except ScheduleConflictError as exc:
             return Response(exc.conflicts, status.HTTP_409_CONFLICT)
 
@@ -553,7 +660,7 @@ class APIScheduleViewSet(
             return Response(serializer.data)
 
         try:
-            resolution = Schedule.resolve_conflicts(request.data, schedule.pk, schedule.show.pk)
+            resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk)
         except ScheduleConflictError as exc:
             return Response(exc.conflicts, status.HTTP_409_CONFLICT)
 
@@ -627,7 +734,7 @@ class APITimeSlotViewSet(
             # We do this because the Dashboard needs to update the repetition timeslot as well
             # but with another playlist containing the recording instead of the original playlist
             if first_repetition := TimeSlot.objects.filter(
-                show=show_pk, start__gt=timeslot.start, is_repetition=True
+                show=show_pk, start__gt=timeslot.start, repetition_of=timeslot
             ).first():
                 serializer = TimeSlotSerializer(first_repetition)
                 return Response(serializer.data)
@@ -667,81 +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)
+    filterset_class = filters.NoteFilterSet
 
-        serializer = NoteSerializer(
-            data={"show": show_pk, "timeslot": timeslot_pk} | request.data,
-            context={"user_id": request.user.id},
-        )
-
-        if serializer.is_valid():
-            hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True))
-            if not request.user.is_superuser and request.data["host"] not in hosts:
-                serializer.validated_data["host"] = None
-
-            serializer.save()
-            return Response(serializer.data, status=status.HTTP_201_CREATED)
-
-        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-    def update(self, request, *args, **kwargs):
-        """
-        Only 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)
-
-        if serializer.is_valid():
-            hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True))
-            # Don't assign a host the user mustn't edit. Reassign the original value instead
-            if not request.user.is_superuser and int(request.data["host"]) not in hosts:
-                serializer.validated_data["host"] = Host.objects.filter(pk=note.host_id)[0]
-
-            serializer.save()
-            return Response(serializer.data)
-
-        return Response(status=status.HTTP_400_BAD_REQUEST)
-
-    def destroy(self, request, *args, **kwargs):
-        """
-        Only 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)
+    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()
 
-        self.get_object().delete()
-
-        return Response(status=status.HTTP_204_NO_CONTENT)
+    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:
@@ -838,3 +909,58 @@ class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
     queryset = Host.objects.all()
     serializer_class = HostSerializer
     pagination_class = LimitOffsetPagination
+
+    def create(self, request, *args, **kwargs):
+        serializer = HostSerializer(
+            data=request.data, context={"created_by": request.user.username}
+        )
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+        else:
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    def update(self, request, *args, **kwargs):
+        partial = kwargs.get("partial", False)
+        host = self.get_object()
+        serializer = HostSerializer(
+            host,
+            data=request.data,
+            context={"updated_by": request.user.username},
+            partial=partial,
+        )
+        if serializer.is_valid():
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK)
+        else:
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    def partial_update(self, request, *args, **kwargs):
+        kwargs["partial"] = True
+        return self.update(request, *args, **kwargs)
+
+
+@extend_schema_view(
+    create=extend_schema(summary="Create a new link type."),
+    retrieve=extend_schema(summary="Retrieve a single link type."),
+    update=extend_schema(summary="Update an existing link type."),
+    partial_update=extend_schema(summary="Partially update an existing link type."),
+    destroy=extend_schema(summary="Delete an existing link type."),
+    list=extend_schema(summary="List all link types."),
+)
+class APILinkTypeViewSet(viewsets.ModelViewSet):
+    queryset = LinkType.objects.all()
+    serializer_class = LinkTypeSerializer
+
+
+@extend_schema_view(
+    create=extend_schema(summary="Create a new license type."),
+    retrieve=extend_schema(summary="Retrieve a single license type."),
+    update=extend_schema(summary="Update an existing license type."),
+    partial_update=extend_schema(summary="Partially update an existing license type."),
+    destroy=extend_schema(summary="Delete an existing license type."),
+    list=extend_schema(summary="List all license types."),
+)
+class APILicenseTypeViewSet(viewsets.ModelViewSet):
+    queryset = LicenseType.objects.all()
+    serializer_class = LicenseTypeSerializer
diff --git a/steering/settings.py b/steering/settings.py
index d9f569510f0dfbeeafd2686318c50f239797a212..a646b46ef7c0341b8d09fc660f003c1148e85001 100644
--- a/steering/settings.py
+++ b/steering/settings.py
@@ -110,7 +110,6 @@ INSTALLED_APPS = (
     "django.contrib.admin",
     "django.contrib.staticfiles",
     "program",
-    "profile",
     "versatileimagefield",
     "rest_framework",
     "rest_framework_nested",
diff --git a/steering/urls.py b/steering/urls.py
index f0a4311fb4e5e3607b25c6f688feb86c40aaaee4..70df93704acabc6716f9789ba03adba5a8f0155e 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -21,15 +21,21 @@
 from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
 from rest_framework_nested import routers
 
+from django.conf import settings
+from django.conf.urls.static import static
 from django.contrib import admin
 from django.urls import include, path
 from program.views import (
     APICategoryViewSet,
     APIFundingCategoryViewSet,
     APIHostViewSet,
+    APIImageViewSet,
     APILanguageViewSet,
+    APILicenseTypeViewSet,
+    APILinkTypeViewSet,
     APIMusicFocusViewSet,
     APINoteViewSet,
+    APIRRuleViewSet,
     APIScheduleViewSet,
     APIShowViewSet,
     APITimeSlotViewSet,
@@ -52,9 +58,13 @@ router.register(r"notes", APINoteViewSet)
 router.register(r"categories", APICategoryViewSet)
 router.register(r"topics", APITopicViewSet)
 router.register(r"types", APITypeViewSet)
-router.register(r"musicfocus", APIMusicFocusViewSet)
-router.register(r"fundingcategories", 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"rrules", APIRRuleViewSet)
+router.register(r"images", APIImageViewSet)
 
 # Nested Routers
 
@@ -68,25 +78,17 @@ show_router.register(r"notes", APINoteViewSet, basename="show-notes")
 
 # /shows/1/timeslots
 show_router.register(r"timeslots", APITimeSlotViewSet, basename="show-timeslots")
-show_timeslot_router = routers.NestedSimpleRouter(
-    show_router, r"timeslots", lookup="timeslot"
-)
+show_timeslot_router = routers.NestedSimpleRouter(show_router, r"timeslots", lookup="timeslot")
 
 # /shows/1/timeslots/1/note/
 show_timeslot_router.register(r"note", APINoteViewSet, basename="show-timeslots-note")
 
 # /shows/1/schedules
-schedule_router = routers.NestedSimpleRouter(
-    show_router, r"schedules", lookup="schedule"
-)
+schedule_router = routers.NestedSimpleRouter(show_router, r"schedules", lookup="schedule")
 
 # /shows/1/schedules/1/timeslots
-schedule_router.register(
-    r"timeslots", APITimeSlotViewSet, basename="schedule-timeslots"
-)
-timeslot_router = routers.NestedSimpleRouter(
-    schedule_router, r"timeslots", lookup="timeslot"
-)
+schedule_router.register(r"timeslots", APITimeSlotViewSet, basename="schedule-timeslots")
+timeslot_router = routers.NestedSimpleRouter(schedule_router, r"timeslots", lookup="timeslot")
 
 # /shows/1/schedules/1/timeslots/1/note
 timeslot_router.register(r"note", APINoteViewSet, basename="timeslots-note")
@@ -108,4 +110,4 @@ urlpatterns = [
         name="swagger-ui",
     ),
     path("admin/", admin.site.urls),
-]
+] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)