From ec08012bd6d3ac1c57d45b6d9f21f5560e1fa8dc Mon Sep 17 00:00:00 2001
From: Konrad Mohrfeldt <konrad.mohrfeldt@farbdev.org>
Date: Tue, 22 Mar 2022 21:30:53 +0100
Subject: [PATCH] feat: add schema documentation with drf-spectacular

This adds extensive API documentation based on the official API
documentation [1] and conflict resolution [2] documents.

Where possible field documentation was added to models or serializers,
so that other code like auto-generated forms can also profit from these
changes (hence the migration part of this commit).

The changes introduce two new API endpoints.

`/api/v1/schema/` exposes the API schema as an OpenAPI 3.0.3 document.
The standard format is yaml but can be switched to JSON by appending the
`?format=json` query parameter.

`/api/v1/schema/swagger-ui/` renders a visual representation of the
OpenAPI 3 specification with support for testing the individual API
endpoints including authentication.

[1]
https://gitlab.servus.at/aura/meta/-/blob/ec3c753d34ccb0269969808ac7dc28fff2ff1648/docs/development/api-definition.md
[2]
https://gitlab.servus.at/aura/meta/-/blob/ec3c753d34ccb0269969808ac7dc28fff2ff1648/docs/development/conflict-resolution.md
---
 program/auth.py                               |  26 ++
 program/filters.py                            |   3 +-
 program/migrations/0018_auto_20220322_2113.py | 103 ++++++
 program/models.py                             |  82 ++++-
 program/serializers.py                        | 170 ++++++++-
 program/views.py                              | 323 +++++++++++++-----
 requirements.txt                              |   1 +
 steering/schema.py                            |  59 ++++
 steering/settings.py                          |  13 +
 steering/urls.py                              |   7 +
 10 files changed, 675 insertions(+), 112 deletions(-)
 create mode 100644 program/migrations/0018_auto_20220322_2113.py
 create mode 100644 steering/schema.py

diff --git a/program/auth.py b/program/auth.py
index 19d2b997..723b9495 100644
--- a/program/auth.py
+++ b/program/auth.py
@@ -18,6 +18,8 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
+from drf_spectacular.extensions import OpenApiAuthenticationExtension
+from drf_spectacular.plumbing import build_bearer_security_scheme_object
 from oidc_provider.lib.utils.oauth2 import extract_access_token
 from oidc_provider.models import Token
 from rest_framework import authentication, exceptions
@@ -40,3 +42,27 @@ class OidcOauth2Auth(authentication.BaseAuthentication):
             raise exceptions.AuthenticationFailed("The oauth2 token has expired")
 
         return oauth2_token.user, None
