From 53641237452b5cdcfed0ce8a9963356488d9f414 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:33:33 -0600 Subject: [PATCH] - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added - Modern OpenAPI 3.0 specification compliance - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection --- CHANGELOG.md | 8 + apps/accounts/api_views.py | 63 +++-- apps/api/urls.py | 26 +- apps/channels/api_views.py | 487 +++++++++++++------------------------ apps/epg/api_views.py | 39 ++- apps/hdhr/api_views.py | 24 +- apps/hdhr/views.py | 24 +- apps/m3u/api_views.py | 19 +- core/api_views.py | 27 +- dispatcharr/settings.py | 24 +- dispatcharr/urls.py | 28 +-- 11 files changed, 290 insertions(+), 479 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97952632..26c3abd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: + - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added + - Modern OpenAPI 3.0 specification compliance + - Better auto-generation of request/response schemas + - Improved documentation accuracy with serializer introspection + ## [0.18.1] - 2026-01-27 ### Fixed diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 41e2f077..607ce199 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response -from rest_framework import viewsets, status -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from rest_framework import viewsets, status, serializers +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes import json from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet): return [IsAuthenticated()] return [] - @swagger_auto_schema( - operation_description="Authenticate and log in a user", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["username", "password"], - properties={ - "username": openapi.Schema(type=openapi.TYPE_STRING), - "password": openapi.Schema( - type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD - ), + @extend_schema( + description="Authenticate and log in a user", + request=inline_serializer( + name="LoginRequest", + fields={ + "username": serializers.CharField(), + "password": serializers.CharField(), }, ), - responses={200: "Login successful", 400: "Invalid credentials"}, ) def login(self, request): """Logs in a user and returns user details""" @@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet): ) return Response({"error": "Invalid credentials"}, status=400) - @swagger_auto_schema( - operation_description="Log out the current user", - responses={200: "Logout successful"}, + @extend_schema( + description="Log out the current user", ) def logout(self, request): """Logs out the authenticated user""" @@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet): return [IsAdmin()] - @swagger_auto_schema( - operation_description="Retrieve a list of users", + @extend_schema( + description="Retrieve a list of users", responses={200: UserSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific user by ID") + @extend_schema(description="Retrieve a specific user by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new user") + @extend_schema(description="Create a new user") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a user") + @extend_schema(description="Update a user") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a user") + @extend_schema(description="Delete a user") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) - @swagger_auto_schema( - method="get", - operation_description="Get active user information", + @extend_schema( + description="Get active user information", ) @action(detail=False, methods=["get"], url_path="me") def me(self, request): @@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [Authenticated] - @swagger_auto_schema( - operation_description="Retrieve a list of groups", + @extend_schema( + description="Retrieve a list of groups", responses={200: GroupSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific group by ID") + @extend_schema(description="Retrieve a specific group by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new group") + @extend_schema(description="Create a new group") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a group") + @extend_schema(description="Update a group") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a group") + @extend_schema(description="Delete a group") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) # 🔹 4) Permissions List API -@swagger_auto_schema( - method="get", - operation_description="Retrieve a list of all permissions", +@extend_schema( + description="Retrieve a list of all permissions", responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) diff --git a/apps/api/urls.py b/apps/api/urls.py index 4c92c70a..5a688778 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,23 +1,8 @@ from django.urls import path, include, re_path -from drf_yasg.views import get_schema_view -from drf_yasg import openapi -from rest_framework.permissions import AllowAny +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView app_name = 'api' -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version='v1', - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="support@dispatcharr.local"), - license=openapi.License(name="Unlicense"), - ), - public=True, - permission_classes=(AllowAny,), -) - urlpatterns = [ path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')), path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')), @@ -35,8 +20,9 @@ urlpatterns = [ - # Swagger Documentation api_urls - re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'), + # OpenAPI Schema and Documentation (drf-spectacular) + path('schema/', SpectacularAPIView.as_view(), name='schema'), + re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), + path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), ] diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 11e7525f..27e07dac 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -4,8 +4,9 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction from django.db.models import Count, F @@ -269,17 +270,15 @@ class StreamViewSet(viewsets.ModelViewSet): ] }) - @swagger_auto_schema( - method="post", - operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["ids"], - properties={ - "ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to retrieve" + @extend_schema( + methods=["POST"], + description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", + request=inline_serializer( + name="StreamByIdsRequest", + fields={ + "ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to retrieve" ), }, ), @@ -342,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return super().partial_update(request, *args, **kwargs) - @swagger_auto_schema( - method="post", - operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", - responses={200: "Cleanup completed"}, + @extend_schema( + methods=["POST"], + description="Delete all channel groups that have no associations (no channels or M3U accounts)", ) @action(detail=False, methods=["post"], url_path="cleanup") def cleanup_unused_groups(self, request): @@ -831,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet): # Return the response with the list of IDs return Response(list(channel_ids)) - @swagger_auto_schema( - method="post", - operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "starting_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="Starting channel number to assign (can be decimal)", + @extend_schema( + methods=["POST"], + description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", + request=inline_serializer( + name="AssignChannelsRequest", + fields={ + "starting_number": serializers.FloatField( + help_text="Starting channel number to assign (can be decimal)", + required=False, ), - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to assign", + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to assign", ), }, ), - responses={200: "Channels have been auto-assigned!"}, ) @action(detail=False, methods=["post"], url_path="assign") def assign(self, request): @@ -869,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK ) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Create a new channel from an existing stream. " "If 'channel_number' is provided, it will be used (if available); " "otherwise, the next available channel number is assigned. " "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " "Accepts either a single ID or an array of IDs." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_id"], - properties={ - "stream_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="ID of the stream to link" + request=inline_serializer( + name="FromStreamRequest", + fields={ + "stream_id": serializers.IntegerField(help_text="ID of the stream to link"), + "channel_number": serializers.FloatField( + help_text="(Optional) Desired channel number. Must not be in use.", + required=False, ), - "channel_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="(Optional) Desired channel number. Must not be in use.", - ), - "name": openapi.Schema( - type=openapi.TYPE_STRING, description="Desired channel name" - ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "name": serializers.CharField(help_text="Desired channel name", required=False), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), }, ), @@ -1055,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Asynchronously bulk create channels from stream IDs. " "Returns a task ID to track progress via WebSocket. " "This is the recommended approach for large bulk operations." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to create channels from" + request=inline_serializer( + name="FromStreamBulkRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to create channels from" ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), - "starting_channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number" + "starting_channel_number": serializers.IntegerField( + help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number", + required=False, ), }, ), - responses={202: "Task started successfully"}, ) @action(detail=False, methods=["post"], url_path="from-stream/bulk") def from_stream_bulk(self, request): @@ -1122,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 6) EPG Fuzzy Matching # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.' + @extend_schema( + methods=["POST"], + description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", + request=inline_serializer( + name="MatchEpgRequest", + fields={ + 'channel_ids': serializers.ListField( + child=serializers.IntegerField(), + help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.', + required=False, ) } ), - responses={202: "EPG matching task initiated"}, ) @action(detail=False, methods=["post"], url_path="match-epg") def match_epg(self, request): @@ -1156,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": message}, status=status.HTTP_202_ACCEPTED ) - @swagger_auto_schema( - method="post", - operation_description="Try to auto-match this specific channel with EPG data.", - responses={200: "EPG matching completed", 202: "EPG matching task initiated"}, + @extend_schema( + methods=["POST"], + description="Try to auto-match this specific channel with EPG data.", ) @action(detail=True, methods=["post"], url_path="match-epg") def match_channel_epg(self, request, pk=None): @@ -1186,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Set EPG data for a channel and refresh program data", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["epg_data_id"], - properties={ - "epg_data_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="EPG data ID to link" - ) + @extend_schema( + methods=["POST"], + description="Set EPG data for a channel and refresh program data", + request=inline_serializer( + name="SetEpgRequest", + fields={ + "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link") }, ), responses={200: "EPG data linked and refresh triggered"}, @@ -1251,28 +1233,22 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + description=( "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " "The channel will receive the next whole number after the target channel, and all subsequent " "channels will be renumbered accordingly." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "insert_after_id": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="ID of the channel to insert after. Use null to move to the beginning.", - nullable=True, + request=inline_serializer( + name="ReorderChannelRequest", + fields={ + "insert_after_id": serializers.IntegerField( + help_text="ID of the channel to insert after. Use null to move to the beginning.", + required=False, + allow_null=True, ), }, ), - responses={ - 200: "Channel reordered successfully", - 404: "Channel not found", - 400: "Invalid request", - }, ) @action(detail=True, methods=["post"], url_path="reorder") def reorder(self, request, pk=None): @@ -1340,25 +1316,23 @@ class ChannelViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) - @swagger_auto_schema( - method="post", - operation_description="Associate multiple channels with EPG data without triggering a full refresh", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "associations": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "channel_id": openapi.Schema(type=openapi.TYPE_INTEGER), - "epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER), + @extend_schema( + methods=["POST"], + description="Associate multiple channels with EPG data without triggering a full refresh", + request=inline_serializer( + name="BatchSetEpgRequest", + fields={ + "associations": serializers.ListField( + child=inline_serializer( + name="EpgAssociation", + fields={ + "channel_id": serializers.IntegerField(), + "epg_data_id": serializers.IntegerField(), }, ), ) }, ), - responses={200: "EPG data linked for multiple channels"}, ) @action(detail=False, methods=["post"], url_path="batch-set-epg") def batch_set_epg(self, request): @@ -1456,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete streams by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Stream IDs to delete", + @extend_schema( + description="Bulk delete streams by ID", + request=inline_serializer( + name="BulkDeleteStreamsRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Stream IDs to delete", ) }, ), - responses={204: "Streams deleted"}, ) def delete(self, request, *args, **kwargs): stream_ids = request.data.get("stream_ids", []) @@ -1492,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete channels by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to delete", + @extend_schema( + description="Bulk delete channels by ID", + request=inline_serializer( + name="BulkDeleteChannelsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to delete", ) }, ), - responses={204: "Channels deleted"}, ) def delete(self, request): channel_ids = request.data.get("channel_ids", []) @@ -1527,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete logos by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["logo_ids"], - properties={ - "logo_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Logo IDs to delete", + @extend_schema( + description="Bulk delete logos by ID", + request=inline_serializer( + name="BulkDeleteLogosRequest", + fields={ + "logo_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Logo IDs to delete", ) }, ), - responses={204: "Logos deleted"}, ) def delete(self, request): logo_ids = request.data.get("logo_ids", []) @@ -1597,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Delete all channel logos that are not used by any channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "delete_files": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether to delete local logo files from disk", - default=False + @extend_schema( + description="Delete all channel logos that are not used by any channels", + request=inline_serializer( + name="CleanupUnusedLogosRequest", + fields={ + "delete_files": serializers.BooleanField( + help_text="Whether to delete local logo files from disk", + default=False, + required=False, ) }, ), - responses={200: "Cleanup completed"}, ) def post(self, request): """Delete all channel logos with no channel associations""" @@ -2019,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", - request_body=BulkChannelProfileMembershipSerializer, - responses={ - 200: openapi.Response( - description="Channels updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "status": openapi.Schema(type=openapi.TYPE_STRING, example="success"), - "updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"), - "created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"), - "invalid_channels": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="List of channel IDs that don't exist" - ), - }, - ), - ), - 400: "Invalid request data", - 404: "Profile not found", - }, + @extend_schema( + description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request=BulkChannelProfileMembershipSerializer, ) def patch(self, request, profile_id): """Bulk enable or disable channels for a specific profile""" @@ -2406,71 +2350,24 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="List all series rules", - operation_description="Retrieve all configured DVR series recording rules.", - responses={ - 200: openapi.Response( - description="List of series rules", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), - }, - ), - description='List of series recording rules' - ), - }, - ), - ), - }, + @extend_schema( + summary="List all series rules", + description="Retrieve all configured DVR series recording rules.", ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) - @swagger_auto_schema( - operation_summary="Create or update a series rule", - operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + @extend_schema( + summary="Create or update a series rule", + description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request=inline_serializer( + name="SeriesRuleRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), + 'title': serializers.CharField(help_text='Series title', required=False), }, ), - responses={ - 200: openapi.Response( - description="Series rule created/updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"), - }, ) def post(self, request): data = request.data or {} @@ -2504,35 +2401,12 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Delete a series rule", - operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", - manual_parameters=[ - openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'), + @extend_schema( + summary="Delete a series rule", + description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + parameters=[ + OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), ], - responses={ - 200: openapi.Response( - description="Series rule deleted successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - }, ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) @@ -2548,26 +2422,15 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Evaluate series rules", - operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'), + @extend_schema( + summary="Evaluate series rules", + description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request=inline_serializer( + name="EvaluateSeriesRulesRequest", + fields={ + "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."), }, ), - responses={ - 200: openapi.Response( - description="Evaluation completed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - }, - ), - ), - }, ) def post(self, request): tvg_id = request.data.get("tvg_id") @@ -2590,31 +2453,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Bulk remove scheduled recordings for a series", - operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'), - 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'), + @extend_schema( + summary="Bulk remove scheduled recordings for a series", + description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request=inline_serializer( + name="BulkRemoveSeriesRecordingsRequest", + fields={ + "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"), + "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"), + "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"), }, ), - responses={ - 200: openapi.Response( - description="Recordings removed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id)"), - }, ) def post(self, request): from django.utils import timezone diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 319368d0..00f7403f 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,10 +1,10 @@ import logging, os -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes from django.utils import timezone from datetime import timedelta from .models import EPGSource, ProgramData, EPGData # Added ProgramData @@ -122,8 +122,8 @@ class EPGGridAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", + @extend_schema( + description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", responses={200: ProgramDataSerializer(many=True)}, ) def get(self, request, format=None): @@ -371,9 +371,8 @@ class EPGImportAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers an EPG data import", - responses={202: "EPG data import initiated"}, + @extend_schema( + description="Triggers an EPG data import", ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") @@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Get currently playing programs for specified channels or all channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="Array of channel IDs. If null or omitted, returns all channels with current programs.", - nullable=True, - ) + @extend_schema( + description="Get currently playing programs for specified channels or all channels", + request=inline_serializer( + name="CurrentProgramsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + ), }, ), - responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))}, + responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): # Get channel IDs from request body diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 8f1609d4..2227170e 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -4,8 +4,8 @@ from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse import logging -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.db import models from apps.channels.models import Channel, ChannelProfile, Stream @@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): uri_parts = ["hdhr"] @@ -100,9 +99,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request, profile=None): if profile is not None: @@ -141,9 +139,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): data = { @@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 40823259..f9dd42d2 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -3,8 +3,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from apps.channels.models import Channel from .models import HDHRDevice @@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") @@ -81,9 +80,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request): channels = Channel.objects.all().order_by("channel_number") @@ -102,9 +100,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request): data = { @@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 1f16f20f..73331f7a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -6,8 +6,8 @@ from apps.accounts.permissions import ( permission_classes_by_action, permission_classes_by_method, ) -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of all active M3U accounts", - responses={202: "M3U refresh initiated"}, + @extend_schema( + description="Triggers a refresh of all active M3U accounts", ) def post(self, request, format=None): refresh_m3u_accounts.delay() @@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of a single M3U account", - responses={202: "M3U account refresh initiated"}, + @extend_schema( + description="Triggers a refresh of a single M3U account", ) def post(self, request, account_id, format=None): refresh_single_m3u_account.delay(account_id) @@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of account information for a specific M3U profile", - responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"}, + @extend_schema( + description="Triggers a refresh of account information for a specific M3U profile", ) def post(self, request, profile_id, format=None): try: diff --git a/core/api_views.py b/core/api_views.py index 30829174..44001f95 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -9,8 +9,8 @@ from rest_framework.views import APIView from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import api_view, permission_classes, action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from .models import ( UserAgent, StreamProfile, @@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): -@swagger_auto_schema( - method="get", - operation_description="Endpoint for environment details", - responses={200: "Environment variables"}, +@extend_schema( + description="Endpoint for environment details", ) @api_view(["GET"]) @permission_classes([Authenticated]) @@ -314,10 +312,8 @@ def environment(request): ) -@swagger_auto_schema( - method="get", - operation_description="Get application version information", - responses={200: "Version information"}, +@extend_schema( + description="Get application version information", ) @api_view(["GET"]) @@ -333,10 +329,8 @@ def version(request): ) -@swagger_auto_schema( - method="post", - operation_description="Trigger rehashing of all streams", - responses={200: "Rehash task started"}, +@extend_schema( + description="Trigger rehashing of all streams", ) @api_view(["POST"]) @permission_classes([Authenticated]) @@ -383,9 +377,8 @@ class TimezoneListView(APIView): def get_permissions(self): return [Authenticated()] - @swagger_auto_schema( - operation_description="Get list of all supported timezones", - responses={200: openapi.Response('List of timezones with grouping by region')} + @extend_schema( + description="Get list of all supported timezones", ) def get(self, request): import pytz diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1a9a1a44..b65ee2d0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -32,7 +32,7 @@ INSTALLED_APPS = [ "apps.vod.apps.VODConfig", "core", "daphne", - "drf_yasg", + "drf_spectacular", "channels", "django.contrib.admin", "django.contrib.auth", @@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [ ] REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", "rest_framework.renderers.BrowsableAPIRenderer", @@ -162,10 +162,22 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } -SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} - } +SPECTACULAR_SETTINGS = { + "TITLE": "Dispatcharr API", + "DESCRIPTION": "API documentation for Dispatcharr", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SECURITY": [{"BearerAuth": []}], + "COMPONENTS": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.", + } + } + }, } LANGUAGE_CODE = "en-us" diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 890d0c2d..1ab4ebfb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -3,32 +3,20 @@ from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.views.generic import TemplateView, RedirectView -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.ts_proxy.views import stream_xc from apps.output.views import xc_movie_stream, xc_series_stream -# Define schema_view for Swagger -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version="v1", - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@dispatcharr.local"), - license=openapi.License(name="Creative Commons by-nc-sa"), - ), - public=True, - permission_classes=(permissions.AllowAny,), -) - urlpatterns = [ # API Routes path("api/", include(("apps.api.urls", "api"), namespace="api")), path("api", RedirectView.as_view(url="/api/", permanent=True)), + # Swagger redirects (Swagger UI is served at /api/swagger/) + path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), + path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), # Admin path("admin", RedirectView.as_view(url="/admin/", permanent=True)), path("admin/", admin.site.urls), @@ -68,12 +56,6 @@ urlpatterns = [ name="xc_series_stream", ), - re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - # ReDoc UI - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - # Optionally, serve the raw Swagger JSON - path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), - # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point