From 2f71e281c377fbef5b11a463075918020802eab8 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