+
+
+class OidcOauth2AuthenticationScheme(OpenApiAuthenticationExtension):
+    target_class = "program.auth.OidcOauth2Auth"
+    name = "tokenAuth"
+
+    def get_security_definition(self, auto_schema):
+        # One might be inclined to return a list here, because the bearer token
+        # can also be passed as an `access_token` query parameter.
+        # Officially this seems to be supported, but once we return a list
+        # the authorization helper in the generated documentation is empty,
+        # so we only return the primary mode instead.
+        return build_bearer_security_scheme_object("Authorization", "Bearer")
+
+        # This should work, but doesn’t for the reasons above:
+        #
+        # return [
+        #     build_bearer_security_scheme_object("Authorization", "Bearer"),
+        #     {
+        #         "type": "apiKey",
+        #         "in": "query",
+        #         "name": "access_token",
+        #     }
+        # ]
diff --git a/program/filters.py b/program/filters.py
index 7e8a3eb7..ea43ff7b 100644
--- a/program/filters.py
+++ b/program/filters.py
@@ -48,7 +48,8 @@ class ShowFilterSet(StaticFilterHelpTextMixin, filters.FilterSet):
         field_name="is_active",
         method="filter_active",
         help_text=(
-            "Return only currently running shows if true or past or upcoming shows if false.",
+            "Return only currently running shows (with timeslots in the future) if true "
+            "or past or upcoming shows if false."
         ),
     )
     host = ModelMultipleChoiceFilter(
diff --git a/program/migrations/0018_auto_20220322_2113.py b/program/migrations/0018_auto_20220322_2113.py
new file mode 100644
index 00000000..70dc947b
--- /dev/null
+++ b/program/migrations/0018_auto_20220322_2113.py
@@ -0,0 +1,103 @@
+# Generated by Django 3.2.12 on 2022-03-22 20:13
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("program", "0017_auto_20220302_1711"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="schedule",
+            name="add_business_days_only",
+            field=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.",  # noqa: E501
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="add_days_no",
+            field=models.IntegerField(
+                blank=True,
+                help_text="Add a number of days to the generated dates. This can be useful for repetitions, like 'On the following day'.",  # noqa: E501
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="by_weekday",
+            field=models.IntegerField(
+                choices=[
+                    (0, "Monday"),
+                    (1, "Tuesday"),
+                    (2, "Wednesday"),
+                    (3, "Thursday"),
+                    (4, "Friday"),
+                    (5, "Saturday"),
+                    (6, "Sunday"),
+                ],
+                help_text="Number of the Weekday.",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="default_playlist_id",
+            field=models.IntegerField(
+                blank=True,
+                help_text="A tank ID in case the timeslot's playlist_id is empty.",
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="end_time",
+            field=models.TimeField(help_text="End time of schedule."),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="first_date",
+            field=models.DateField(help_text="Start date of schedule."),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="is_repetition",
+            field=models.BooleanField(
+                default=False, help_text="Whether the schedule is a repetition."
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="last_date",
+            field=models.DateField(help_text="End date of schedule."),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="rrule",
+            field=models.ForeignKey(
+                help_text="\nA recurrence rule.\n\n* 1 = once,\n* 2 = daily,\n* 3 = business days,\n* 4 = weekly,\n* 5 = biweekly,\n* 6 = every four weeks,\n* 7 = every even calendar week (ISO 8601),\n* 8 = every odd calendar week (ISO 8601),\n* 9 = every 1st week of month,\n* 10 = every 2nd week of month,\n* 11 = every 3rd week of month,\n* 12 = every 4th week of month,\n* 13 = every 5th week of month\n",  # noqa: E501
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="schedules",
+                to="program.rrule",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="show",
+            field=models.ForeignKey(
+                help_text="Show the schedule belongs to.",
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="schedules",
+                to="program.show",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schedule",
+            name="start_time",
+            field=models.TimeField(help_text="Start time of schedule."),
+        ),
+    ]
diff --git a/program/models.py b/program/models.py
index fe132cea..cb5991e0 100644
--- a/program/models.py
+++ b/program/models.py
@@ -19,6 +19,7 @@
 #
 
 from datetime import datetime, time, timedelta
+from textwrap import dedent
 
 from dateutil.relativedelta import relativedelta
 from dateutil.rrule import rrule
@@ -225,17 +226,76 @@ class RRule(models.Model):
 
 
 class Schedule(models.Model):
-    rrule = models.ForeignKey(RRule, on_delete=models.CASCADE, related_name="schedules")
-    show = models.ForeignKey(Show, on_delete=models.CASCADE, related_name="schedules")
-    by_weekday = models.IntegerField()
-    first_date = models.DateField()
-    start_time = models.TimeField()
-    end_time = models.TimeField()
-    last_date = models.DateField()
-    is_repetition = models.BooleanField(default=False)
-    add_days_no = models.IntegerField(blank=True, null=True)
-    add_business_days_only = models.BooleanField(default=False)
-    default_playlist_id = models.IntegerField(blank=True, null=True)
+    rrule = models.ForeignKey(
+        RRule,
+        on_delete=models.CASCADE,
+        related_name="schedules",
+        help_text=dedent(
+            """
+            A recurrence rule.
+
+            * 1 = once,
+            * 2 = daily,
+            * 3 = business days,
+            * 4 = weekly,
+            * 5 = biweekly,
+            * 6 = every four weeks,
+            * 7 = every even calendar week (ISO 8601),
+            * 8 = every odd calendar week (ISO 8601),
+            * 9 = every 1st week of month,
+            * 10 = every 2nd week of month,
+            * 11 = every 3rd week of month,
+            * 12 = every 4th week of month,
+            * 13 = every 5th week of month
+            """
+        ),
+    )
+    show = models.ForeignKey(
+        Show,
+        on_delete=models.CASCADE,
+        related_name="schedules",
+        help_text="Show the schedule belongs to.",
+    )
+    by_weekday = models.IntegerField(
+        help_text="Number of the Weekday.",
+        choices=[
+            (0, "Monday"),
+            (1, "Tuesday"),
+            (2, "Wednesday"),
+            (3, "Thursday"),
+            (4, "Friday"),
+            (5, "Saturday"),
+            (6, "Sunday"),
+        ],
+    )
+    first_date = models.DateField(help_text="Start date of schedule.")
+    start_time = models.TimeField(help_text="Start time of schedule.")
+    end_time = models.TimeField(help_text="End time of schedule.")
+    last_date = models.DateField(help_text="End 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."
+        ),
+    )
+    default_playlist_id = models.IntegerField(
+        blank=True,
+        null=True,
+        help_text="A tank ID in case the timeslot's playlist_id is empty.",
+    )
 
     class Meta:
         ordering = ("first_date", "start_time")
diff --git a/program/serializers.py b/program/serializers.py
index 48191044..76a903cb 100644
--- a/program/serializers.py
+++ b/program/serializers.py
@@ -20,6 +20,7 @@
 
 from profile.models import Profile
 from profile.serializers import ProfileSerializer
+from typing import List
 
 from rest_framework import serializers
 
@@ -44,6 +45,37 @@ from program.models import (
 from program.utils import get_audio_url
 from steering.settings import THUMBNAIL_SIZES
 
+SOLUTION_CHOICES = {
+    "theirs": "Discard projected timeslot. Keep existing timeslot(s).",
+    "ours": "Create projected timeslot. Delete existing timeslot(s).",
+    "theirs-start": (
+        "Keep existing timeslot. Create projected timeslot with start time of existing end."
+    ),
+    "ours-start": (
+        "Create projected timeslot. Change end of existing timeslot to projected start time."
+    ),
+    "theirs-end": (
+        "Keep existing timeslot. Create projected timeslot with end of existing start time."
+    ),
+    "ours-end": (
+        "Create projected timeslot. Change start of existing timeslot to projected end time."
+    ),
+    "theirs-both": (
+        "Keep existing timeslot. "
+        "Create two projected timeslots with end of existing start and start of existing end."
+    ),
+    "ours-both": (
+        "Create projected timeslot. Split existing timeslot into two: \n\n"
+        "* set existing end time to projected start,\n"
+        "* create another timeslot with start = projected end and end = existing end."
+    ),
+}
+
+
+class ErrorSerializer(serializers.Serializer):
+    message = serializers.CharField()
+    code = serializers.CharField(allow_null=True)
+
 
 class UserSerializer(serializers.ModelSerializer):
     # Add profile fields to JSON
@@ -139,10 +171,10 @@ class LinkSerializer(serializers.ModelSerializer):
 
 class HostSerializer(serializers.ModelSerializer):
     links = LinkSerializer(many=True, required=False)
-    thumbnails = serializers.SerializerMethodField()  # Read-only
+    thumbnails = serializers.SerializerMethodField()
 
     @staticmethod
-    def get_thumbnails(host):
+    def get_thumbnails(host) -> List[str]:
         """Returns thumbnails"""
         thumbnails = []
 
@@ -261,10 +293,10 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
     predecessor = serializers.PrimaryKeyRelatedField(
         queryset=Show.objects.all(), required=False, allow_null=True
     )
-    thumbnails = serializers.SerializerMethodField()  # Read-only
+    thumbnails = serializers.SerializerMethodField()
 
     @staticmethod
-    def get_thumbnails(show):
+    def get_thumbnails(show) -> List[str]:
         """Returns thumbnails"""
         thumbnails = []
 
@@ -372,14 +404,44 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
 
 
 class ScheduleSerializer(serializers.ModelSerializer):
-    rrule = serializers.PrimaryKeyRelatedField(queryset=RRule.objects.all())
-    show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all())
+    rrule = serializers.PrimaryKeyRelatedField(
+        queryset=RRule.objects.all(),
+        help_text=Schedule.rrule.field.help_text,
+    )
+    show = serializers.PrimaryKeyRelatedField(
+        queryset=Show.objects.all(),
+        help_text=Schedule.show.field.help_text,
+    )
     # TODO: remove this when the dashboard is updated
-    byweekday = serializers.IntegerField(source="by_weekday")
-    dstart = serializers.DateField(source="first_date")
-    tstart = serializers.TimeField(source="start_time")
-    tend = serializers.TimeField(source="end_time")
-    until = serializers.DateField(source="last_date")
+    byweekday = serializers.IntegerField(
+        source="by_weekday",
+        help_text=Schedule.by_weekday.field.help_text,
+    )
+    dstart = serializers.DateField(
+        source="first_date",
+        help_text=Schedule.first_date.field.help_text,
+    )
+    tstart = serializers.TimeField(
+        source="start_time",
+        help_text=Schedule.start_time.field.help_text,
+    )
+    tend = serializers.TimeField(
+        source="end_time",
+        help_text=Schedule.end_time.field.help_text,
+    )
+    until = serializers.DateField(
+        source="last_date",
+        help_text=Schedule.last_date.field.help_text,
+    )
+    dryrun = serializers.BooleanField(
+        write_only=True,
+        required=False,
+        help_text=(
+            "Whether to simulate the database changes. If true, no database changes will occur. "
+            "Instead a list of objects that would be created, updated and deleted if dryrun was "
+            "false will be returned."
+        ),
+    )
 
     class Meta:
         model = Schedule
@@ -423,6 +485,88 @@ class ScheduleSerializer(serializers.ModelSerializer):
         return instance
 
 
+class CollisionSerializer(serializers.Serializer):
+    id = serializers.IntegerField()
+    start = serializers.DateTimeField()
+    end = serializers.DateTimeField()
+    playlist_id = serializers.IntegerField(allow_null=True)
+    show = serializers.IntegerField()
+    show_name = serializers.CharField()
+    is_repetition = serializers.BooleanField()
+    schedule = serializers.IntegerField()
+    memo = serializers.CharField()
+    note_id = serializers.IntegerField(allow_null=True)
+
+
+class ProjectedTimeSlotSerializer(serializers.Serializer):
+    hash = serializers.CharField()
+    start = serializers.DateTimeField()
+    end = serializers.DateTimeField()
+    collisions = CollisionSerializer(many=True)
+    error = serializers.CharField(allow_null=True)
+    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
+    )
+    playlist_id = serializers.IntegerField(allow_null=True)
+    start = serializers.DateField()
+    end = serializers.DateField()
+    is_repetition = serializers.BooleanField()
+    memo = serializers.CharField()
+
+
+class ScheduleCreateUpdateRequestSerializer(serializers.Serializer):
+    schedule = ScheduleSerializer()
+    solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES))
+    notes = serializers.DictField(child=serializers.IntegerField(), required=False)
+    playlists = serializers.DictField(child=serializers.IntegerField(), required=False)
+
+
+# TODO: There shouldn’t be a separate ScheduleSerializer for use in responses.
+#       Instead the default serializer should be used. Unfortunately, the
+#       code that generates the data creates custom dicts with this particular format.
+class ScheduleInResponseSerializer(serializers.Serializer):
+    # "Schedule schema type" is the rendered name of the ScheduleSerializer.
+    """
+    For documentation on the individual fields see the
+    Schedule schema type.
+    """
+    add_business_days_only = serializers.BooleanField()
+    add_days_no = serializers.IntegerField(allow_null=True)
+    by_weekday = serializers.IntegerField()
+    default_playlist_id = serializers.IntegerField(allow_null=True)
+    end_time = serializers.TimeField()
+    first_date = serializers.DateField()
+    id = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all())
+    is_repetition = serializers.BooleanField()
+    last_date = serializers.DateField()
+    rrule = serializers.PrimaryKeyRelatedField(queryset=RRule.objects.all())
+    show = serializers.PrimaryKeyRelatedField(queryset=Note.objects.all())
+    start_time = serializers.TimeField()
+
+
+class ScheduleConflictResponseSerializer(serializers.Serializer):
+    projected = ProjectedTimeSlotSerializer(many=True)
+    solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES))
+    notes = serializers.DictField(child=serializers.IntegerField())
+    playlists = serializers.DictField(child=serializers.IntegerField())
+    schedule = ScheduleInResponseSerializer()
+
+
+class ScheduleDryRunResponseSerializer(serializers.Serializer):
+    created = DryRunTimeSlotSerializer(many=True)
+    updated = DryRunTimeSlotSerializer(many=True)
+    deleted = DryRunTimeSlotSerializer(many=True)
+
+
 class TimeSlotSerializer(serializers.ModelSerializer):
     show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all())
     schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all())
