From 1274b2d84953a04f2177645629a56ec0970b3190 Mon Sep 17 00:00:00 2001
From: Kyle Robbertze <paddatrapper@users.noreply.github.com>
Date: Sat, 16 Oct 2021 18:34:03 +0000
Subject: [PATCH] Add openapi spec for API v2 (#1388)

---
 api/README.md                        |  4 +--
 api/libretimeapi/serializers.py      | 11 +++---
 api/libretimeapi/settings.py         |  6 ++--
 api/libretimeapi/tests/test_views.py | 53 ++++++++++++++++++++++++++++
 api/libretimeapi/urls.py             |  7 ++++
 api/libretimeapi/views.py            | 31 ++++++++++++++--
 api/setup.py                         |  3 +-
 7 files changed, 104 insertions(+), 11 deletions(-)

diff --git a/api/README.md b/api/README.md
index e2cf1f378..17b8723a6 100644
--- a/api/README.md
+++ b/api/README.md
@@ -15,8 +15,8 @@ restarting:
 
 Connections to the API are proxied through the Apache web server by default.
 Endpoint exploration and documentation is available from
-`http://example.com/api/v2/`, where `example.com` is the URL for the LibreTime
-instance.
+`http://example.com/api/v2/schema/swagger-ui/`, where `example.com` is the URL
+for the LibreTime instance.
 
 ### Development
 
diff --git a/api/libretimeapi/serializers.py b/api/libretimeapi/serializers.py
index 200e3e2ce..ab448c981 100644
--- a/api/libretimeapi/serializers.py
+++ b/api/libretimeapi/serializers.py
@@ -138,18 +138,21 @@ class ScheduleSerializer(serializers.HyperlinkedModelSerializer):
             "id",
             "starts",
             "ends",
+            "file",
+            "file_id",
+            "stream",
+            "stream_id",
             "clip_length",
             "fade_in",
             "fade_out",
             "cue_in",
             "cue_out",
             "media_item_played",
-            "file",
-            "file_id",
-            "stream",
-            "stream_id",
             "instance",
             "instance_id",
+            "playout_status",
+            "broadcasted",
+            "position",
         ]
 
 
diff --git a/api/libretimeapi/settings.py b/api/libretimeapi/settings.py
index 6c916a00e..ebadf9dc4 100644
--- a/api/libretimeapi/settings.py
+++ b/api/libretimeapi/settings.py
@@ -38,7 +38,8 @@ INSTALLED_APPS = [
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "rest_framework",
-    "url_filter",
+    "django_filters",
+    "drf_spectacular",
 ]
 
 MIDDLEWARE = [
@@ -114,8 +115,9 @@ REST_FRAMEWORK = {
         "libretimeapi.permissions.IsSystemTokenOrUser",
     ],
     "DEFAULT_FILTER_BACKENDS": [
-        "url_filter.integrations.drf.DjangoFilterBackend",
+        "django_filters.rest_framework.DjangoFilterBackend",
     ],
+    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
     "URL_FIELD_NAME": "item_url",
 }
 
diff --git a/api/libretimeapi/tests/test_views.py b/api/libretimeapi/tests/test_views.py
index 5ec6acf81..6ae6b322f 100644
--- a/api/libretimeapi/tests/test_views.py
+++ b/api/libretimeapi/tests/test_views.py
@@ -164,3 +164,56 @@ class TestScheduleViewSet(APITestCase):
         self.assertEqual(len(result), 1)
         self.assertEqual(dateparse.parse_datetime(result[0]["ends"]), scheduleItem.ends)
         self.assertEqual(dateparse.parse_duration(result[0]["cue_out"]), f.cueout)
