diff --git a/README.md b/README.md index 0bb89d0d6..54512bfe4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,22 @@ python manage.py runserver & celery -A config worker --beat --scheduler django - Go to: http://localhost:8000 +### OpenAPI schema generation + +The API spec in [openapi.yaml](openapi.yaml) can be generated automatically from Django/DRF using [`drf-spectacular`](https://github.com/tfranzel/drf-spectacular). + +From the project root, run: + +```bash +cd src +python manage.py generate_openapi --validate +``` + +When schema serving is enabled (default: `DEBUG=True`, or by setting `SPECTACULAR_ENABLE_SERVE=True`), docs are available at: + +- `http://localhost:8000/api/v1/schema/` +- `http://localhost:8000/api/v1/docs/` + ## 💪 Support the Project There are many ways you can support Yamtrack's development: diff --git a/src/api/apps.py b/src/api/apps.py index 9c8724439..4d781fcd8 100644 --- a/src/api/apps.py +++ b/src/api/apps.py @@ -9,4 +9,4 @@ class ApiConfig(AppConfig): def ready(self): """Import signals when the app is ready.""" - import api.schema # noqa: F401, PLC0415 + import api.schemas # noqa: F401, PLC0415 diff --git a/src/api/authentication.py b/src/api/authentication.py index 8fae897e4..ddf6ba44e 100644 --- a/src/api/authentication.py +++ b/src/api/authentication.py @@ -3,15 +3,19 @@ from users.models import User +AUTHORIZATION_HEADER = "Authorization" +API_KEY_HEADER = "X-API-Key" + class BearerAuthentication(BaseAuthentication): """Bearer Authentication.""" keyword = "Bearer" + header_name = AUTHORIZATION_HEADER def authenticate(self, request): """Authenticate the user with Bearer token.""" - auth = request.headers.get("Authorization") + auth = request.headers.get(self.header_name) if not auth: return None parts = auth.split() @@ -29,9 +33,11 @@ def authenticate(self, request): class APIKeyAuthentication(BaseAuthentication): """API Key Authentication.""" + header_name = API_KEY_HEADER + def authenticate(self, request): """Authenticate the user with API Key.""" - auth = request.headers.get("X-API-Key") + auth = request.headers.get(self.header_name) if not auth: return None try: diff --git a/src/api/helpers.py b/src/api/helpers.py index ff772f740..3ade34e6a 100644 --- a/src/api/helpers.py +++ b/src/api/helpers.py @@ -162,6 +162,8 @@ MediaTypes.BOARDGAME.value: ["bgg", "manual"], } +SOURCES_VALID_LIST = list(VALID_SOURCES.keys()) + def build_item_id(item): """Build the item_id string for the given item.""" diff --git a/src/api/management/__init__.py b/src/api/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/management/commands/__init__.py b/src/api/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/api/management/commands/generate_openapi.py b/src/api/management/commands/generate_openapi.py new file mode 100644 index 000000000..e6a32afee --- /dev/null +++ b/src/api/management/commands/generate_openapi.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from django.conf import settings +from django.core.management import BaseCommand, call_command + + +class Command(BaseCommand): + """Generate the OpenAPI schema file from DRF endpoints.""" + + help = "Generate openapi.yaml using drf-spectacular." + + def add_arguments(self, parser): + """Add optional CLI arguments.""" + parser.add_argument( + "--file", + default=None, + help="Output path for the schema file. Defaults to repository openapi.yaml.", # noqa: E501 + ) + parser.add_argument( + "--validate", + action="store_true", + help="Validate generated schema during export.", + ) + + def handle(self, *args, **options): # noqa: ARG002 + """Generate OpenAPI file using spectacular command.""" + if options["file"]: + output_path = Path(options["file"]).expanduser().resolve() + else: + output_path = settings.BASE_DIR.parent / "openapi.yaml" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + call_command( + "spectacular", + file=str(output_path), + validate=options["validate"], + color=True, + ) + + self.stdout.write( + self.style.SUCCESS(f"OpenAPI schema generated: {output_path}"), + ) diff --git a/src/api/schema.py b/src/api/schema.py deleted file mode 100644 index 32855faac..000000000 --- a/src/api/schema.py +++ /dev/null @@ -1,30 +0,0 @@ -from drf_spectacular.extensions import OpenApiAuthenticationExtension - - -class BearerAuthenticationScheme(OpenApiAuthenticationExtension): - """Describe the custom bearer token auth scheme for OpenAPI generation.""" - - target_class = "api.authentication.BearerAuthentication" - name = "bearerAuth" - - def get_security_definition(self, _auto_schema): - """Return the OpenAPI security scheme for bearer authentication.""" - return { - "type": "http", - "scheme": "bearer", - } - - -class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension): - """Describe the custom API key auth scheme for OpenAPI generation.""" - - target_class = "api.authentication.APIKeyAuthentication" - name = "ApiKeyAuth" - - def get_security_definition(self, _auto_schema): - """Return the OpenAPI security scheme for header-based API keys.""" - return { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - } diff --git a/src/api/schemas.py b/src/api/schemas.py new file mode 100644 index 000000000..178b8c861 --- /dev/null +++ b/src/api/schemas.py @@ -0,0 +1,86 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + OpenApiTypes, +) + +from .serializers import ApiErrorResponseSerializer + + +class BearerAuthenticationScheme(OpenApiAuthenticationExtension): + """Describe the custom bearer token auth scheme for OpenAPI generation.""" + + target_class = "api.authentication.BearerAuthentication" + name = "bearerAuth" + + def get_security_definition(self, _auto_schema): + """Return the OpenAPI security scheme for bearer authentication.""" + return { + "type": "http", + "scheme": "bearer", + } + + +class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension): + """Describe the custom API key auth scheme for OpenAPI generation.""" + + target_class = "api.authentication.APIKeyAuthentication" + name = "ApiKeyAuth" + + def get_security_definition(self, _auto_schema): + """Return the OpenAPI security scheme for header-based API keys.""" + return { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + } + + +forbidden_response = OpenApiResponse( + ApiErrorResponseSerializer, + description="Forbidden", + examples=[ + OpenApiExample( + "No authentication example", + description="No authentication example", + summary="No authentication example", + value={"detail": "Authentication credentials were not provided."}, + ), + OpenApiExample( + "Invalid token example", + description="Invalid token example", + summary="Invalid token example", + value={"detail": "Invalid token"}, + ), + ], +) + +PaginationLimitParam = OpenApiParameter( + name="limit", + type={"type": "integer", "minimum": 1, "default": 20}, + location=OpenApiParameter.QUERY, + description="Maximum number of results to return (default: 20).", +) + +PaginationOffsetParam = OpenApiParameter( + name="offset", + type={"type": "integer", "minimum": 0, "default": 0}, + location=OpenApiParameter.QUERY, + description="Number of results to skip before returning items (default: 0).", +) + +ListSortParam = OpenApiParameter( + name="sort", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Sorting expression in the format `:asc|desc`.", +) + +ListSearchParam = OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Free-text filter for list names or item titles.", +) diff --git a/src/api/serializers.py b/src/api/serializers.py index 41a57d631..2c3752d95 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.utils.timezone import now +from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field from rest_framework import serializers from app.models import ( @@ -24,21 +25,17 @@ get_changes_from_diff, get_changes_from_new_record, ) -from .helpers import ( - build_item_id, - build_parent_id, - get_media_status, -) +from .helpers import build_item_id, build_parent_id, get_media_status -class ItemIdField(serializers.Field): +class ItemIdField(serializers.CharField): """Custom field to generate item_id string.""" def to_representation(self, item): # noqa: D102 return build_item_id(item) -class ParentIdField(serializers.Field): +class ParentIdField(serializers.CharField): """Custom field to generate parent_id string for seasons and episodes.""" def to_representation(self, item): # noqa: D102 @@ -57,6 +54,7 @@ class ItemSerializer(serializers.ModelSerializer): media_id = serializers.SerializerMethodField() + @extend_schema_field(str) def get_media_id(self, obj): """Return media_id preserving alphanumeric provider IDs.""" media_id = getattr(obj, "media_id", None) @@ -69,9 +67,24 @@ class Meta: # noqa: D106 exclude = ("id",) +class ChangesHistoryChangeSerializer(serializers.Serializer): + """Serializer for a single change in a history entry.""" + + field = serializers.ChoiceField( + choices=["end_date", "notes", "progress", "score", "start_date", "status"] + ) + old_value = serializers.CharField(allow_null=True, required=False) + new_value = serializers.CharField(allow_null=True, required=False) + + class ChangesHistoryEntrySerializer(serializers.Serializer): """Serializer that builds a change-based history entry.""" + id = serializers.IntegerField(allow_null=True, required=False) + item_id = serializers.CharField(allow_null=True, required=False) + timestamp = serializers.DateTimeField(allow_null=True, required=False) + changes = ChangesHistoryChangeSerializer(many=True) + def to_representation(self, instance): """Build history entry with changes.""" media_type = None @@ -112,6 +125,45 @@ def __init__(self, status_value): } +class ApiMessageResponseSerializer(serializers.Serializer): + """Standard API message response serializer.""" + + detail = serializers.CharField() + + +# TODO: errors field can be str or list, depending on the error +class ApiErrorResponseSerializer(serializers.Serializer): + """Standard API error response serializer.""" + + detail = serializers.CharField() + errors = serializers.CharField(required=False, allow_blank=True) + + +class PaginationSerializer(serializers.Serializer): + """Common pagination metadata serializer.""" + + total = serializers.IntegerField() + limit = serializers.IntegerField() + offset = serializers.IntegerField() + next = serializers.CharField(allow_null=True) + previous = serializers.CharField(allow_null=True) + + +class StatisticsMediaCountSerializer(serializers.Serializer): + """Serializer for media count by type in statistics response.""" + + total = serializers.IntegerField() + tv = serializers.IntegerField() + season = serializers.IntegerField() + movie = serializers.IntegerField() + anime = serializers.IntegerField() + manga = serializers.IntegerField() + game = serializers.IntegerField() + book = serializers.IntegerField() + comic = serializers.IntegerField() + board_game = serializers.IntegerField() + + class CompleteEpisodeSerializer(serializers.Serializer): """Serializer that builds a CompleteEpisode response.""" @@ -478,9 +530,27 @@ def __init__(self, item_dict): return data +class PaginatedEventsSerializer(serializers.Serializer): + """Serializer for paginated calendar events.""" + + pagination = PaginationSerializer() + results = EventSerializer(many=True) + + +class HealthCheckSerializer(serializers.Serializer): + """Serializer for individual health checks.""" + + status = serializers.ChoiceField(choices=["ok", "error"]) + error = serializers.CharField(allow_null=True) + + class HealthResponseSerializer(serializers.Serializer): """Serializer for health check response.""" + status = serializers.ChoiceField(choices=["ok", "unavailable"]) + timestamp = serializers.DateTimeField() + checks = HealthCheckSerializer(many=True) + def to_representation(self, instance): """Transform reports from health-check library to json.""" plugins = instance.get("plugins", {}) @@ -560,6 +630,14 @@ def to_representation(self, instance): class InfoSerializer(serializers.Serializer): """Serializer for the info endpoint.""" + version = serializers.CharField() + debug = serializers.BooleanField() + frontend_url = serializers.URLField() + language = serializers.CharField() + timezone = serializers.CharField() + admin_enabled = serializers.BooleanField() + track_time = serializers.BooleanField() + def to_representation(self, instance): # noqa: ARG002 """Transform to representation.""" return { @@ -685,6 +763,41 @@ def to_representation(self, instance): } +# TODO: Complete the mapping of statistics response fields +class StatisticsResponseSerializer(serializers.Serializer): + """Serializer for statistics endpoint payload.""" + + start_date = serializers.DateTimeField() + end_date = serializers.DateTimeField() + media_count = StatisticsMediaCountSerializer() + activity_data = serializers.JSONField() + media_type_distribution = serializers.DictField() + score_distribution = serializers.DictField() + top_rated = MediaSerializer(many=True) + status_distribution = serializers.DictField() + status_pie_chart_data = serializers.JSONField() + timeline = serializers.DictField( + child=serializers.ListField(child=serializers.DictField()), + ) + + +class SearchMediaSerializer(serializers.Serializer): + """Serializer for individual media items in search results.""" + + media_id = serializers.CharField() + source = serializers.CharField() + media_type = serializers.CharField() + title = serializers.CharField() + image = serializers.URLField() + + +class SearchResponseSerializer(serializers.Serializer): + """Serializer for search endpoint results.""" + + pagination = PaginationSerializer() + results = SearchMediaSerializer(many=True) + + class MixedMediaSerializer(serializers.Serializer): """Serializer that handles mixed media types by checking every item.""" diff --git a/src/api/urls.py b/src/api/urls.py index 41c0d6d65..5a2c5c981 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -1,4 +1,9 @@ +from django.conf import settings from django.urls import re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, +) from . import views @@ -165,3 +170,13 @@ ), re_path(r"^statistics/?$", views.StatisticsView.as_view(), name="api_statistics"), ] + +if settings.SPECTACULAR_ENABLE_SERVE: + urlpatterns += [ + re_path(r"^schema/?$", SpectacularAPIView.as_view(), name="api_schema"), + re_path( + r"^docs/?$", + SpectacularSwaggerView.as_view(url_name="api_schema"), + name="api_docs", + ), + ] diff --git a/src/api/views.py b/src/api/views.py index 9d3a367fc..f2a11b4c5 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -6,6 +6,13 @@ from django.core.cache import cache from django.db import IntegrityError from django.utils.timezone import datetime, localdate, make_aware +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + extend_schema, +) from health_check.views import HealthCheckView from rest_framework import permissions from rest_framework import views as drf_views @@ -28,6 +35,7 @@ from lists.models import CustomList, CustomListItem from users.models import MediaStatusChoices +from .authentication import APIKeyAuthentication, BearerAuthentication from .changes_history_processor import ( delete_changes_history_entry, get_changes_history_entries, @@ -35,6 +43,8 @@ ) from .helpers import ( MEDIA_TYPE_COMPLETE_MODEL_MAP, + MEDIA_TYPE_VALID_LIST, + SOURCES_VALID_LIST, apply_aggregated_sort, apply_list_sort, build_lists_by_item_id, @@ -54,16 +64,38 @@ try_parse_date, validate_body, ) +from .schemas import ( + ListSearchParam, + ListSortParam, + PaginationLimitParam, + PaginationOffsetParam, + forbidden_response, +) from .serializers import ( + ApiErrorResponseSerializer, + ApiMessageResponseSerializer, ChangesHistoryEntrySerializer, CompleteEpisodeSerializer, CompleteMediaSerializer, + CreateListRequestSerializer, EpisodeSerializer, + EventSerializer, + GenericObjectSerializer, HealthResponseSerializer, HistorySerializer, InfoSerializer, + ListSerializer, MediaSerializer, + MixedMediaSerializer, + PaginatedChangesHistoryResponseSerializer, + PaginatedEventsSerializer, + PaginatedGenericResponseSerializer, + PaginatedListMembershipResponseSerializer, + PaginatedPolymorphicMediaResponseSerializer, + SearchResponseSerializer, + StatisticsResponseSerializer, TimelineItemSerializer, + UpdateListRequestSerializer, serialize_data, ) @@ -90,10 +122,140 @@ class CalendarView(drf_views.APIView): """Calendar view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = PaginatedEventsSerializer + + @extend_schema( + operation_id="calendar_get", + summary="Get events", + parameters=[ + OpenApiParameter( + name="start_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="Filter range start date.", + ), + OpenApiParameter( + name="end_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description=( + "Filter range end date. If omitted with start_date, " + "defaults to the end of that month; otherwise defaults " + "to the end of the selected/current month." + ), + ), + OpenApiParameter( + name="month", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description=( + "Calendar month (1-12) used with year. Used only " + "if start_date is not set. Default is current month." + ), + ), + OpenApiParameter( + name="year", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description=( + "Calendar year used with month. Used only " + "if start_date is not set. Default is current year." + ), + ), + PaginationLimitParam, + PaginationOffsetParam, + ], + responses={ + 200: OpenApiResponse( + PaginatedEventsSerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Events response example", + description="Events response example", + summary="Events response example", + value={ + "pagination": { + "total": 2, + "limit": 20, + "offset": 0, + "next": None, + "previous": None, + }, + "results": [ + { + "id": 5086, + "item": { + "media_id": "208569", + "source": "tmdb", + "media_type": "episode", + "title": "Will Trent", + "image": "https://image.tmdb.org/t/p/w500/qG5O46gUxxYGImld03tl2zLhvrg.jpg", + "season_number": 4, + "episode_number": 13, + }, + "item_id": "tv/tmdb/208569/4/13", + "parent_id": "tv/tmdb/208569/4", + "content_number": 13, + "datetime": "2026-04-01T00:00:00Z", + "notification_sent": False, + }, + { + "id": 14438, + "item": { + "media_id": "75219", + "source": "tmdb", + "media_type": "episode", + "title": "9-1-1", + "image": "https://image.tmdb.org/t/p/w500/2hFiCrn4XtvvTGlZQdLzGhnaOsg.jpg", + "season_number": 9, + "episode_number": 16, + }, + "item_id": "tv/tmdb/75219/9/16", + "parent_id": "tv/tmdb/75219/9", + "content_number": 16, + "datetime": "2026-04-03T00:00:00Z", + "notification_sent": False, + }, + ], + }, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid date format example", + description="Invalid date format example", + summary="Invalid date format example", + value={"detail": "Invalid date format."}, + ) + ], + ), + 403: forbidden_response, + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal server error", + examples=[ + OpenApiExample( + "Error while fetching events", + description="Error while fetching events example", + summary="Error while fetching events example", + value={ + "detail": "Error occurred while fetching events.", + "errors": "", + }, + ) + ], + ), + }, + ) def get(self, request): - """Retrieve calendar events for the authenticated user.""" + """Retrieve calendar events.""" start_date = request.GET.get("start_date") end_date = request.GET.get("end_date") month_q = request.GET.get("month") @@ -128,11 +290,10 @@ def get(self, request): ) paginated_data = paginate_data(request, releases, limit, offset) - paginated_data["results"] = serialize_data( + paginated_data["results"] = EventSerializer( paginated_data["results"], many=True, - context={"request": request}, - ) + ).data return Response(paginated_data) @@ -141,10 +302,32 @@ def get(self, request): class CalendarUpdateView(drf_views.APIView): """Update calendar view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = ApiMessageResponseSerializer + + @extend_schema( + operation_id="calendar_update_post", + summary="Trigger calendar update", + request=None, + responses={ + 202: OpenApiResponse( + ApiMessageResponseSerializer, + description="Task queued successfully", + examples=[ + OpenApiExample( + "Task queued example", + description="Task queued example", + summary="Task queued example", + value={"detail": "Task queued"}, + ) + ], + ), + 403: forbidden_response, + }, + ) def post(self, request): - """Trigger calendar events update for the authenticated user.""" + """Trigger calendar events update.""" tasks.reload_calendar.delay(request.user) return Response( {"detail": "Task queued"}, @@ -156,8 +339,93 @@ def post(self, request): class MediaTypeChangesHistoryDetailView(drf_views.APIView): """Changes history record view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = ChangesHistoryEntrySerializer + + @extend_schema( + operation_id="changes_history_entry_get", + summary="Get changes history record", + parameters=[ + OpenApiParameter( + name="media_type", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The type of media for which to retrieve changes history.", + enum=[media_type.value for media_type in MediaTypes], + ), + OpenApiParameter( + name="history_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The ID of the changes history record to retrieve.", + ), + ], + responses={ + 200: OpenApiResponse( + ChangesHistoryEntrySerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Changes history record example", + description="Changes history record example", + summary="Changes history record example", + value={ + "id": 312, + "item_id": "tv/tmdb/245703", + "timestamp": "2026-01-18T15:21:02.920479Z", + "changes": [ + {"field": "status", "old_value": 3, "new_value": 1} + ], + }, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid media type example", + description="Invalid media type example", + summary="Invalid media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 404: OpenApiResponse( + ApiErrorResponseSerializer, + description="Not found", + examples=[ + OpenApiExample( + "History record not found example", + description="History record not found example", + summary="History record not found example", + value={ + "detail": "History record not found", + "errors": "HistoricalTV matching query does not exist.", + }, + ) + ], + ), + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal server error", + examples=[ + OpenApiExample( + "Error while fetching history record example", + description="Error while fetching history record example", + summary="Error while fetching history record example", + value={ + "detail": "An error occurred while fetching the history record.", + "errors": "", + }, + ) + ], + ), + }, + ) def get(self, request, media_type, history_id): """Retrieve the changes history record for a specific media.""" if not check_valid_type(media_type, complete=True): @@ -168,11 +436,10 @@ def get(self, request, media_type, history_id): try: record = get_changes_history_entry(media_type, history_id, request.user) - serialized_data = serialize_data( - record, - context={"media_type": media_type}, - serializer_class=ChangesHistoryEntrySerializer, - ) + + serialized_data = ChangesHistoryEntrySerializer( + record, context={"media_type": media_type} + ).data return Response(serialized_data, status=HTTP.OK) except Exception as e: # noqa: BLE001 return Response( @@ -183,6 +450,81 @@ def get(self, request, media_type, history_id): status=HTTP.NOT_FOUND, ) + @extend_schema( + operation_id="changes_history_entry_delete", + summary="Delete changes history record", + parameters=[ + OpenApiParameter( + name="media_type", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The type of media for which to delete changes history.", + enum=[media_type.value for media_type in MediaTypes], + ), + OpenApiParameter( + name="history_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The ID of the changes history record to delete.", + ), + ], + responses={ + 204: OpenApiResponse( + description="History record deleted successfully", + examples=[ + OpenApiExample( + "History record deleted example", + description="History record deleted example", + summary="History record deleted example", + value=None, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid media type example", + description="Invalid media type example", + summary="Invalid media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 404: OpenApiResponse( + ApiErrorResponseSerializer, + description="Not found", + examples=[ + OpenApiExample( + "History record not found example", + description="History record not found example", + summary="History record not found example", + value={ + "detail": "History record not found", + "errors": "HistoricalTV matching query does not exist.", + }, + ) + ], + ), + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal server error", + examples=[ + OpenApiExample( + "Error while deleting history record example", + description="Error while deleting history record example", + summary="Error while deleting history record example", + value={ + "detail": "An error occurred while deleting the history record.", + "errors": "", + }, + ) + ], + ), + }, + ) def delete(self, request, media_type, history_id): """Delete the changes history record for a specific media.""" if not check_valid_type(media_type, complete=True): @@ -213,6 +555,7 @@ class HealthView(drf_views.APIView): authentication_classes = [] permission_classes = [] + serializer_class = HealthResponseSerializer checks = HealthCheckView.checks @@ -228,6 +571,78 @@ async def _collect_health_results(self): *(check.get_result() for check in self.get_checks()) ) + @extend_schema( + operation_id="health_get", + summary="Check API health status", + responses={ + 200: OpenApiResponse( + HealthResponseSerializer, + description="API is healthy", + examples=[ + OpenApiExample( + "Healthy API example", + description="Healthy API example", + summary="Healthy API example", + value={ + "status": "ok", + "timestamp": "2026-04-28T08:49:33.826808+00:00", + "checks": { + "Cache(alias='default')": { + "status": "ok", + "error": None, + }, + "Database(alias='default')": { + "status": "ok", + "error": None, + }, + "Storage(alias='default')": { + "status": "ok", + "error": None, + }, + }, + }, + ) + ], + ), + 500: OpenApiResponse( + HealthResponseSerializer, + description="API is unhealthy", + examples=[ + OpenApiExample( + "Unhealthy API example", + description="Unhealthy API example", + summary="Unhealthy API example", + value={ + "status": "unavailable", + "timestamp": "2026-04-28T08:49:33.826808+00:00", + "checks": { + "Cache(alias='default')": { + "status": "ok", + "error": None, + }, + "Database(alias='default')": { + "status": "ok", + "error": None, + }, + "DNS(hostname='laptop')": { + "status": "error", + "error": "OK", + }, + "Mail(backend='django.core.mail.backends.smtp.EmailBackend')": { + "status": "error", + "error": "OK", + }, + "Storage(alias='default')": { + "status": "ok", + "error": None, + }, + }, + }, + ) + ], + ), + }, + ) def get(self, request): # noqa: ARG002 """Check API health status.""" # TODO: speed up data collection, right now request takes ~2s @@ -242,10 +657,7 @@ def get(self, request): # noqa: ARG002 "plugins": plugins, "errors": errors, } - response_data = serialize_data( - health_data, - serializer_class=HealthResponseSerializer, - ) + response_data = HealthResponseSerializer(health_data).data status_code = HTTP.INTERNAL_SERVER_ERROR if errors else HTTP.OK return Response(response_data, status=status_code) @@ -256,14 +668,37 @@ class InfoView(drf_views.APIView): authentication_classes = [] permission_classes = [] - + serializer_class = InfoSerializer + + @extend_schema( + operation_id="info_get", + summary="Get application information", + responses={ + 200: OpenApiResponse( + InfoSerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Info response example", + description="Info response example", + summary="Info response example", + value={ + "version": "dev", + "debug": True, + "frontend_url": "http://localhost:8000", + "language": "en-us", + "timezone": "UTC", + "admin_enabled": True, + "track_time": True, + }, + ) + ], + ) + }, + ) def get(self, request): # noqa: ARG002 """Get application information.""" - info_data = {} - response_data = serialize_data( - info_data, - serializer_class=InfoSerializer, - ) + response_data = InfoSerializer({}).data return Response(response_data, status=HTTP.OK) @@ -1267,8 +1702,111 @@ def patch(self, request, media_type, source, media_id): class MediaChangesHistoryView(drf_views.APIView): """Media changes history view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = PaginatedChangesHistoryResponseSerializer + + @extend_schema( + operation_id="media_changes_history_get", + summary="Get media changes history", + parameters=[ + PaginationLimitParam, + PaginationOffsetParam, + ], + responses={ + 200: OpenApiResponse( + PaginatedChangesHistoryResponseSerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Example response", + value={ + "pagination": { + "total": 2, + "limit": 20, + "offset": 0, + "next": None, + "previous": None, + }, + "results": [ + { + "id": 312, + "item_id": "tv/tmdb/245703", + "timestamp": "2026-01-18T15:21:02.920479Z", + "changes": [ + { + "field": "status", + "old_value": 3, + "new_value": 1, + } + ], + }, + { + "id": 150, + "item_id": "tv/tmdb/245703", + "timestamp": "2025-09-17T09:41:00Z", + "changes": [ + { + "field": "score", + "old_value": None, + "new_value": 9.0, + }, + { + "field": "status", + "old_value": None, + "new_value": 3, + }, + { + "field": "notes", + "old_value": None, + "new_value": "", + }, + ], + }, + ], + }, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid media type example", + description="Invalid media type example", + summary="Invalid media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 404: OpenApiResponse( + ApiErrorResponseSerializer, + description="Not found", + examples=[ + OpenApiExample( + "Media not found example", + description="Media not found or not tracked example", + summary="Media not found example", + value={"detail": "Media not found or not tracked."}, + ) + ], + ), + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal Server Error", + examples=[ + OpenApiExample( + "Internal server error example", + description="Internal server error example", + summary="Internal server error example", + value={"detail": "Internal Server Error."}, + ) + ], + ), + }, + ) def get(self, request, media_type, source, media_id): """Retrieve changes history timeline entries for a specific media.""" limit, offset, err = parse_limit_offset(request) @@ -1310,12 +1848,11 @@ def get(self, request, media_type, source, media_id): limit, offset, ) - paginated_data["results"] = serialize_data( + paginated_data["results"] = ChangesHistoryEntrySerializer( paginated_data["results"], many=True, context={"media_type": media_type}, - serializer_class=ChangesHistoryEntrySerializer, - ) + ).data return Response(paginated_data, status=HTTP.OK) @@ -2268,8 +2805,135 @@ def patch(self, request, media_type, source, media_id, season_number): class MediaSeasonChangesHistoryView(drf_views.APIView): """Changes history season view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = PaginatedChangesHistoryResponseSerializer + + @extend_schema( + operation_id="season_changes_history_get", + summary="Get season changes history", + parameters=[ + PaginationLimitParam, + PaginationOffsetParam, + ], + responses={ + 200: OpenApiResponse( + PaginatedChangesHistoryResponseSerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Example response", + value={ + "pagination": { + "total": 4, + "limit": 20, + "offset": 0, + "next": None, + "previous": None, + }, + "results": [ + { + "id": 144, + "item_id": "tv/tmdb/245703/1", + "timestamp": "2026-01-18T15:21:02.888039Z", + "changes": [ + { + "field": "status", + "old_value": 3, + "new_value": 1, + } + ], + }, + { + "id": 143, + "item_id": "tv/tmdb/245703/1", + "timestamp": "2026-01-18T15:15:10.443874Z", + "changes": [ + { + "field": "notes", + "old_value": "", + "new_value": "Ciccio bomba", + }, + { + "field": "score", + "old_value": 9.0, + "new_value": 2.0, + }, + ], + }, + { + "id": 142, + "item_id": "tv/tmdb/245703/1", + "timestamp": "2026-01-18T15:10:47.709781Z", + "changes": [ + { + "field": "score", + "old_value": None, + "new_value": 9.0, + } + ], + }, + { + "id": 9, + "item_id": "tv/tmdb/245703/1", + "timestamp": "2025-09-17T09:41:00Z", + "changes": [ + { + "field": "status", + "old_value": None, + "new_value": 3, + }, + { + "field": "notes", + "old_value": None, + "new_value": "", + }, + ], + }, + ], + }, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid media type example", + description="Invalid media type example", + summary="Invalid media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 404: OpenApiResponse( + ApiErrorResponseSerializer, + description="Not found", + examples=[ + OpenApiExample( + "Season not found example", + description="Season not found or not tracked example", + summary="Season not found example", + value={"detail": "Season not found or not tracked."}, + ) + ], + ), + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal Server Error", + examples=[ + OpenApiExample( + "Internal server error example", + description="Internal server error example", + summary="Internal server error example", + value={"detail": "Internal Server Error."}, + ) + ], + ), + }, + ) def get(self, request, media_type, source, media_id, season_number): """Retrieve changes history timeline entries for a season.""" limit, offset, err = parse_limit_offset(request) @@ -2320,12 +2984,11 @@ def get(self, request, media_type, source, media_id, season_number): limit, offset, ) - paginated_data["results"] = serialize_data( + paginated_data["results"] = ChangesHistoryEntrySerializer( paginated_data["results"], many=True, context={"media_type": MediaTypes.SEASON.value}, - serializer_class=ChangesHistoryEntrySerializer, - ) + ).data return Response(paginated_data, status=HTTP.OK) @@ -3355,8 +4018,101 @@ def patch( class MediaEpisodeChangesHistoryView(drf_views.APIView): """Changes history episode view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = PaginatedChangesHistoryResponseSerializer + + @extend_schema( + operation_id="episode_changes_history_get", + summary="Get episode changes history", + parameters=[ + PaginationLimitParam, + PaginationOffsetParam, + ], + responses={ + 200: OpenApiResponse( + PaginatedChangesHistoryResponseSerializer, + description="Successful response", + examples=[ + OpenApiExample( + "Example response", + value={ + "pagination": { + "total": 2, + "limit": 20, + "offset": 0, + "next": None, + "previous": None, + }, + "results": [ + { + "id": 1226, + "item_id": "tv/tmdb/245703/1/1", + "timestamp": "2026-01-18T15:21:02.851911Z", + "changes": [ + { + "field": "end_date", + "old_value": None, + "new_value": "2026-01-18T15:15:00Z", + } + ], + }, + { + "id": 112, + "item_id": "tv/tmdb/245703/1/1", + "timestamp": "2026-01-15T15:33:03.502096Z", + "changes": [ + { + "field": "end_date", + "old_value": None, + "new_value": "2025-09-17T09:41:00Z", + } + ], + }, + ], + }, + ) + ], + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid media type example", + description="Invalid media type example", + summary="Invalid media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 404: OpenApiResponse( + ApiErrorResponseSerializer, + description="Not found", + examples=[ + OpenApiExample( + "Episode not found example", + description="Episode not found or not tracked example", + summary="Episode not found example", + value={"detail": "Episode not found or not tracked."}, + ) + ], + ), + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal Server Error", + examples=[ + OpenApiExample( + "Internal server error example", + description="Internal server error example", + summary="Internal server error example", + value={"detail": "Internal Server Error."}, + ) + ], + ), + }, + ) def get(self, request, media_type, source, media_id, season_number, episode_number): """Retrieve changes history timeline entries for a specific episode.""" limit, offset, err = parse_limit_offset(request) @@ -3408,12 +4164,11 @@ def get(self, request, media_type, source, media_id, season_number, episode_numb limit, offset, ) - paginated_data["results"] = serialize_data( + paginated_data["results"] = ChangesHistoryEntrySerializer( paginated_data["results"], many=True, - context={"request": request, "media_type": MediaTypes.EPISODE.value}, - serializer_class=ChangesHistoryEntrySerializer, - ) + context={"media_type": MediaTypes.EPISODE.value}, + ).data return Response(paginated_data, status=HTTP.OK) @@ -3937,9 +4692,70 @@ def post( class SearchProviderView(drf_views.APIView): """Search view.""" - serializer_class = MediaSerializer + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] + serializer_class = MediaSerializer + @extend_schema( + operation_id="search_get", + summary="Search for media", + parameters=[ + OpenApiParameter( + name="media_type", + type=OpenApiTypes.STR, + enum=MEDIA_TYPE_VALID_LIST, + location=OpenApiParameter.PATH, + description="Type of media to search for", + ), + OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search query", + ), + OpenApiParameter( + name="source", + type=OpenApiTypes.STR, + enum=SOURCES_VALID_LIST, + location=OpenApiParameter.QUERY, + description="Source of the media", + ), + PaginationLimitParam, + PaginationOffsetParam, + ], + responses={ + 200: OpenApiResponse( + SearchResponseSerializer, + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Unsupported media type example", + description="Unsupported media type example", + summary="Unsupported media type example", + value={"detail": "Unsupported media type."}, + ) + ], + ), + 403: forbidden_response, + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal server error", + examples=[ + OpenApiExample( + "Error while fetching results", + description="Error while fetching results example", + summary="Error while fetching results example", + value={ + "detail": "Internal server error.", + }, + ) + ], + ), + } + ) def get(self, request, media_type): """Search for media using the specified provider.""" search = request.GET.get("search", "") @@ -4023,8 +4839,61 @@ def get(self, request, media_type): class StatisticsView(drf_views.APIView): """Statistics view.""" + authentication_classes = [BearerAuthentication, APIKeyAuthentication] permission_classes = [permissions.IsAuthenticated] - + serializer_class = StatisticsResponseSerializer + + @extend_schema( + operation_id="statistics_get", + summary="Get user statistics", + parameters=[ + OpenApiParameter( + name="start_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="Filter media started after this date (YYYY-MM-DD)", + ), + OpenApiParameter( + name="end_date", + type=OpenApiTypes.DATE, + location=OpenApiParameter.QUERY, + description="Filter media started before this date (YYYY-MM-DD)", + ), + ], + responses={ + 200: OpenApiResponse( + StatisticsResponseSerializer, + description="Successful response", + ), + 400: OpenApiResponse( + ApiErrorResponseSerializer, + description="Bad request", + examples=[ + OpenApiExample( + "Invalid date format example", + description="Invalid date format example", + summary="Invalid date format example", + value={"detail": "Invalid date format."}, + ) + ], + ), + 403: forbidden_response, + 500: OpenApiResponse( + ApiErrorResponseSerializer, + description="Internal server error", + examples=[ + OpenApiExample( + "Error while fetching statistics", + description="Error while fetching statistics example", + summary="Error while fetching statistics example", + value={ + "detail": "Internal server error.", + }, + ) + ], + ), + } + ) def get(self, request): """Retrieve statistics for the authenticated user.""" # TODO: Possibly don't use WebUI needed statistics but compute them for API diff --git a/src/config/settings.py b/src/config/settings.py index e8923a412..9141f58c6 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -114,6 +114,7 @@ def secret(key, default=undefined, **kwargs): "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "api", "app", "events", "integrations", @@ -131,7 +132,6 @@ def secret(key, default=undefined, **kwargs): "allauth.socialaccount", "django.contrib.humanize", "rest_framework", - "api", "drf_spectacular", ] @@ -143,11 +143,10 @@ def secret(key, default=undefined, **kwargs): "api.authentication.BearerAuthentication", "api.authentication.APIKeyAuthentication", ], - "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } - APPEND_SLASH = True MIDDLEWARE = [ @@ -352,6 +351,34 @@ def secret(key, default=undefined, **kwargs): TRACK_TIME = config("TRACK_TIME", default=True, cast=bool) +SPECTACULAR_ENABLE_SERVE = config( + "SPECTACULAR_ENABLE_SERVE", + default=DEBUG, + cast=bool, +) + +SPECTACULAR_SETTINGS = { + "TITLE": "Yamtrack API", + "DESCRIPTION": "OpenAPI schema for Yamtrack's API", + "VERSION": "0.1.0", + "LICENSE": { + "name": "GNU AFFERO GENERAL PUBLIC LICENSE v3.0", + "url": "https://github.com/FuzzyGrim/Yamtrack/blob/dev/LICENSE", + }, + "SERVERS": [ + { + "url": "http://localhost:8000/", + "description": "Local development server", + }, + ], + "SCHEMA_PATH_PREFIX": "/api/v1", + "SORT_OPERATIONS": True, + "COMPONENT_SPLIT_REQUEST": True, + "PARSER_WHITELIST": [ + "rest_framework.parsers.JSONParser", + ], +} + TZ = zoneinfo.ZoneInfo(TIME_ZONE) IMG_NONE = "https://www.themoviedb.org/assets/2/v4/glyphicons/basic/glyphicons-basic-38-picture-grey-c2ebdbb057f2a7614185931650f8cee23fa137b93812ccb132b9df511df1cfac.svg" diff --git a/src/config/urls.py b/src/config/urls.py index 42b7ba7a0..9b9ad4191 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -12,7 +12,6 @@ from django.contrib import admin from django.contrib.auth.decorators import login_not_required from django.urls import include, path -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from health_check.views import HealthCheckView from redis.asyncio import Redis as RedisClient @@ -44,12 +43,6 @@ ), ), path("api/v1/", include("api.urls")), - path("api/schema/", SpectacularAPIView.as_view(), name="schema"), - path( - "api/docs/", - SpectacularSwaggerView.as_view(url_name="schema"), - name="swagger-ui", - ), ] # Build the accounts URLs