@@ -452,10 +596,10 @@ class NoteSerializer(serializers.ModelSerializer):
     show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all())
     timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all())
     host = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all())
-    thumbnails = serializers.SerializerMethodField()  # Read-only
+    thumbnails = serializers.SerializerMethodField()
 
     @staticmethod
-    def get_thumbnails(note):
+    def get_thumbnails(note) -> List[str]:
         """Returns thumbnails"""
         thumbnails = []
 
diff --git a/program/views.py b/program/views.py
index 9b8fcbcf..e0f7b4d8 100644
--- a/program/views.py
+++ b/program/views.py
@@ -21,7 +21,9 @@
 import json
 import logging
 from datetime import date, datetime, time
+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.pagination import LimitOffsetPagination
 from rest_framework.response import Response
@@ -47,11 +49,15 @@ from program.models import (
 )
 from program.serializers import (
     CategorySerializer,
+    ErrorSerializer,
     FundingCategorySerializer,
     HostSerializer,
     LanguageSerializer,
     MusicFocusSerializer,
     NoteSerializer,
+    ScheduleConflictResponseSerializer,
+    ScheduleCreateUpdateRequestSerializer,
+    ScheduleDryRunResponseSerializer,
     ScheduleSerializer,
     ShowSerializer,
     TimeSlotSerializer,
@@ -192,6 +198,28 @@ def json_playout(request):
     )
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new user."),
+    retrieve=extend_schema(
+        summary="Retrieve a single user.",
+        description="Non-admin users may only retrieve their own user record.",
+    ),
+    update=extend_schema(
+        summary="Update an existing user.",
+        description="Non-admin users may only update their own user record.",
+    ),
+    partial_update=extend_schema(
+        summary="Partially update an existing user.",
+        description="Non-admin users may only update their own user record.",
+    ),
+    list=extend_schema(
+        summary="List all users.",
+        description=(
+            "The returned list of records will only contain a single record "
+            "for non-admin users which is their own user account."
+        ),
+    ),
+)
 class APIUserViewSet(
     DisabledObjectPermissionCheckMixin,
     mixins.CreateModelMixin,
@@ -200,12 +228,6 @@ class APIUserViewSet(
     mixins.ListModelMixin,
     viewsets.GenericViewSet,
 ):
-    """
-    Returns a list of users.
-
-    Only returns the user that is currently authenticated unless the user is a superuser.
-    """
-
     permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
     serializer_class = UserSerializer
     queryset = User.objects.all()
@@ -221,9 +243,7 @@ class APIUserViewSet(
 
     def create(self, request, *args, **kwargs):
         """
-        Create a User.
-
-        Only superusers may create users.
+        Only admins may create users.
         """
 
         if not request.user.is_superuser:
@@ -238,13 +258,15 @@ class APIUserViewSet(
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new show."),
+    retrieve=extend_schema(summary="Retrieve a single show."),
+    update=extend_schema(summary="Update an existing show."),
+    partial_update=extend_schema(summary="Partially update an existing show."),
+    destroy=extend_schema(summary="Delete an existing show."),
+    list=extend_schema(summary="List all shows."),
+)
 class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of available shows.
-
-    Only superusers may add and delete shows.
-    """
-
     queryset = Show.objects.all()
     serializer_class = ShowSerializer
     permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
@@ -266,9 +288,7 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
 
     def create(self, request, *args, **kwargs):
         """
-        Create a show.
-
-        Only superusers may create a show.
+        Only admins may create a show.
         """
 
         if not request.user.is_superuser:
@@ -284,9 +304,7 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
 
     def update(self, request, *args, **kwargs):
         """
-        Update a show.
-
-        Common users may only update shows they own.
+        Non-admin users may only update shows they own.
         """
 
         pk = get_values(self.kwargs, "pk")
@@ -312,9 +330,7 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
 
     def destroy(self, request, *args, **kwargs):
         """
-        Delete a show.
-
-        Only superusers may delete shows.
+        Only admins may delete shows.
         """
 
         if not request.user.is_superuser:
@@ -325,17 +341,85 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
+@extend_schema_view(
+    create=extend_schema(
+        summary="Create a new schedule.",
+        responses={
+            status.HTTP_201_CREATED: OpenApiResponse(
+                response=ScheduleConflictResponseSerializer,
+                description=(
+                    "Signals the successful creation of the schedule and of the projected "
+                    "timeslots."
+                ),
+            ),
+            status.HTTP_202_ACCEPTED: OpenApiResponse(
+                response=ScheduleDryRunResponseSerializer,
+                description=(
+                    "Returns the list of timeslots that would be created, updated and deleted if "
+                    "the schedule request would not have been sent with the dryrun flag."
+                ),
+            ),
+            status.HTTP_400_BAD_REQUEST: OpenApiResponse(
+                response=ErrorSerializer(many=True),
+                description=dedent(
+                    """
+                    Returned in case the request contained invalid data.
+
+                    This may happen if:
+                    * the until date is before the start date (`no-start-after-end`),
+                      in which case you should correct either the start or until date.
+                    * The start and until date are the same (`no-same-day-start-and-end`).
+                      This is only allowed for single timeslots with the recurrence rule
+                      set to `once`. You should fix either the start or until date.
+                    * The number of conflicts and solutions aren’t the same
+                      (`one-solution-per-conflict`). Only one solution is allowed per conflict,
+                      so you either offered too many or not enough solutions for any reported
+                      conflicts.
+                      """
+                ),
+            ),
+            status.HTTP_403_FORBIDDEN: OpenApiResponse(
+                response=ErrorSerializer,
+                description=(
+                    "Returned in case the request contained no or invalid authenticated data "
+                    "or the authenticated user does not have authorization to perform the "
+                    "requested operation."
+                ),
+            ),
+            status.HTTP_409_CONFLICT: OpenApiResponse(
+                response=ScheduleConflictResponseSerializer,
+                description=dedent(
+                    """
+                    Returns the list of projected timeslots and any collisions that may have
+                    been found for existing timeslots.
+
+                    Errors on projected timeslots may include:
+                    * '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,
+                      which is not allowed.
+                    * 'No solution given': No solution was provided for the conflict in
+                      `solutions`. Provide a value of `solution_choices`.
+                    * 'Given solution is not accepted for this conflict.':
+                      The solution has a value which is not part of `solution_choices`.
+                      Provide a value of `solution_choices` (at least `ours` or `theirs`).
+                    """
+                ),
+            ),
+        },
+    ),
+    retrieve=extend_schema(summary="Retrieve a single schedule."),
+    update=extend_schema(summary="Update an existing schedule."),
+    partial_update=extend_schema(summary="Partially update an existing schedule."),
+    destroy=extend_schema(summary="Delete an existing schedule."),
+    list=extend_schema(summary="List all schedules."),
+)
 class APIScheduleViewSet(
     DisabledObjectPermissionCheckMixin,
     NestedObjectFinderMixin,
     viewsets.ModelViewSet,
 ):
-    """
-    Returns a list of schedules.
-
-    Only superusers may create and update schedules.
-    """
-
     ROUTE_FILTER_LOOKUPS = {
         "show_pk": "show",
     }
@@ -344,11 +428,41 @@ class APIScheduleViewSet(
     serializer_class = ScheduleSerializer
     permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
 
+    def get_serializer_class(self):
+        if self.action in ("create", "update", "partial_update"):
+            return ScheduleCreateUpdateRequestSerializer
+        return super().get_serializer_class()
+
     def create(self, request, *args, **kwargs):
         """
-        Create a schedule, generate timeslots, test for collisions and resolve them including notes
+        Create a schedule, generate timeslots, test for collisions and resolve them
+        (including notes).
+
+        Note that creating or updating a schedule is the only way to create timeslots.
+
+        Only admins may add schedules.
+
+        The projected timeslots defined by the schedule are matched against existing
+        timeslots. The API will return an object that contains
+
+        * the schedule's data,
+        * projected timeslots,
+        * detected collisions,
+        * and possible solutions.
+
+        As long as no `solutions` object has been set or unresolved collisions exist,
+        no data is written to the database. A schedule is only created if at least
+        one timeslot was generated by it.
+
+        In order to resolve any possible conflicts, the client must submit a new request with
+        a solution for each conflict. Possible solutions are listed as part of the projected
+        timeslot in the `solution_choices` array. In a best-case scenario with no detected
+        conflicts an empty solutions object will suffice. For more details on the individual
+        types of solutions see the SolutionChoicesEnum.
 
-        Only superusers may add schedules.
+        **Please note**:
+        If there's more than one collision for a projected timeslot, only `theirs` and `ours`
+        are currently supported as solutions.
         """
 
         if not request.user.is_superuser:
@@ -389,7 +503,7 @@ class APIScheduleViewSet(
         Update a schedule, generate timeslots, test for collisions and resolve
         them including notes.
 
-        Only superusers may update schedules.
+        Only admins may update schedules.
         """
 
         if not request.user.is_superuser:
@@ -442,9 +556,7 @@ class APIScheduleViewSet(
 
     def destroy(self, request, *args, **kwargs):
         """
-        Delete a schedule.
-
-        Only superusers may delete schedules.
+        Only admins may delete schedules.
         """
 
         if not request.user.is_superuser:
@@ -458,6 +570,21 @@ class APIScheduleViewSet(
 # TODO: Create is currently not implemented because timeslots are supposed to be inserted
 #       by creating or updating a schedule.
 #       There might be a use case for adding a single timeslot without any conflicts though.
+@extend_schema_view(
+    retrieve=extend_schema(summary="Retrieve a single timeslot."),
+    update=extend_schema(summary="Update an existing timeslot."),
+    partial_update=extend_schema(summary="Partially update an existing timeslot."),
+    destroy=extend_schema(summary="Delete an existing timeslot."),
+    list=extend_schema(
+        summary="List all timeslots.",
+        description=dedent(
+            """
+            By default, only timeslots ranging from now + 60 days will be displayed.
+            You may override this default overriding start and/or end parameter.
+            """
+        ),
+    ),
+)
 class APITimeSlotViewSet(
     DisabledObjectPermissionCheckMixin,
     NestedObjectFinderMixin,
@@ -467,15 +594,6 @@ class APITimeSlotViewSet(
     mixins.ListModelMixin,
     viewsets.GenericViewSet,
 ):
-    """
-    Returns a list of timeslots.
-
-    By default, only timeslots ranging from now + 60 days will be displayed.
-    You may override this default overriding start and/or end parameter.
-
-    Timeslots may only be added by creating/updating a schedule.
-    """
-
     ROUTE_FILTER_LOOKUPS = {
         "show_pk": "show",
         "schedule_pk": "schedule",
@@ -488,8 +606,6 @@ class APITimeSlotViewSet(
     filterset_class = filters.TimeSlotFilterSet
 
     def update(self, request, *args, **kwargs):
-        """Link a playlist_id to a timeslot"""
-
         show_pk = get_values(self.kwargs, "show_pk")
 
         if (
@@ -518,9 +634,7 @@ class APITimeSlotViewSet(
 
     def destroy(self, request, *args, **kwargs):
         """
-        Deletes a timeslot.
-
-        Only superusers may delete timeslots.
+        Only admins may delete timeslots.
         """
 
         if not request.user.is_superuser:
@@ -531,17 +645,22 @@ class APITimeSlotViewSet(
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new note."),
+    retrieve=extend_schema(summary="Retrieve a single note."),
+    update=extend_schema(summary="Update an existing note."),
+    partial_update=extend_schema(
+        summary="Partially update an existing note.",
+        description="Only admins can partially update existing notes.",
+    ),
+    destroy=extend_schema(summary="Delete an existing note."),
+    list=extend_schema(summary="List all notes."),
+)
 class APINoteViewSet(
     DisabledObjectPermissionCheckMixin,
     NestedObjectFinderMixin,
     viewsets.ModelViewSet,
 ):
-    """
-    Returns a list of notes.
-
-    Superusers may access and update all notes.
-    """
-
     ROUTE_FILTER_LOOKUPS = {
         "show_pk": "show",
         "timeslot_pk": "timeslot",
@@ -555,7 +674,7 @@ class APINoteViewSet(
 
     def create(self, request, *args, **kwargs):
         """
-        Only superusers can create new notes.
+        Only admins can create new notes.
         """
         show_pk, timeslot_pk = get_values(self.kwargs, "show_pk", "timeslot_pk")
 
@@ -584,7 +703,7 @@ class APINoteViewSet(
 
     def update(self, request, *args, **kwargs):
         """
-        Only superusers can update existing notes.
+        Only admins can update existing notes.
         """
         show_pk = get_values(self.kwargs, "show_pk")
 
@@ -614,7 +733,7 @@ class APINoteViewSet(
 
     def destroy(self, request, *args, **kwargs):
         """
-        Only superusers can delete existing notes.
+        Only admins can delete existing notes.
         """
         show_pk = get_values(self.kwargs, "show_pk")
 
@@ -633,65 +752,95 @@ class ActiveFilterMixin:
     filter_class = filters.ActiveFilterSet
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new category."),
+    retrieve=extend_schema(summary="Retrieve a single category."),
+    update=extend_schema(summary="Update an existing category."),
+    partial_update=extend_schema(summary="Partially update an existing category."),
+    destroy=extend_schema(summary="Delete an existing category."),
+    list=extend_schema(summary="List all categories."),
+)
 class APICategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of categories.
-    """
-
     queryset = Category.objects.all()
     serializer_class = CategorySerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new type."),
+    retrieve=extend_schema(summary="Retrieve a single type."),
+    update=extend_schema(summary="Update an existing type."),
+    partial_update=extend_schema(summary="Partially update an existing type."),
+    destroy=extend_schema(summary="Delete an existing type."),
+    list=extend_schema(summary="List all types."),
+)
 class APITypeViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of types.
-    """
-
     queryset = Type.objects.all()
     serializer_class = TypeSerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new topic."),
+    retrieve=extend_schema(summary="Retrieve a single topic."),
+    update=extend_schema(summary="Update an existing topic."),
+    partial_update=extend_schema(summary="Partially update an existing topic."),
+    destroy=extend_schema(summary="Delete an existing topic."),
+    list=extend_schema(summary="List all topics."),
+)
 class APITopicViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of topics.
-    """
-
     queryset = Topic.objects.all()
     serializer_class = TopicSerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new music focus."),
+    retrieve=extend_schema(summary="Retrieve a single music focus."),
+    update=extend_schema(summary="Update an existing music focus."),
+    partial_update=extend_schema(summary="Partially update an existing music focus."),
+    destroy=extend_schema(summary="Delete an existing music focus."),
+    list=extend_schema(summary="List all music focuses."),
+)
 class APIMusicFocusViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of music focuses.
-    """
-
     queryset = MusicFocus.objects.all()
     serializer_class = MusicFocusSerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new funding category."),
+    retrieve=extend_schema(summary="Retrieve a single funding category."),
+    update=extend_schema(summary="Update an existing funding category."),
+    partial_update=extend_schema(
+        summary="Partially update an existing funding category."
+    ),
+    destroy=extend_schema(summary="Delete an existing funding category."),
+    list=extend_schema(summary="List all funding categories."),
+)
 class APIFundingCategoryViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of funding categories.
-    """
-
     queryset = FundingCategory.objects.all()
     serializer_class = FundingCategorySerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new language."),
+    retrieve=extend_schema(summary="Retrieve a single language."),
+    update=extend_schema(summary="Update an existing language."),
+    partial_update=extend_schema(summary="Partially update an existing language."),
+    destroy=extend_schema(summary="Delete an existing language."),
+    list=extend_schema(summary="List all languages."),
+)
 class APILanguageViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of languages.
-    """
-
     queryset = Language.objects.all()
     serializer_class = LanguageSerializer
 
 
+@extend_schema_view(
+    create=extend_schema(summary="Create a new host."),
+    retrieve=extend_schema(summary="Retrieve a single host."),
+    update=extend_schema(summary="Update an existing host."),
+    partial_update=extend_schema(summary="Partially update an existing host."),
+    destroy=extend_schema(summary="Delete an existing host."),
+    list=extend_schema(summary="List all hosts."),
+)
 class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
-    """
-    Returns a list of hosts.
-    """
-
     queryset = Host.objects.all()
     serializer_class = HostSerializer
     pagination_class = LimitOffsetPagination
diff --git a/requirements.txt b/requirements.txt
index 8ef0a542..74eb2c55 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,6 +9,7 @@ django-filter==21.1
 django-oidc-provider==0.7.0
 django-versatileimagefield==2.2
 djangorestframework==3.13.1
+drf_spectacular==0.21.2
 drf-nested-routers==0.93.4
 future==0.18.2
 gunicorn==20.1.0
diff --git a/steering/schema.py b/steering/schema.py
new file mode 100644
index 00000000..8505a547
--- /dev/null
+++ b/steering/schema.py
@@ -0,0 +1,59 @@
+from typing import Iterable, Tuple
+
+
+def _generate_choices_description(choices: Iterable[Tuple[str, str]]):
+    def _gen():
+        for key, value in choices:
+            yield f"**{key}**: {value}\n\n"
+
+    return "\n".join(_gen()).strip()
+
+
+def add_enum_documentation(result, generator, request, public):
+    """
+    Choice descriptions are available through the assigned choices values
+    but are not incorporated into the schema by default.
+
+    This post-processing hook adds them to the appropriate objects.
+    """
+    # TODO: The logic behind this might be a worthwhile addition to drf-spectacular.
+    from program.models import Schedule
+    from program.serializers import SOLUTION_CHOICES
+
+    weekday_choices_desc = _generate_choices_description(
+        Schedule.by_weekday.field.choices
+    )
+    solutions_choices_desc = _generate_choices_description(SOLUTION_CHOICES.items())
+    schema = result["components"]["schemas"]
+    schema["ByWeekdayEnum"]["description"] = weekday_choices_desc
+    schema["SolutionChoicesEnum"]["description"] = solutions_choices_desc
+    for item in ["ScheduleCreateUpdateRequest", "PatchedScheduleCreateUpdateRequest"]:
+        solutions_props = schema[item]["properties"]["solutions"][
+            "additionalProperties"
+        ]
+        solutions_props["description"] = solutions_choices_desc
+    return result
+
+
+def fix_schedule_pk_type(result, generator, request, public):
+    """
+    schedule_pk’s type cannot be deduced in note routes because the Note class
+    has no schedule field drf-spectacular can map it to.
+
+    Normally we would define this by using @extend_schema on the note viewset, but
+    as the schedule_pk field does not exist for __all__ note routes this would
+    inadvertently add the field to routes that don’t even have the parameter like
+    /api/v1/notes/.
+
+    So we patch the type of schedule_pk fields in post-processing and ignore
+    the warning until we find a better solution.
+    """
+    for path, methods in result["paths"].items():
+        if not ("{schedule_pk}" in path and "/note" in path):
+            continue
+        for method_name, method_def in methods.items():
+            for parameter in method_def["parameters"]:
+                if parameter["in"] == "path" and parameter["name"] == "schedule_pk":
+                    parameter["schema"]["type"] = "integer"
+                    break
+    return result
diff --git a/steering/settings.py b/steering/settings.py
index b36e961a..bbedf9d9 100644
--- a/steering/settings.py
+++ b/steering/settings.py
@@ -109,9 +109,21 @@ REST_FRAMEWORK = {
         "program.auth.OidcOauth2Auth",
     ],
     "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
+    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
     "EXCEPTION_HANDLER": "steering.views.full_details_exception_handler",
 }
 
+SPECTACULAR_SETTINGS = {
+    "TITLE": "AURA Steering API",
+    "DESCRIPTION": "Programme/schedule management for Aura",
+    "POSTPROCESSING_HOOKS": [
+        "drf_spectacular.hooks.postprocess_schema_enums",
+        "steering.schema.add_enum_documentation",
+        "steering.schema.fix_schedule_pk_type",
+    ],
+    "VERSION": "1.0.0",
+}
+
 INSTALLED_APPS = (
     "django.contrib.auth",
     "django.contrib.contenttypes",
@@ -126,6 +138,7 @@ INSTALLED_APPS = (
     "rest_framework",
     "rest_framework_nested",
     "django_filters",
+    "drf_spectacular",
     "oidc_provider",
     "corsheaders",
 )
diff --git a/steering/urls.py b/steering/urls.py
index fdad4b38..b0e7edf8 100644
--- a/steering/urls.py
+++ b/steering/urls.py
@@ -18,6 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
 from rest_framework_nested import routers
 
 from django.contrib import admin
@@ -100,5 +101,11 @@ urlpatterns = [
     path("api/v1/playout", json_playout),
     path("api/v1/program/week", json_playout),
     path("api/v1/program/<int:year>/<int:month>/<int:day>)/", json_day_schedule),
+    path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"),
+    path(
+        "api/v1/schema/swagger-ui/",
+        SpectacularSwaggerView.as_view(url_name="schema"),
+        name="swagger-ui",
+    ),
     path("admin/", admin.site.urls),
 ]
-- 
GitLab