+
+    def test_schedule_item_range(self):
+        music_dir = baker.make(
+            "libretimeapi.MusicDir",
+            directory=os.path.join(os.path.dirname(__file__), "resources"),
+        )
+        f = baker.make(
+            "libretimeapi.File",
+            directory=music_dir,
+            mime="audio/mp3",
+            filepath="song.mp3",
+            length=timedelta(seconds=40.86),
+            cuein=timedelta(seconds=0),
+            cueout=timedelta(seconds=40.8131),
+        )
+        filter_point = datetime.now(tz=timezone.utc)
+
+        show = baker.make(
+            "libretimeapi.ShowInstance",
+            starts=filter_point - timedelta(minutes=5),
+            ends=filter_point + timedelta(minutes=5),
+        )
+        schedule_item = baker.make(
+            "libretimeapi.Schedule",
+            starts=filter_point,
+            ends=filter_point + f.length,
+            cue_out=f.cueout,
+            instance=show,
+            file=f,
+        )
+        previous_item = baker.make(
+            "libretimeapi.Schedule",
+            starts=filter_point - timedelta(minutes=5),
+            ends=filter_point - timedelta(minutes=5) + f.length,
+            cue_out=f.cueout,
+            instance=show,
+            file=f,
+        )
+        self.client.credentials(HTTP_AUTHORIZATION="Api-Key {}".format(self.token))
+        range_start = (filter_point - timedelta(minutes=1)).isoformat(
+            timespec="seconds"
+        )
+        range_end = (filter_point + timedelta(minutes=1)).isoformat(timespec="seconds")
+        response = self.client.get(
+            self.path, {"starts__range": "{},{}".format(range_start, range_end)}
+        )
+        self.assertEqual(response.status_code, 200)
+        result = response.json()
+        # The previous_item should be filtered out and not returned
+        self.assertEqual(len(result), 1)
+        self.assertEqual(
+            dateparse.parse_datetime(result[0]["starts"]), schedule_item.starts
+        )
diff --git a/api/libretimeapi/urls.py b/api/libretimeapi/urls.py
index df7ca36ed..d7290dc7a 100644
--- a/api/libretimeapi/urls.py
+++ b/api/libretimeapi/urls.py
@@ -1,4 +1,5 @@
 from django.urls import include, path
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
 from rest_framework import routers
 
 from .views import *
@@ -46,6 +47,12 @@ router.register("track-types", TrackTypeViewSet)
 
 urlpatterns = [
     path("api/v2/", include(router.urls)),
+    path("api/v2/schema/", SpectacularAPIView.as_view(), name="schema"),
+    path(
+        "api/v2/schema/swagger-ui/",
+        SpectacularSwaggerView.as_view(url_name="schema"),
+        name="swagger-ui",
+    ),
     path("api/v2/version/", version),
     path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
 ]
diff --git a/api/libretimeapi/views.py b/api/libretimeapi/views.py
index e42cf59f9..aeb9d5e1a 100644
--- a/api/libretimeapi/views.py
+++ b/api/libretimeapi/views.py
@@ -4,7 +4,8 @@ from django.conf import settings
 from django.db.models import F
 from django.http import FileResponse
 from django.shortcuts import get_object_or_404
-from rest_framework import status, viewsets
+from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
+from rest_framework import fields, status, viewsets
 from rest_framework.decorators import action, api_view, permission_classes
 from rest_framework.permissions import AllowAny
 from rest_framework.response import Response
@@ -12,6 +13,15 @@ from rest_framework.response import Response
 from .permissions import IsAdminOrOwnUser
 from .serializers import *
 
+FILTER_NUMERICAL_LOOKUPS = [
+    "exact",
+    "gt",
+    "lt",
+    "gte",
+    "lte",
+    "range",
+]
+
 
 class UserViewSet(viewsets.ModelViewSet):
     queryset = get_user_model().objects.all()
@@ -139,10 +149,27 @@ class PreferenceViewSet(viewsets.ModelViewSet):
     model_permission_name = "preference"
 
 
+@extend_schema_view(
+    list=extend_schema(
+        parameters=[
+            OpenApiParameter(
+                name="is_valid",
+                description="Filter on valid instances",
+                required=False,
+                type=bool,
+            ),
+        ]
+    )
+)
 class ScheduleViewSet(viewsets.ModelViewSet):
     queryset = Schedule.objects.all()
     serializer_class = ScheduleSerializer
-    filter_fields = ("starts", "ends", "playout_status", "broadcasted")
+    filter_fields = {
+        "starts": FILTER_NUMERICAL_LOOKUPS,
+        "ends": FILTER_NUMERICAL_LOOKUPS,
+        "playout_status": FILTER_NUMERICAL_LOOKUPS,
+        "broadcasted": FILTER_NUMERICAL_LOOKUPS,
+    }
     model_permission_name = "schedule"
 
     def get_queryset(self):
diff --git a/api/setup.py b/api/setup.py
index 16a8c7607..12397beff 100644
--- a/api/setup.py
+++ b/api/setup.py
@@ -25,7 +25,8 @@ setup(
         "coreapi",
         "django~=3.0",
         "djangorestframework",
-        "django-url-filter",
+        "django-filter",
+        "drf-spectacular",
         "markdown",
         "model_bakery",
     ],