diff --git a/.github/workflows/api-v2-ci.yml b/.github/workflows/api-v2-ci.yml index 5e879b91a..f34d6c72a 100644 --- a/.github/workflows/api-v2-ci.yml +++ b/.github/workflows/api-v2-ci.yml @@ -1,15 +1,22 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + name: API v2 CI on: push: - branches: [beta] + branches: [beta, api-v2] paths: - 'apps/api_v2/**' + - 'pyproject.toml' + - 'requirements.txt' - '.github/workflows/api-v2-ci.yml' pull_request: - branches: [beta] + branches: [beta, api-v2] paths: - 'apps/api_v2/**' + - 'pyproject.toml' + - 'requirements.txt' - '.github/workflows/api-v2-ci.yml' jobs: @@ -33,3 +40,78 @@ jobs: - name: Ruff format check run: ruff format --check apps/api_v2/ + + test: + name: Tests + runs-on: ubuntu-latest + needs: [lint] + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: gcd_ci + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ptest" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + default-libmysqlclient-dev \ + libicu-dev \ + pkg-config + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip "setuptools<70" + pip install -r requirements.txt + + - name: Create CI settings override + run: | + cat > settings_ci.py <<'EOF' + from settings import * + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'gcd_ci', + 'USER': 'root', + 'PASSWORD': 'test', + 'HOST': '127.0.0.1', + 'PORT': 3306, + 'ATOMIC_REQUESTS': True, + } + } + + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake', + } + } + + SILENCED_SYSTEM_CHECKS = [ + 'django_recaptcha.recaptcha_test_key_error', + 'models.E025', + 'fields.W903', + ] + EOF + + - name: Run v2 API tests + env: + DJANGO_SETTINGS_MODULE: settings_ci + run: pytest apps/api_v2/tests/ -v --tb=short diff --git a/apps/api_v2/__init__.py b/apps/api_v2/__init__.py index e69de29bb..baa66ed4e 100644 --- a/apps/api_v2/__init__.py +++ b/apps/api_v2/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only diff --git a/apps/api_v2/filters/__init__.py b/apps/api_v2/filters/__init__.py index e69de29bb..baa66ed4e 100644 --- a/apps/api_v2/filters/__init__.py +++ b/apps/api_v2/filters/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only diff --git a/apps/api_v2/filters/issues.py b/apps/api_v2/filters/issues.py new file mode 100644 index 000000000..2d9f656d0 --- /dev/null +++ b/apps/api_v2/filters/issues.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""django-filter configuration for v2 issue endpoints.""" + +import django_filters + +from apps.gcd.models import Issue + + +class IssueFilterSet(django_filters.FilterSet): + """Filters for issue list endpoints.""" + + series = django_filters.NumberFilter(field_name='series_id') + variant_of = django_filters.BooleanFilter(method='filter_variant_of') + key_date__gt = django_filters.CharFilter( + field_name='key_date', + lookup_expr='gt', + ) + key_date__gte = django_filters.CharFilter( + field_name='key_date', + lookup_expr='gte', + ) + key_date__lt = django_filters.CharFilter( + field_name='key_date', + lookup_expr='lt', + ) + key_date__lte = django_filters.CharFilter( + field_name='key_date', + lookup_expr='lte', + ) + on_sale_date__gt = django_filters.CharFilter( + field_name='on_sale_date', + lookup_expr='gt', + ) + on_sale_date__gte = django_filters.CharFilter( + field_name='on_sale_date', + lookup_expr='gte', + ) + on_sale_date__lt = django_filters.CharFilter( + field_name='on_sale_date', + lookup_expr='lt', + ) + on_sale_date__lte = django_filters.CharFilter( + field_name='on_sale_date', + lookup_expr='lte', + ) + modified__gt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gt', + ) + modified__gte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gte', + ) + modified__lt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lt', + ) + modified__lte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lte', + ) + created__gt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gt', + ) + created__gte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gte', + ) + created__lt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lt', + ) + created__lte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lte', + ) + + class Meta: + """FilterSet metadata for issue filtering.""" + + model = Issue + fields = ( + 'series', + 'number', + 'key_date__gt', + 'key_date__gte', + 'key_date__lt', + 'key_date__lte', + 'on_sale_date__gt', + 'on_sale_date__gte', + 'on_sale_date__lt', + 'on_sale_date__lte', + 'isbn', + 'barcode', + 'variant_of', + 'modified__gt', + 'modified__gte', + 'modified__lt', + 'modified__lte', + 'created__gt', + 'created__gte', + 'created__lt', + 'created__lte', + ) + + def filter_variant_of(self, queryset, name, value): + """Filter by whether an issue is a variant.""" + del name + return queryset.filter(variant_of__isnull=not value) diff --git a/apps/api_v2/filters/publishers.py b/apps/api_v2/filters/publishers.py new file mode 100644 index 000000000..abeb5d10a --- /dev/null +++ b/apps/api_v2/filters/publishers.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""django-filter configuration for v2 publisher endpoints.""" + +import django_filters + +from apps.gcd.models import Publisher + + +class PublisherFilterSet(django_filters.FilterSet): + """Filters for publisher list endpoints.""" + + name = django_filters.CharFilter( + field_name='name', + lookup_expr='icontains', + ) + country = django_filters.CharFilter(field_name='country__code') + modified__gt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gt', + ) + modified__gte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gte', + ) + modified__lt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lt', + ) + modified__lte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lte', + ) + created__gt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gt', + ) + created__gte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gte', + ) + created__lt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lt', + ) + created__lte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lte', + ) + + class Meta: + """FilterSet metadata for publisher filtering.""" + + model = Publisher + fields = ( + 'name', + 'year_began', + 'year_ended', + 'country', + 'modified__gt', + 'modified__gte', + 'modified__lt', + 'modified__lte', + 'created__gt', + 'created__gte', + 'created__lt', + 'created__lte', + ) diff --git a/apps/api_v2/filters/series.py b/apps/api_v2/filters/series.py new file mode 100644 index 000000000..cec740c0a --- /dev/null +++ b/apps/api_v2/filters/series.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""django-filter configuration for v2 series endpoints.""" + +import django_filters + +from apps.gcd.models import Series + + +class SeriesFilterSet(django_filters.FilterSet): + """Filters for series list endpoints.""" + + name = django_filters.CharFilter( + field_name='name', + lookup_expr='icontains', + ) + country = django_filters.CharFilter(field_name='country__code') + language = django_filters.CharFilter(field_name='language__code') + publisher = django_filters.NumberFilter(field_name='publisher_id') + publication_type = django_filters.NumberFilter( + field_name='publication_type_id', + ) + modified__gt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gt', + ) + modified__gte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='gte', + ) + modified__lt = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lt', + ) + modified__lte = django_filters.IsoDateTimeFilter( + field_name='modified', + lookup_expr='lte', + ) + created__gt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gt', + ) + created__gte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='gte', + ) + created__lt = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lt', + ) + created__lte = django_filters.IsoDateTimeFilter( + field_name='created', + lookup_expr='lte', + ) + + class Meta: + """FilterSet metadata for series filtering.""" + + model = Series + fields = ( + 'name', + 'year_began', + 'year_ended', + 'country', + 'language', + 'publisher', + 'publication_type', + 'modified__gt', + 'modified__gte', + 'modified__lt', + 'modified__lte', + 'created__gt', + 'created__gte', + 'created__lt', + 'created__lte', + ) diff --git a/apps/api_v2/pagination.py b/apps/api_v2/pagination.py index 9c18c5409..dfdb45c9f 100644 --- a/apps/api_v2/pagination.py +++ b/apps/api_v2/pagination.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Pagination classes for the v2 API.""" from rest_framework.pagination import PageNumberPagination diff --git a/apps/api_v2/routers.py b/apps/api_v2/routers.py index c880ab202..5b1aface4 100644 --- a/apps/api_v2/routers.py +++ b/apps/api_v2/routers.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Custom DRF routers for the v2 API. Both surface URL confs (``urls_www`` and ``urls_my``) build their diff --git a/apps/api_v2/serializers/__init__.py b/apps/api_v2/serializers/__init__.py index e69de29bb..baa66ed4e 100644 --- a/apps/api_v2/serializers/__init__.py +++ b/apps/api_v2/serializers/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only diff --git a/apps/api_v2/serializers/issues.py b/apps/api_v2/serializers/issues.py new file mode 100644 index 000000000..961f8d1a5 --- /dev/null +++ b/apps/api_v2/serializers/issues.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Serializers for v2 issue endpoints.""" + +from rest_framework import serializers + +from apps.api_v2.utils.credits import collect_credit_strings +from apps.gcd.models import Issue, Story + + +def _cover_url(issue): + """Return the best available cover URL for an issue.""" + covers = getattr(issue, 'active_cover_list', None) + if covers is None: + covers = list(issue.cover_set.filter(deleted=False).order_by('id')) + if not covers and issue.variant_of_id and issue.variant_cover_status == 1: + base_issue = getattr(issue, 'variant_of', None) + base_covers = getattr(base_issue, 'active_cover_list', None) + if base_covers is None and base_issue is not None: + base_covers = list( + base_issue.cover_set.filter(deleted=False).order_by('id') + ) + covers = base_covers or [] + if not covers: + return '' + cover = covers[0] + return f'{cover.get_base_url()}/w400/{cover.id}.jpg' + + +class StorySerializer(serializers.ModelSerializer): + """Serialize nested stories on issue detail responses.""" + + type = serializers.SerializerMethodField() + script = serializers.SerializerMethodField() + pencils = serializers.SerializerMethodField() + inks = serializers.SerializerMethodField() + colors = serializers.SerializerMethodField() + letters = serializers.SerializerMethodField() + editing = serializers.SerializerMethodField() + keywords = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field='name', + ) + + class Meta: + """Serializer metadata for issue detail story selection.""" + + model = Story + fields = ( + 'id', + 'type', + 'title', + 'feature', + 'sequence_number', + 'page_count', + 'script', + 'pencils', + 'inks', + 'colors', + 'letters', + 'editing', + 'job_number', + 'genre', + 'first_line', + 'characters', + 'synopsis', + 'notes', + 'keywords', + ) + + def get_type(self, obj): + """Return the minimal nested story type reference.""" + return { + 'id': obj.type_id, + 'name': obj.type.name, + } + + def get_script(self, obj): + """Return story script credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'script', + prefetched_attr='active_credit_list', + ) + + def get_pencils(self, obj): + """Return story pencil credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'pencils', + prefetched_attr='active_credit_list', + ) + + def get_inks(self, obj): + """Return story ink credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'inks', + prefetched_attr='active_credit_list', + ) + + def get_colors(self, obj): + """Return story color credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'colors', + prefetched_attr='active_credit_list', + ) + + def get_letters(self, obj): + """Return story lettering credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'letters', + prefetched_attr='active_credit_list', + ) + + def get_editing(self, obj): + """Return story editing credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'editing', + prefetched_attr='active_credit_list', + ) + + +class IssueListSerializer(serializers.ModelSerializer): + """Serialize issue list responses for the v2 public API.""" + + series = serializers.SerializerMethodField() + descriptor = serializers.SerializerMethodField() + editing_credits = serializers.SerializerMethodField() + indicia_publisher = serializers.SerializerMethodField() + brand_emblems = serializers.SerializerMethodField() + variant_of = serializers.IntegerField( + source='variant_of_id', read_only=True + ) + keywords = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field='name', + ) + cover_url = serializers.SerializerMethodField() + + class Meta: + """Serializer metadata for issue list field selection.""" + + model = Issue + fields = ( + 'id', + 'series', + 'number', + 'volume', + 'descriptor', + 'variant_name', + 'title', + 'publication_date', + 'key_date', + 'on_sale_date', + 'price', + 'page_count', + 'editing_credits', + 'indicia_publisher', + 'brand_emblems', + 'isbn', + 'barcode', + 'rating', + 'indicia_frequency', + 'notes', + 'variant_of', + 'keywords', + 'created', + 'modified', + 'cover_url', + ) + + def get_series(self, obj): + """Return the minimal nested series reference.""" + return { + 'id': obj.series_id, + 'name': obj.series.name, + } + + def get_descriptor(self, obj): + """Return the full issue descriptor.""" + return obj.full_descriptor + + def get_editing_credits(self, obj): + """Return issue editing credits as plain-text entries.""" + return collect_credit_strings( + obj, + 'editing', + prefetched_attr='active_credit_list', + ) + + def get_indicia_publisher(self, obj): + """Return the minimal nested indicia publisher reference.""" + if obj.indicia_publisher_id is None: + return None + return { + 'id': obj.indicia_publisher_id, + 'name': obj.indicia_publisher.name, + } + + def get_brand_emblems(self, obj): + """Return nested brand emblem references sorted by name.""" + return [ + {'id': brand.id, 'name': brand.name} + for brand in sorted( + obj.brand_emblem.all(), + key=lambda brand: (brand.name, brand.id), + ) + ] + + def get_cover_url(self, obj): + """Return the first available cover URL.""" + return _cover_url(obj) + + +class IssueDetailSerializer(IssueListSerializer): + """Serialize issue detail responses with nested stories.""" + + stories = serializers.SerializerMethodField() + + class Meta(IssueListSerializer.Meta): + """Serializer metadata for issue detail field selection.""" + + fields = IssueListSerializer.Meta.fields + ('stories',) + + def get_stories(self, obj): + """Return nested active stories for the issue detail response.""" + stories = getattr(obj, 'active_story_list', None) + if stories is None: + stories = obj.active_stories().order_by('sequence_number', 'id') + return StorySerializer(stories, many=True).data diff --git a/apps/api_v2/serializers/publishers.py b/apps/api_v2/serializers/publishers.py new file mode 100644 index 000000000..5ae82b719 --- /dev/null +++ b/apps/api_v2/serializers/publishers.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Serializers for v2 publisher endpoints.""" + +from rest_framework import serializers + +from apps.gcd.models import Publisher + + +class PublisherSerializer(serializers.ModelSerializer): + """Serialize publishers for v2 list and detail endpoints.""" + + country = serializers.SlugRelatedField(read_only=True, slug_field='code') + keywords = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field='name', + ) + + class Meta: + """Serializer metadata for publisher field selection.""" + + model = Publisher + fields = ( + 'id', + 'name', + 'year_began', + 'year_ended', + 'country', + 'url', + 'notes', + 'series_count', + 'issue_count', + 'brand_count', + 'indicia_publisher_count', + 'keywords', + 'created', + 'modified', + ) diff --git a/apps/api_v2/serializers/series.py b/apps/api_v2/serializers/series.py new file mode 100644 index 000000000..66741dd70 --- /dev/null +++ b/apps/api_v2/serializers/series.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Serializers for v2 series endpoints.""" + +from rest_framework import serializers + +from apps.gcd.models import Series + + +class SeriesSerializer(serializers.ModelSerializer): + """Serialize series for v2 list and detail endpoints.""" + + country = serializers.SlugRelatedField(read_only=True, slug_field='code') + language = serializers.SlugRelatedField(read_only=True, slug_field='code') + publication_type = serializers.CharField( + source='publication_type.name', + read_only=True, + allow_null=True, + ) + publisher = serializers.SerializerMethodField() + active_issue_ids = serializers.SerializerMethodField() + keywords = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field='name', + ) + + class Meta: + """Serializer metadata for series field selection.""" + + model = Series + fields = ( + 'id', + 'name', + 'sort_name', + 'year_began', + 'year_ended', + 'country', + 'language', + 'publisher', + 'publication_type', + 'color', + 'dimensions', + 'paper_stock', + 'binding', + 'publishing_format', + 'notes', + 'issue_count', + 'active_issue_ids', + 'keywords', + 'created', + 'modified', + ) + + def get_publisher(self, obj): + """Return the minimal nested publisher reference.""" + return { + 'id': obj.publisher_id, + 'name': obj.publisher.name, + } + + def get_active_issue_ids(self, obj): + """Return ordered non-deleted issue ids for the series.""" + active_issues = getattr(obj, 'active_issue_list', None) + if active_issues is None: + return list( + obj.active_issues() + .order_by('sort_code', 'id') + .values_list('id', flat=True) + ) + return [issue.id for issue in active_issues] diff --git a/apps/api_v2/tests/__init__.py b/apps/api_v2/tests/__init__.py index e69de29bb..baa66ed4e 100644 --- a/apps/api_v2/tests/__init__.py +++ b/apps/api_v2/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only diff --git a/apps/api_v2/tests/conftest.py b/apps/api_v2/tests/conftest.py index 49dd94c28..58ad0d3c5 100644 --- a/apps/api_v2/tests/conftest.py +++ b/apps/api_v2/tests/conftest.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Shared pytest fixtures for the v2 API test suite. Three groups: diff --git a/apps/api_v2/tests/test_conditional.py b/apps/api_v2/tests/test_conditional.py index bf65b5e67..226f59036 100644 --- a/apps/api_v2/tests/test_conditional.py +++ b/apps/api_v2/tests/test_conditional.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Tests for the conditional-request helpers in ``api_v2.utils``.""" +from datetime import datetime from unittest.mock import Mock from apps.api_v2.utils.conditional import make_last_modified @@ -8,6 +12,7 @@ class _DummyModel: """Minimal stand-in model with a mock default manager.""" + _meta = Mock(label_lower='tests.dummy') _default_manager = Mock() @@ -15,11 +20,15 @@ def test_make_last_modified_uses_queryset_getter_for_list_result_set(): """A custom queryset getter scopes list timestamps to view results.""" default_qs = Mock(name='default_qs') filtered_qs = Mock(name='filtered_qs') + ordered_qs = Mock(name='ordered_qs') + values_qs = Mock(name='values_qs') _DummyModel._default_manager = Mock() _DummyModel._default_manager.all.return_value = default_qs filtered_qs.filter.return_value = filtered_qs - filtered_qs.aggregate.return_value = {'latest': 'filtered-latest'} + filtered_qs.order_by.return_value = ordered_qs + ordered_qs.values_list.return_value = values_qs + values_qs.first.return_value = 'filtered-latest' queryset_getter = Mock(return_value=filtered_qs) last_modified = make_last_modified( @@ -31,16 +40,22 @@ def test_make_last_modified_uses_queryset_getter_for_list_result_set(): queryset_getter.assert_called_once_with('request', pk=None) _DummyModel._default_manager.all.assert_not_called() filtered_qs.filter.assert_called_once_with(deleted=False) - filtered_qs.aggregate.assert_called_once() + filtered_qs.order_by.assert_called_once_with('-modified') + ordered_qs.values_list.assert_called_once_with('modified', flat=True) + values_qs.first.assert_called_once_with() def test_make_last_modified_applies_soft_delete_to_custom_queryset(): """Custom queryset getters still honor the helper's soft-delete rule.""" raw_qs = Mock(name='raw_qs') visible_qs = Mock(name='visible_qs') + ordered_qs = Mock(name='ordered_qs') + values_qs = Mock(name='values_qs') raw_qs.filter.return_value = visible_qs - visible_qs.aggregate.return_value = {'latest': 'visible-latest'} + visible_qs.order_by.return_value = ordered_qs + ordered_qs.values_list.return_value = values_qs + values_qs.first.return_value = 'visible-latest' queryset_getter = Mock(return_value=raw_qs) last_modified = make_last_modified( @@ -50,4 +65,37 @@ def test_make_last_modified_applies_soft_delete_to_custom_queryset(): assert last_modified(request='request') == 'visible-latest' raw_qs.filter.assert_called_once_with(deleted=False) - visible_qs.aggregate.assert_called_once() + visible_qs.order_by.assert_called_once_with('-modified') + ordered_qs.values_list.assert_called_once_with('modified', flat=True) + values_qs.first.assert_called_once_with() + + +def test_make_last_modified_caches_request_local_list_result(): + """List requests reuse cached metadata within a single request.""" + qs = Mock(name='qs') + ordered_qs = Mock(name='ordered_qs') + values_qs = Mock(name='values_qs') + latest = datetime(2026, 5, 3, 12, 0, 0) + + _DummyModel._default_manager = Mock() + _DummyModel._default_manager.all.return_value = qs + qs.filter.return_value = qs + qs.order_by.return_value = ordered_qs + ordered_qs.values_list.return_value = values_qs + values_qs.first.return_value = latest + + class _Request: + """Minimal request object with a stable path.""" + + def get_full_path(self): + return '/api/v2/issues/' + + request = _Request() + last_modified = make_last_modified(_DummyModel) + + assert last_modified(request=request) == latest + assert last_modified(request=request) == latest + qs.filter.assert_called_once_with(deleted=False) + qs.order_by.assert_called_once_with('-modified') + ordered_qs.values_list.assert_called_once_with('modified', flat=True) + values_qs.first.assert_called_once_with() diff --git a/apps/api_v2/tests/test_filters/__init__.py b/apps/api_v2/tests/test_filters/__init__.py new file mode 100644 index 000000000..168a4cf23 --- /dev/null +++ b/apps/api_v2/tests/test_filters/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Filter tests for v2 endpoints.""" diff --git a/apps/api_v2/tests/test_filters/test_issues.py b/apps/api_v2/tests/test_filters/test_issues.py new file mode 100644 index 000000000..50890175c --- /dev/null +++ b/apps/api_v2/tests/test_filters/test_issues.py @@ -0,0 +1,201 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the issue filter set.""" + +from datetime import timedelta + +from django.utils import timezone + +from apps.api_v2.filters.issues import IssueFilterSet +from apps.gcd.models import Issue + + +def _set_timestamps(obj, *, created, modified): + """Persist explicit created/modified timestamps for filter tests.""" + Issue.objects.filter(pk=obj.pk).update( + created=created, + modified=modified, + ) + obj.refresh_from_db() + + +def _create_issue( + series, + *, + number, + key_date, + on_sale_date, + isbn='', + barcode='', + variant_of=None, +): + """Create a minimal issue row for filter tests.""" + return Issue.objects.create( + number=number, + title='', + volume='', + isbn=isbn, + valid_isbn='', + variant_name='', + barcode=barcode, + publication_date='', + key_date=key_date, + on_sale_date=on_sale_date, + sort_code=int(number), + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=series, + variant_of=variant_of, + ) + + +def test_issue_filter_matches_series_number_isbn_and_barcode( + series, publisher +): + """Exact issue filters narrow list results correctly.""" + other_series = series.__class__.objects.create( + name='Other Series', + sort_name='Other Series', + year_began=1991, + publication_dates='1991 - present', + notes='', + tracking_notes='', + country=series.country, + language=series.language, + publisher=publisher, + ) + matching = _create_issue( + series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + isbn='1111111111111', + barcode='12345', + ) + _create_issue( + other_series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + isbn='1111111111111', + barcode='12345', + ) + _create_issue( + series, + number='2', + key_date='2024-01-01', + on_sale_date='2024-01-08', + isbn='2222222222222', + barcode='67890', + ) + + qs = IssueFilterSet( + { + 'series': str(series.pk), + 'number': '1', + 'isbn': '1111111111111', + 'barcode': '12345', + }, + queryset=Issue.objects.all(), + ).qs + + assert list(qs) == [matching] + + +def test_issue_filter_matches_key_date_and_on_sale_date_ranges(series): + """Date-like char filters support range-style issue queries.""" + earlier = _create_issue( + series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + ) + later = _create_issue( + series, + number='2', + key_date='2024-03-01', + on_sale_date='2024-03-08', + ) + + qs = IssueFilterSet( + { + 'key_date__gte': '2024-02-01', + 'on_sale_date__lte': '2024-03-31', + }, + queryset=Issue.objects.all(), + ).qs + + assert list(qs) == [later] + assert earlier not in qs + + +def test_issue_filter_matches_variant_presence(series): + """Variant filtering supports base-only and variant-only queries.""" + base = _create_issue( + series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + ) + variant = _create_issue( + series, + number='2', + key_date='2024-01-02', + on_sale_date='2024-01-09', + variant_of=base, + ) + + variant_qs = IssueFilterSet( + {'variant_of': 'true'}, + queryset=Issue.objects.all(), + ).qs + base_qs = IssueFilterSet( + {'variant_of': 'false'}, + queryset=Issue.objects.all(), + ).qs + + assert list(variant_qs) == [variant] + assert list(base_qs) == [base] + + +def test_issue_filter_matches_modified_and_created_ranges(series): + """Created/modified range filters support sync-style issue queries.""" + older = _create_issue( + series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + ) + newer = _create_issue( + series, + number='2', + key_date='2024-02-01', + on_sale_date='2024-02-08', + ) + now = timezone.now() + _set_timestamps( + older, + created=now - timedelta(days=3), + modified=now - timedelta(days=2), + ) + _set_timestamps( + newer, + created=now - timedelta(hours=2), + modified=now - timedelta(hours=1), + ) + + modified_qs = IssueFilterSet( + {'modified__gt': (now - timedelta(days=1)).isoformat()}, + queryset=Issue.objects.all(), + ).qs + created_qs = IssueFilterSet( + {'created__lte': (now - timedelta(days=1)).isoformat()}, + queryset=Issue.objects.all(), + ).qs + + assert list(modified_qs) == [newer] + assert list(created_qs) == [older] diff --git a/apps/api_v2/tests/test_filters/test_publishers.py b/apps/api_v2/tests/test_filters/test_publishers.py new file mode 100644 index 000000000..4fa5cdb70 --- /dev/null +++ b/apps/api_v2/tests/test_filters/test_publishers.py @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the publisher filter set.""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from apps.api_v2.filters.publishers import PublisherFilterSet +from apps.gcd.models import Publisher +from apps.stddata.models import Country + + +@pytest.fixture +def other_country(db): + """Return a second country for publisher filter tests.""" + obj, _ = Country.objects.get_or_create( + code='yy', + defaults={'name': 'Other Country'}, + ) + return obj + + +def _set_timestamps(obj, *, created, modified): + """Persist explicit created/modified timestamps for filter tests.""" + Publisher.objects.filter(pk=obj.pk).update( + created=created, + modified=modified, + ) + obj.refresh_from_db() + + +def test_publisher_filter_matches_name_icontains(country): + """The name filter uses case-insensitive containment.""" + matching = Publisher.objects.create( + name='Marvel Comics', + year_began=1939, + notes='', + country=country, + ) + Publisher.objects.create( + name='DC Comics', + year_began=1934, + notes='', + country=country, + ) + + qs = PublisherFilterSet( + {'name': 'marvel'}, + queryset=Publisher.objects.all(), + ).qs + + assert list(qs) == [matching] + + +def test_publisher_filter_matches_year_fields_and_country( + country, + other_country, +): + """Exact year and country filters narrow publishers correctly.""" + matching = Publisher.objects.create( + name='Matching Publisher', + year_began=1950, + year_ended=1980, + notes='', + country=country, + ) + Publisher.objects.create( + name='Wrong Country', + year_began=1950, + year_ended=1980, + notes='', + country=other_country, + ) + Publisher.objects.create( + name='Wrong Year', + year_began=1949, + year_ended=1980, + notes='', + country=country, + ) + + qs = PublisherFilterSet( + { + 'year_began': '1950', + 'year_ended': '1980', + 'country': country.code, + }, + queryset=Publisher.objects.all(), + ).qs + + assert list(qs) == [matching] + + +def test_publisher_filter_matches_modified_range(country): + """Modified range filters support delta-style sync queries.""" + older = Publisher.objects.create( + name='Older Publisher', + year_began=1940, + notes='', + country=country, + ) + newer = Publisher.objects.create( + name='Newer Publisher', + year_began=1950, + notes='', + country=country, + ) + now = timezone.now() + _set_timestamps( + older, + created=now - timedelta(days=3), + modified=now - timedelta(days=2), + ) + _set_timestamps( + newer, + created=now - timedelta(days=1), + modified=now - timedelta(hours=1), + ) + + qs = PublisherFilterSet( + {'modified__gt': (now - timedelta(days=1)).isoformat()}, + queryset=Publisher.objects.all(), + ).qs + + assert list(qs) == [newer] + + +def test_publisher_filter_matches_created_range(country): + """Created range filters support bounded publisher queries.""" + older = Publisher.objects.create( + name='Older Publisher', + year_began=1940, + notes='', + country=country, + ) + newer = Publisher.objects.create( + name='Newer Publisher', + year_began=1950, + notes='', + country=country, + ) + now = timezone.now() + older_created = now - timedelta(days=3) + newer_created = now - timedelta(hours=1) + _set_timestamps( + older, + created=older_created, + modified=older_created, + ) + _set_timestamps( + newer, + created=newer_created, + modified=newer_created, + ) + + qs = PublisherFilterSet( + {'created__lte': (now - timedelta(days=1)).isoformat()}, + queryset=Publisher.objects.all(), + ).qs + + assert list(qs) == [older] diff --git a/apps/api_v2/tests/test_filters/test_series.py b/apps/api_v2/tests/test_filters/test_series.py new file mode 100644 index 000000000..f49b97f56 --- /dev/null +++ b/apps/api_v2/tests/test_filters/test_series.py @@ -0,0 +1,286 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the series filter set.""" + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from apps.api_v2.filters.series import SeriesFilterSet +from apps.gcd.models import Publisher, Series, SeriesPublicationType +from apps.stddata.models import Country, Language + + +@pytest.fixture +def other_country(db): + """Return a second country for series filter tests.""" + obj, _ = Country.objects.get_or_create( + code='yy', + defaults={'name': 'Other Country'}, + ) + return obj + + +@pytest.fixture +def other_language(db): + """Return a second language for series filter tests.""" + obj, _ = Language.objects.get_or_create( + code='yy', + defaults={'name': 'Other Language'}, + ) + return obj + + +@pytest.fixture +def series_publication_type(db): + """Return a publication type for series filter tests.""" + return SeriesPublicationType.objects.create( + name='Comic Book', + notes='', + ) + + +def _set_timestamps(obj, *, created, modified): + """Persist explicit created/modified timestamps for filter tests.""" + Series.objects.filter(pk=obj.pk).update( + created=created, + modified=modified, + ) + obj.refresh_from_db() + + +def _create_series( + *, + country, + language, + name, + publication_type, + publisher, + year_began, + year_ended=None, +): + """Create a minimal series row for filter tests.""" + return Series.objects.create( + name=name, + sort_name=name, + year_began=year_began, + year_ended=year_ended, + publication_dates='1990 - present', + notes='', + tracking_notes='', + country=country, + language=language, + publisher=publisher, + publication_type=publication_type, + ) + + +def test_series_filter_matches_name_icontains( + country, + language, + publisher, + series_publication_type, +): + """The name filter uses case-insensitive containment.""" + matching = _create_series( + country=country, + language=language, + name='Batman Adventures', + publication_type=series_publication_type, + publisher=publisher, + year_began=1992, + ) + _create_series( + country=country, + language=language, + name='Superman Adventures', + publication_type=series_publication_type, + publisher=publisher, + year_began=1992, + ) + + qs = SeriesFilterSet( + {'name': 'batman'}, + queryset=Series.objects.all(), + ).qs + + assert list(qs) == [matching] + + +def test_series_filter_matches_exact_fields( + country, + other_country, + language, + other_language, + publisher, + series_publication_type, +): + """Exact filters narrow the series collection correctly.""" + other_publisher = Publisher.objects.create( + name='Other Publisher', + year_began=1940, + notes='', + country=country, + ) + other_publication_type = SeriesPublicationType.objects.create( + name='Magazine', + notes='', + ) + matching = _create_series( + country=country, + language=language, + name='Matching Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1985, + year_ended=1989, + ) + _create_series( + country=other_country, + language=language, + name='Wrong Country', + publication_type=series_publication_type, + publisher=publisher, + year_began=1985, + year_ended=1989, + ) + _create_series( + country=country, + language=other_language, + name='Wrong Language', + publication_type=series_publication_type, + publisher=publisher, + year_began=1985, + year_ended=1989, + ) + _create_series( + country=country, + language=language, + name='Wrong Publisher', + publication_type=series_publication_type, + publisher=other_publisher, + year_began=1985, + year_ended=1989, + ) + _create_series( + country=country, + language=language, + name='Wrong Type', + publication_type=other_publication_type, + publisher=publisher, + year_began=1985, + year_ended=1989, + ) + _create_series( + country=country, + language=language, + name='Wrong Year', + publication_type=series_publication_type, + publisher=publisher, + year_began=1984, + year_ended=1989, + ) + + qs = SeriesFilterSet( + { + 'year_began': '1985', + 'year_ended': '1989', + 'country': country.code, + 'language': language.code, + 'publisher': str(publisher.pk), + 'publication_type': str(series_publication_type.pk), + }, + queryset=Series.objects.all(), + ).qs + + assert list(qs) == [matching] + + +def test_series_filter_matches_modified_range( + country, + language, + publisher, + series_publication_type, +): + """Modified range filters support delta-style sync queries.""" + older = _create_series( + country=country, + language=language, + name='Older Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1980, + ) + newer = _create_series( + country=country, + language=language, + name='Newer Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1990, + ) + now = timezone.now() + _set_timestamps( + older, + created=now - timedelta(days=3), + modified=now - timedelta(days=2), + ) + _set_timestamps( + newer, + created=now - timedelta(days=1), + modified=now - timedelta(hours=1), + ) + + qs = SeriesFilterSet( + {'modified__gt': (now - timedelta(days=1)).isoformat()}, + queryset=Series.objects.all(), + ).qs + + assert list(qs) == [newer] + + +def test_series_filter_matches_created_range( + country, + language, + publisher, + series_publication_type, +): + """Created range filters support bounded series queries.""" + older = _create_series( + country=country, + language=language, + name='Older Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1980, + ) + newer = _create_series( + country=country, + language=language, + name='Newer Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1990, + ) + now = timezone.now() + older_created = now - timedelta(days=3) + newer_created = now - timedelta(hours=1) + _set_timestamps( + older, + created=older_created, + modified=older_created, + ) + _set_timestamps( + newer, + created=newer_created, + modified=newer_created, + ) + + qs = SeriesFilterSet( + {'created__lte': (now - timedelta(days=1)).isoformat()}, + queryset=Series.objects.all(), + ).qs + + assert list(qs) == [older] diff --git a/apps/api_v2/tests/test_performance/__init__.py b/apps/api_v2/tests/test_performance/__init__.py new file mode 100644 index 000000000..780244b51 --- /dev/null +++ b/apps/api_v2/tests/test_performance/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Performance tests for v2 endpoints.""" diff --git a/apps/api_v2/tests/test_performance/test_issues.py b/apps/api_v2/tests/test_performance/test_issues.py new file mode 100644 index 000000000..8e92147c2 --- /dev/null +++ b/apps/api_v2/tests/test_performance/test_issues.py @@ -0,0 +1,146 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Performance tests for issue endpoints.""" + +from decimal import Decimal + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from apps.gcd.models import Brand, Cover, Story, StoryType + + +def _create_issue( + series, + *, + number, + sort_code, + variant_of=None, + variant_cover_status=3, +): + """Create a minimal issue row for performance tests.""" + return series.issue_set.model.objects.create( + number=number, + title='', + volume='', + isbn='', + valid_isbn='', + variant_name='', + barcode='', + publication_date='', + key_date='2024-01-01', + on_sale_date='2024-01-08', + sort_code=sort_code, + indicia_frequency='', + price='', + editing='Editor One; Editor Two', + notes='', + indicia_printer_sourced_by='', + series=series, + variant_of=variant_of, + variant_cover_status=variant_cover_status, + ) + + +def test_issue_list_query_count(api_client, series): + """The issue list stays on the expected query budget.""" + brand = Brand.objects.create( + name='Test Brand', + year_began=1960, + notes='', + ) + series.is_comics_publication = True + series.save() + first = _create_issue(series, number='1', sort_code=1) + second = _create_issue(series, number='2', sort_code=2) + first.brand_emblem.add(brand) + second.brand_emblem.add(brand) + first.keywords.add('alpha') + second.keywords.add('beta') + Cover.objects.create(issue=first) + Cover.objects.create(issue=second) + + with CaptureQueriesContext(connection) as context: + response = api_client.get(reverse('issue-list')) + + assert response.status_code == 200 + assert len(context) == 9 + + +def test_issue_detail_query_count(api_client, issue): + """The issue detail endpoint avoids lazy-loading regressions.""" + story_type, _ = StoryType.objects.get_or_create( + name='Comic Story', + defaults={'sort_code': 19}, + ) + issue.series.is_comics_publication = True + issue.series.save() + issue.keywords.add('detail') + Cover.objects.create(issue=issue) + story = Story.objects.create( + title='Lead Story', + feature='Feature Text', + sequence_number=1, + page_count=Decimal('10.000'), + script='Writer One; Writer Two', + pencils='Penciler One', + inks='Inker One', + colors='Colorist One', + letters='Letterer One', + editing='Story Editor', + job_number='JOB-1', + genre='superhero', + characters='Batman', + synopsis='Story synopsis', + reprint_notes='', + notes='Story notes', + issue=issue, + type=story_type, + ) + story.keywords.add('story-alpha') + + with CaptureQueriesContext(connection) as context: + response = api_client.get( + reverse('issue-detail', kwargs={'pk': issue.pk}), + ) + + assert response.status_code == 200 + assert len(context) == 10 + + +def test_issue_list_variant_cover_query_count(api_client, series): + """Variant cover fallback avoids per-row cover lookups.""" + series.is_comics_publication = True + series.save() + base = _create_issue(series, number='1', sort_code=1) + first_variant = _create_issue( + series, + number='1/A', + sort_code=2, + variant_of=base, + variant_cover_status=1, + ) + second_variant = _create_issue( + series, + number='1/B', + sort_code=3, + variant_of=base, + variant_cover_status=1, + ) + Cover.objects.create(issue=base) + + with CaptureQueriesContext(connection) as context: + response = api_client.get(reverse('issue-list')) + + assert response.status_code == 200 + assert response.data['count'] == 3 + assert response.data['results'][1]['id'] == first_variant.pk + assert response.data['results'][2]['id'] == second_variant.pk + cover_queries = [ + query['sql'] + for query in context.captured_queries + if 'FROM `gcd_cover`' in query['sql'] + ] + assert len(cover_queries) == 2 diff --git a/apps/api_v2/tests/test_performance/test_publishers.py b/apps/api_v2/tests/test_performance/test_publishers.py new file mode 100644 index 000000000..b76812d6d --- /dev/null +++ b/apps/api_v2/tests/test_performance/test_publishers.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Performance tests for publisher endpoints.""" + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from apps.gcd.models import Publisher + + +def test_publisher_list_query_count(api_client, country): + """The publisher list stays on the expected query budget.""" + first = Publisher.objects.create( + name='Alpha Publisher', + year_began=1940, + notes='', + country=country, + ) + second = Publisher.objects.create( + name='Beta Publisher', + year_began=1950, + notes='', + country=country, + ) + first.keywords.add('alpha') + second.keywords.add('beta') + + with CaptureQueriesContext(connection) as context: + response = api_client.get(reverse('publisher-list')) + + assert response.status_code == 200 + assert len(context) == 4 + + +def test_publisher_detail_query_count(api_client, publisher): + """The publisher detail endpoint avoids lazy-loading regressions.""" + publisher.keywords.add('detail') + + with CaptureQueriesContext(connection) as context: + response = api_client.get( + reverse('publisher-detail', kwargs={'pk': publisher.pk}), + ) + + assert response.status_code == 200 + assert len(context) == 3 diff --git a/apps/api_v2/tests/test_performance/test_series.py b/apps/api_v2/tests/test_performance/test_series.py new file mode 100644 index 000000000..9fa78b23f --- /dev/null +++ b/apps/api_v2/tests/test_performance/test_series.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Performance tests for series endpoints.""" + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from apps.gcd.models import Issue, Series + + +def _create_issue(series, *, sort_code): + """Create a minimal issue row for performance tests.""" + return Issue.objects.create( + number=str(sort_code), + title='', + volume='', + isbn='', + valid_isbn='', + variant_name='', + barcode='', + publication_date='', + key_date='', + on_sale_date='', + sort_code=sort_code, + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=series, + ) + + +def test_series_list_query_count(api_client, publisher, country, language): + """The series list stays on the expected query budget.""" + first = Series.objects.create( + name='Alpha Series', + sort_name='Alpha Series', + year_began=1990, + publication_dates='1990 - present', + notes='', + tracking_notes='', + country=country, + language=language, + publisher=publisher, + ) + second = Series.objects.create( + name='Beta Series', + sort_name='Beta Series', + year_began=1991, + publication_dates='1991 - present', + notes='', + tracking_notes='', + country=country, + language=language, + publisher=publisher, + ) + first.keywords.add('alpha') + second.keywords.add('beta') + _create_issue(first, sort_code=10) + _create_issue(second, sort_code=20) + + with CaptureQueriesContext(connection) as context: + response = api_client.get(reverse('series-list')) + + assert response.status_code == 200 + assert len(context) == 5 + + +def test_series_detail_query_count(api_client, series): + """The series detail endpoint avoids lazy-loading regressions.""" + series.keywords.add('detail') + _create_issue(series, sort_code=10) + + with CaptureQueriesContext(connection) as context: + response = api_client.get( + reverse('series-detail', kwargs={'pk': series.pk}), + ) + + assert response.status_code == 200 + assert len(context) == 4 diff --git a/apps/api_v2/tests/test_serializers/__init__.py b/apps/api_v2/tests/test_serializers/__init__.py new file mode 100644 index 000000000..d2048b25b --- /dev/null +++ b/apps/api_v2/tests/test_serializers/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Serializer tests for v2 endpoints.""" diff --git a/apps/api_v2/tests/test_serializers/test_issues.py b/apps/api_v2/tests/test_serializers/test_issues.py new file mode 100644 index 000000000..78ccb92f7 --- /dev/null +++ b/apps/api_v2/tests/test_serializers/test_issues.py @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the issue serializers.""" + +from decimal import Decimal + +from apps.api_v2.serializers.issues import ( + IssueDetailSerializer, + IssueListSerializer, +) +from apps.gcd.models import Brand, Cover, IndiciaPublisher, Story, StoryType + + +def test_issue_serializers_expose_prd_fields(issue, publisher, country): + """The issue serializers emit the Sprint 1 issue contract.""" + issue.series.is_comics_publication = True + issue.series.save() + indicia_publisher = IndiciaPublisher.objects.create( + name='Test Indicia', + year_began=1960, + notes='', + parent=publisher, + country=country, + ) + brand = Brand.objects.create( + name='Test Brand', + year_began=1960, + notes='', + ) + story_type, _ = StoryType.objects.get_or_create( + name='Comic Story', + defaults={'sort_code': 19}, + ) + issue.title = 'Issue Title' + issue.volume = '2' + issue.variant_name = 'Direct Market' + issue.publication_date = 'January 2024' + issue.key_date = '2024-01-01' + issue.on_sale_date = '2024-01-08' + issue.price = '$4.99' + issue.page_count = Decimal('32.000') + issue.editing = 'Editor One; Editor Two' + issue.indicia_publisher = indicia_publisher + issue.isbn = '1111111111111' + issue.barcode = '12345' + issue.rating = 'Teen' + issue.indicia_frequency = 'Monthly' + issue.notes = 'Issue notes' + issue.save() + issue.brand_emblem.add(brand) + issue.keywords.add('alpha', 'beta') + variant = issue.__class__.objects.create( + number='1/A', + title='', + volume='', + isbn='', + valid_isbn='', + variant_name='', + barcode='', + publication_date='', + key_date='2024-01-02', + on_sale_date='2024-01-09', + sort_code=2, + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=issue.series, + variant_of=issue, + ) + issue.variant_of = variant + issue.save() + story = Story.objects.create( + title='Lead Story', + feature='Feature Text', + sequence_number=1, + page_count=Decimal('10.000'), + script='Writer One; Writer Two', + pencils='Penciler One', + inks='Inker One', + colors='Colorist One', + letters='Letterer One', + editing='Story Editor', + job_number='JOB-1', + genre='superhero', + characters='Batman', + synopsis='Story synopsis', + reprint_notes='', + notes='Story notes', + issue=issue, + type=story_type, + ) + story.keywords.add('story-alpha') + cover = Cover.objects.create(issue=issue) + + list_data = IssueListSerializer(issue).data + detail_data = IssueDetailSerializer(issue).data + + assert set(list_data) == { + 'id', + 'series', + 'number', + 'volume', + 'descriptor', + 'variant_name', + 'title', + 'publication_date', + 'key_date', + 'on_sale_date', + 'price', + 'page_count', + 'editing_credits', + 'indicia_publisher', + 'brand_emblems', + 'isbn', + 'barcode', + 'rating', + 'indicia_frequency', + 'notes', + 'variant_of', + 'keywords', + 'created', + 'modified', + 'cover_url', + } + assert list_data['series'] == { + 'id': issue.series_id, + 'name': issue.series.name, + } + assert list_data['descriptor'] == issue.full_descriptor + assert list_data['editing_credits'] == ['Editor One', 'Editor Two'] + assert list_data['indicia_publisher'] == { + 'id': indicia_publisher.pk, + 'name': indicia_publisher.name, + } + assert list_data['brand_emblems'] == [ + {'id': brand.pk, 'name': brand.name}, + ] + assert list_data['variant_of'] == variant.pk + assert set(list_data['keywords']) == {'alpha', 'beta'} + assert list_data['cover_url'] == ( + f'{cover.get_base_url()}/w400/{cover.id}.jpg' + ) + assert 'stories' not in list_data + + assert 'stories' in detail_data + assert len(detail_data['stories']) == 1 + assert detail_data['stories'][0]['id'] == story.pk + assert detail_data['stories'][0]['type'] == { + 'id': story_type.pk, + 'name': story_type.name, + } + assert detail_data['stories'][0]['script'] == [ + 'Writer One', + 'Writer Two', + ] + assert detail_data['stories'][0]['editing'] == ['Story Editor'] + assert detail_data['stories'][0]['keywords'] == ['story-alpha'] diff --git a/apps/api_v2/tests/test_serializers/test_publishers.py b/apps/api_v2/tests/test_serializers/test_publishers.py new file mode 100644 index 000000000..c6ae589f5 --- /dev/null +++ b/apps/api_v2/tests/test_serializers/test_publishers.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the publisher serializer.""" + +from apps.api_v2.serializers.publishers import PublisherSerializer + + +def test_publisher_serializer_exposes_prd_fields(publisher, country): + """The publisher serializer emits the Sprint 1 publisher contract.""" + publisher.url = 'https://example.com/publishers/test-publisher/' + publisher.notes = 'Publisher notes' + publisher.series_count = 12 + publisher.issue_count = 34 + publisher.brand_count = 5 + publisher.indicia_publisher_count = 2 + publisher.save() + publisher.keywords.add('alpha', 'beta') + + data = PublisherSerializer(publisher).data + + assert set(data) == { + 'id', + 'name', + 'year_began', + 'year_ended', + 'country', + 'url', + 'notes', + 'series_count', + 'issue_count', + 'brand_count', + 'indicia_publisher_count', + 'keywords', + 'created', + 'modified', + } + assert data['id'] == publisher.pk + assert data['name'] == 'Test Publisher' + assert data['country'] == country.code + assert data['url'] == publisher.url + assert data['notes'] == 'Publisher notes' + assert data['series_count'] == 12 + assert data['issue_count'] == 34 + assert data['brand_count'] == 5 + assert data['indicia_publisher_count'] == 2 + assert set(data['keywords']) == {'alpha', 'beta'} + assert data['created'] + assert data['modified'] diff --git a/apps/api_v2/tests/test_serializers/test_series.py b/apps/api_v2/tests/test_serializers/test_series.py new file mode 100644 index 000000000..2a25bece1 --- /dev/null +++ b/apps/api_v2/tests/test_serializers/test_series.py @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the series serializer.""" + +import pytest + +from apps.api_v2.serializers.series import SeriesSerializer +from apps.gcd.models import Issue, SeriesPublicationType + + +@pytest.fixture +def series_publication_type(db): + """Return a publication type for series serializer tests.""" + return SeriesPublicationType.objects.create( + name='Comic Book', + notes='', + ) + + +def _create_issue(series, *, sort_code, deleted=False): + """Create a minimal issue row for serializer tests.""" + return Issue.objects.create( + number=str(sort_code), + title='', + volume='', + isbn='', + valid_isbn='', + variant_name='', + barcode='', + publication_date='', + key_date='', + on_sale_date='', + sort_code=sort_code, + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=series, + deleted=deleted, + ) + + +def test_series_serializer_exposes_prd_fields( + series, + country, + language, + publisher, + series_publication_type, +): + """The series serializer emits the Sprint 1 series contract.""" + series.publication_type = series_publication_type + series.color = 'Color' + series.dimensions = '7.25" x 10.5"' + series.paper_stock = 'Glossy' + series.binding = 'Saddle-stitched' + series.publishing_format = 'Ongoing' + series.notes = 'Series notes' + series.issue_count = 2 + series.save() + first_issue = _create_issue(series, sort_code=20) + second_issue = _create_issue(series, sort_code=10) + _create_issue(series, sort_code=30, deleted=True) + series.keywords.add('alpha', 'beta') + + data = SeriesSerializer(series).data + + assert set(data) == { + 'id', + 'name', + 'sort_name', + 'year_began', + 'year_ended', + 'country', + 'language', + 'publisher', + 'publication_type', + 'color', + 'dimensions', + 'paper_stock', + 'binding', + 'publishing_format', + 'notes', + 'issue_count', + 'active_issue_ids', + 'keywords', + 'created', + 'modified', + } + assert data['id'] == series.pk + assert data['name'] == 'Test Series' + assert data['sort_name'] == 'Test Series' + assert data['country'] == country.code + assert data['language'] == language.code + assert data['publisher'] == { + 'id': publisher.pk, + 'name': publisher.name, + } + assert data['publication_type'] == 'Comic Book' + assert data['color'] == 'Color' + assert data['dimensions'] == '7.25" x 10.5"' + assert data['paper_stock'] == 'Glossy' + assert data['binding'] == 'Saddle-stitched' + assert data['publishing_format'] == 'Ongoing' + assert data['notes'] == 'Series notes' + assert data['issue_count'] == 2 + assert data['active_issue_ids'] == [second_issue.pk, first_issue.pk] + assert set(data['keywords']) == {'alpha', 'beta'} + assert data['created'] + assert data['modified'] diff --git a/apps/api_v2/tests/test_url_dispatch.py b/apps/api_v2/tests/test_url_dispatch.py index cbcc49931..cbfca7725 100644 --- a/apps/api_v2/tests/test_url_dispatch.py +++ b/apps/api_v2/tests/test_url_dispatch.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Tests for the MYCOMICS-gated URL dispatch in ``apps/api_v2/urls.py``. The v2 API mirrors the project's existing two-instance deployment diff --git a/apps/api_v2/tests/test_views/__init__.py b/apps/api_v2/tests/test_views/__init__.py new file mode 100644 index 000000000..c7a548b75 --- /dev/null +++ b/apps/api_v2/tests/test_views/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""View tests for v2 endpoints.""" diff --git a/apps/api_v2/tests/test_views/test_issues.py b/apps/api_v2/tests/test_views/test_issues.py new file mode 100644 index 000000000..b2ac4bd8d --- /dev/null +++ b/apps/api_v2/tests/test_views/test_issues.py @@ -0,0 +1,329 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the issue v2 endpoints.""" + +from decimal import Decimal + +from django.urls import reverse + +from apps.api_v2.views.issues import IssueViewSet +from apps.gcd.models import Brand, Cover, IndiciaPublisher, Story, StoryType + + +def _create_issue( + series, + *, + number, + key_date, + on_sale_date, + sort_code, + isbn='', + barcode='', + deleted=False, +): + """Create a minimal issue row for view tests.""" + return series.issue_set.model.objects.create( + number=number, + title='', + volume='', + isbn=isbn, + valid_isbn='', + variant_name='', + barcode=barcode, + publication_date='', + key_date=key_date, + on_sale_date=on_sale_date, + sort_code=sort_code, + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=series, + deleted=deleted, + ) + + +def test_issue_list_returns_paginated_results( + api_client, issue, publisher, country +): + """The list endpoint is anon-readable and paginated.""" + issue.series.is_comics_publication = True + issue.series.save() + indicia_publisher = IndiciaPublisher.objects.create( + name='Test Indicia', + year_began=1960, + notes='', + parent=publisher, + country=country, + ) + brand = Brand.objects.create( + name='Test Brand', + year_began=1960, + notes='', + ) + issue.title = 'Issue Title' + issue.key_date = '2024-01-01' + issue.on_sale_date = '2024-01-08' + issue.editing = 'Editor One; Editor Two' + issue.indicia_publisher = indicia_publisher + issue.save() + issue.brand_emblem.add(brand) + issue.keywords.add('alpha') + cover = Cover.objects.create(issue=issue) + + response = api_client.get(reverse('issue-list')) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['next'] is None + assert response.data['previous'] is None + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == issue.pk + assert response.data['results'][0]['series'] == { + 'id': issue.series_id, + 'name': issue.series.name, + } + assert response.data['results'][0]['editing_credits'] == [ + 'Editor One', + 'Editor Two', + ] + assert response.data['results'][0]['cover_url'] == ( + f'{cover.get_base_url()}/w400/{cover.id}.jpg' + ) + assert 'stories' not in response.data['results'][0] + + +def test_issue_detail_returns_expected_payload( + api_client, issue, publisher, country +): + """The detail endpoint returns the issue serializer payload.""" + issue.series.is_comics_publication = True + issue.series.save() + indicia_publisher = IndiciaPublisher.objects.create( + name='Test Indicia', + year_began=1960, + notes='', + parent=publisher, + country=country, + ) + brand = Brand.objects.create( + name='Test Brand', + year_began=1960, + notes='', + ) + story_type, _ = StoryType.objects.get_or_create( + name='Comic Story', + defaults={'sort_code': 19}, + ) + issue.title = 'Issue Title' + issue.volume = '2' + issue.variant_name = 'Direct Market' + issue.publication_date = 'January 2024' + issue.key_date = '2024-01-01' + issue.on_sale_date = '2024-01-08' + issue.price = '$4.99' + issue.page_count = Decimal('32.000') + issue.editing = 'Editor One; Editor Two' + issue.indicia_publisher = indicia_publisher + issue.isbn = '1111111111111' + issue.barcode = '12345' + issue.rating = 'Teen' + issue.indicia_frequency = 'Monthly' + issue.notes = 'Issue notes' + issue.save() + issue.brand_emblem.add(brand) + issue.keywords.add('alpha', 'beta') + story = Story.objects.create( + title='Lead Story', + feature='Feature Text', + sequence_number=1, + page_count=Decimal('10.000'), + script='Writer One; Writer Two', + pencils='Penciler One', + inks='Inker One', + colors='Colorist One', + letters='Letterer One', + editing='Story Editor', + job_number='JOB-1', + genre='superhero', + characters='Batman', + synopsis='Story synopsis', + reprint_notes='', + notes='Story notes', + issue=issue, + type=story_type, + ) + story.keywords.add('story-alpha') + + response = api_client.get( + reverse('issue-detail', kwargs={'pk': issue.pk}), + ) + + assert response.status_code == 200 + assert response.data['id'] == issue.pk + assert response.data['series'] == { + 'id': issue.series_id, + 'name': issue.series.name, + } + assert response.data['editing_credits'] == ['Editor One', 'Editor Two'] + assert response.data['brand_emblems'] == [ + {'id': brand.pk, 'name': brand.name}, + ] + assert len(response.data['stories']) == 1 + assert response.data['stories'][0]['id'] == story.pk + assert response.data['stories'][0]['type'] == { + 'id': story_type.pk, + 'name': story_type.name, + } + + +def test_issue_list_applies_filter_query_params(api_client, issue, publisher): + """The list endpoint applies django-filter query params.""" + issue.key_date = '2024-01-01' + issue.on_sale_date = '2024-01-08' + issue.isbn = '1111111111111' + issue.barcode = '12345' + issue.save() + other_series = issue.series.__class__.objects.create( + name='Other Series', + sort_name='Other Series', + year_began=1991, + publication_dates='1991 - present', + notes='', + tracking_notes='', + country=issue.series.country, + language=issue.series.language, + publisher=publisher, + ) + _create_issue( + other_series, + number='1', + key_date='2024-01-01', + on_sale_date='2024-01-08', + sort_code=1, + isbn='1111111111111', + barcode='12345', + ) + + response = api_client.get( + reverse('issue-list'), + { + 'series': str(issue.series.pk), + 'number': '1', + 'isbn': '1111111111111', + 'barcode': '12345', + }, + ) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == issue.pk + + +def test_issue_list_uses_variant_base_cover_url(api_client, issue): + """Variant issues reuse the base issue cover when configured to do so.""" + issue.series.is_comics_publication = True + issue.series.save() + base = issue + base.key_date = '2024-01-01' + base.on_sale_date = '2024-01-08' + base.save() + cover = Cover.objects.create(issue=base) + variant = _create_issue( + issue.series, + number='1/A', + key_date='2024-01-02', + on_sale_date='2024-01-09', + sort_code=2, + ) + variant.variant_of = base + variant.variant_cover_status = 1 + variant.save() + + response = api_client.get(reverse('issue-list')) + + assert response.status_code == 200 + assert response.data['count'] == 2 + assert response.data['results'][1]['id'] == variant.pk + assert response.data['results'][1]['cover_url'] == ( + f'{cover.get_base_url()}/w400/{cover.id}.jpg' + ) + + +def test_issue_list_queryset_uses_id_based_series_ordering(): + """The list queryset avoids the costly related-series default sort.""" + assert IssueViewSet.queryset.query.order_by == ( + 'series_id', + 'sort_code', + 'id', + ) + + +def test_issue_endpoints_hide_soft_deleted_records(api_client, issue): + """Soft-deleted issues disappear from list and detail responses.""" + visible = issue + visible.key_date = '2024-01-01' + visible.on_sale_date = '2024-01-08' + visible.save() + deleted = _create_issue( + issue.series, + number='2', + key_date='2024-01-02', + on_sale_date='2024-01-09', + sort_code=2, + deleted=True, + ) + + list_response = api_client.get(reverse('issue-list')) + detail_response = api_client.get( + reverse('issue-detail', kwargs={'pk': deleted.pk}), + ) + + assert list_response.status_code == 200 + assert list_response.data['count'] == 1 + assert list_response.data['results'][0]['id'] == visible.pk + assert detail_response.status_code == 404 + + +def test_issue_list_returns_304_for_if_modified_since( + authenticated_client, + issue, +): + """List responses support Last-Modified cache validation.""" + response = authenticated_client.get(reverse('issue-list')) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('issue-list'), + HTTP_IF_MODIFIED_SINCE=response['Last-Modified'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' + + +def test_issue_detail_returns_304_for_if_none_match( + authenticated_client, + issue, +): + """Detail responses support ETag cache validation.""" + response = authenticated_client.get( + reverse('issue-detail', kwargs={'pk': issue.pk}), + ) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('issue-detail', kwargs={'pk': issue.pk}), + HTTP_IF_NONE_MATCH=response['ETag'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' diff --git a/apps/api_v2/tests/test_views/test_publishers.py b/apps/api_v2/tests/test_views/test_publishers.py new file mode 100644 index 000000000..4747dd529 --- /dev/null +++ b/apps/api_v2/tests/test_views/test_publishers.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the publisher v2 endpoints.""" + +import pytest +from django.urls import reverse + +from apps.gcd.models import Publisher +from apps.stddata.models import Country + + +@pytest.fixture +def other_country(db): + """Return a second country for publisher view tests.""" + obj, _ = Country.objects.get_or_create( + code='yy', + defaults={'name': 'Other Country'}, + ) + return obj + + +def test_publisher_list_returns_paginated_results(api_client, publisher): + """The list endpoint is anon-readable and paginated.""" + publisher.keywords.add('alpha') + + response = api_client.get(reverse('publisher-list')) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['next'] is None + assert response.data['previous'] is None + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == publisher.pk + assert response.data['results'][0]['country'] == publisher.country.code + + +def test_publisher_detail_returns_expected_payload(api_client, publisher): + """The detail endpoint returns the publisher serializer payload.""" + publisher.notes = 'Detail notes' + publisher.series_count = 7 + publisher.issue_count = 21 + publisher.brand_count = 3 + publisher.indicia_publisher_count = 1 + publisher.save() + publisher.keywords.add('alpha', 'beta') + + response = api_client.get( + reverse('publisher-detail', kwargs={'pk': publisher.pk}), + ) + + assert response.status_code == 200 + assert response.data['id'] == publisher.pk + assert response.data['name'] == publisher.name + assert response.data['country'] == publisher.country.code + assert response.data['series_count'] == 7 + assert response.data['issue_count'] == 21 + assert response.data['brand_count'] == 3 + assert response.data['indicia_publisher_count'] == 1 + assert set(response.data['keywords']) == {'alpha', 'beta'} + + +def test_publisher_list_applies_filter_query_params( + api_client, + country, + other_country, +): + """The list endpoint applies django-filter query params.""" + matching = Publisher.objects.create( + name='Marvel Comics', + year_began=1939, + notes='', + country=country, + ) + Publisher.objects.create( + name='DC Comics', + year_began=1934, + notes='', + country=other_country, + ) + + response = api_client.get( + reverse('publisher-list'), + {'name': 'marvel', 'country': country.code}, + ) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == matching.pk + + +def test_publisher_endpoints_hide_soft_deleted_records(api_client, country): + """Soft-deleted publishers disappear from list and detail responses.""" + visible = Publisher.objects.create( + name='Visible Publisher', + year_began=1940, + notes='', + country=country, + ) + deleted = Publisher.objects.create( + name='Deleted Publisher', + year_began=1950, + notes='', + country=country, + deleted=True, + ) + + list_response = api_client.get(reverse('publisher-list')) + detail_response = api_client.get( + reverse('publisher-detail', kwargs={'pk': deleted.pk}), + ) + + assert list_response.status_code == 200 + assert list_response.data['count'] == 1 + assert list_response.data['results'][0]['id'] == visible.pk + assert detail_response.status_code == 404 + + +def test_publisher_list_returns_304_for_if_modified_since( + authenticated_client, + publisher, +): + """List responses support Last-Modified cache validation.""" + response = authenticated_client.get(reverse('publisher-list')) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('publisher-list'), + HTTP_IF_MODIFIED_SINCE=response['Last-Modified'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' + + +def test_publisher_detail_returns_304_for_if_none_match( + authenticated_client, + publisher, +): + """Detail responses support ETag cache validation.""" + response = authenticated_client.get( + reverse('publisher-detail', kwargs={'pk': publisher.pk}), + ) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('publisher-detail', kwargs={'pk': publisher.pk}), + HTTP_IF_NONE_MATCH=response['ETag'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' diff --git a/apps/api_v2/tests/test_views/test_series.py b/apps/api_v2/tests/test_views/test_series.py new file mode 100644 index 000000000..39123d9dc --- /dev/null +++ b/apps/api_v2/tests/test_views/test_series.py @@ -0,0 +1,326 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Tests for the series v2 endpoints.""" + +import pytest +from django.urls import reverse + +from apps.gcd.models import Issue, Publisher, Series, SeriesPublicationType +from apps.stddata.models import Country, Language + + +@pytest.fixture +def other_country(db): + """Return a second country for series view tests.""" + obj, _ = Country.objects.get_or_create( + code='yy', + defaults={'name': 'Other Country'}, + ) + return obj + + +@pytest.fixture +def other_language(db): + """Return a second language for series view tests.""" + obj, _ = Language.objects.get_or_create( + code='yy', + defaults={'name': 'Other Language'}, + ) + return obj + + +@pytest.fixture +def series_publication_type(db): + """Return a publication type for series view tests.""" + return SeriesPublicationType.objects.create( + name='Comic Book', + notes='', + ) + + +def _create_issue(series, *, sort_code, deleted=False): + """Create a minimal issue row for view tests.""" + return Issue.objects.create( + number=str(sort_code), + title='', + volume='', + isbn='', + valid_isbn='', + variant_name='', + barcode='', + publication_date='', + key_date='', + on_sale_date='', + sort_code=sort_code, + indicia_frequency='', + price='', + editing='', + notes='', + indicia_printer_sourced_by='', + series=series, + deleted=deleted, + ) + + +def _create_series( + *, + country, + language, + name, + publication_type, + publisher, + year_began, + year_ended=None, + deleted=False, +): + """Create a minimal series row for view tests.""" + return Series.objects.create( + name=name, + sort_name=name, + year_began=year_began, + year_ended=year_ended, + publication_dates='1990 - present', + notes='', + tracking_notes='', + country=country, + language=language, + publisher=publisher, + publication_type=publication_type, + deleted=deleted, + ) + + +def test_series_list_returns_paginated_results( + api_client, + series, + series_publication_type, +): + """The list endpoint is anon-readable and paginated.""" + series.publication_type = series_publication_type + series.issue_count = 2 + series.save() + first_issue = _create_issue(series, sort_code=20) + second_issue = _create_issue(series, sort_code=10) + _create_issue(series, sort_code=30, deleted=True) + series.keywords.add('alpha') + + response = api_client.get(reverse('series-list')) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['next'] is None + assert response.data['previous'] is None + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == series.pk + assert response.data['results'][0]['country'] == series.country.code + assert response.data['results'][0]['language'] == series.language.code + assert response.data['results'][0]['publisher'] == { + 'id': series.publisher_id, + 'name': series.publisher.name, + } + assert response.data['results'][0]['publication_type'] == 'Comic Book' + assert response.data['results'][0]['active_issue_ids'] == [ + second_issue.pk, + first_issue.pk, + ] + + +def test_series_detail_returns_expected_payload( + api_client, + series, + series_publication_type, +): + """The detail endpoint returns the series serializer payload.""" + series.publication_type = series_publication_type + series.color = 'Color' + series.dimensions = '7.25" x 10.5"' + series.paper_stock = 'Glossy' + series.binding = 'Saddle-stitched' + series.publishing_format = 'Ongoing' + series.notes = 'Detail notes' + series.issue_count = 2 + series.save() + first_issue = _create_issue(series, sort_code=20) + second_issue = _create_issue(series, sort_code=10) + series.keywords.add('alpha', 'beta') + + response = api_client.get( + reverse('series-detail', kwargs={'pk': series.pk}), + ) + + assert response.status_code == 200 + assert response.data['id'] == series.pk + assert response.data['name'] == series.name + assert response.data['publisher'] == { + 'id': series.publisher_id, + 'name': series.publisher.name, + } + assert response.data['publication_type'] == 'Comic Book' + assert response.data['color'] == 'Color' + assert response.data['dimensions'] == '7.25" x 10.5"' + assert response.data['paper_stock'] == 'Glossy' + assert response.data['binding'] == 'Saddle-stitched' + assert response.data['publishing_format'] == 'Ongoing' + assert response.data['issue_count'] == 2 + assert response.data['active_issue_ids'] == [ + second_issue.pk, + first_issue.pk, + ] + assert set(response.data['keywords']) == {'alpha', 'beta'} + + +def test_series_list_applies_filter_query_params( + api_client, + country, + other_country, + language, + other_language, + publisher, + series_publication_type, +): + """The list endpoint applies django-filter query params.""" + other_publisher = Publisher.objects.create( + name='Other Publisher', + year_began=1940, + notes='', + country=country, + ) + other_publication_type = SeriesPublicationType.objects.create( + name='Magazine', + notes='', + ) + matching = _create_series( + country=country, + language=language, + name='Batman Adventures', + publication_type=series_publication_type, + publisher=publisher, + year_began=1992, + ) + _create_series( + country=other_country, + language=language, + name='Batman Adventures', + publication_type=series_publication_type, + publisher=publisher, + year_began=1992, + ) + _create_series( + country=country, + language=other_language, + name='Batman Adventures', + publication_type=series_publication_type, + publisher=publisher, + year_began=1992, + ) + _create_series( + country=country, + language=language, + name='Batman Adventures', + publication_type=series_publication_type, + publisher=other_publisher, + year_began=1992, + ) + _create_series( + country=country, + language=language, + name='Batman Adventures', + publication_type=other_publication_type, + publisher=publisher, + year_began=1992, + ) + + response = api_client.get( + reverse('series-list'), + { + 'name': 'batman', + 'country': country.code, + 'language': language.code, + 'publisher': str(publisher.pk), + 'publication_type': str(series_publication_type.pk), + }, + ) + + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == matching.pk + + +def test_series_endpoints_hide_soft_deleted_records( + api_client, + country, + language, + publisher, + series_publication_type, +): + """Soft-deleted series disappear from list and detail responses.""" + visible = _create_series( + country=country, + language=language, + name='Visible Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1990, + ) + deleted = _create_series( + country=country, + language=language, + name='Deleted Series', + publication_type=series_publication_type, + publisher=publisher, + year_began=1991, + deleted=True, + ) + + list_response = api_client.get(reverse('series-list')) + detail_response = api_client.get( + reverse('series-detail', kwargs={'pk': deleted.pk}), + ) + + assert list_response.status_code == 200 + assert list_response.data['count'] == 1 + assert list_response.data['results'][0]['id'] == visible.pk + assert detail_response.status_code == 404 + + +def test_series_list_returns_304_for_if_modified_since( + authenticated_client, + series, +): + """List responses support Last-Modified cache validation.""" + response = authenticated_client.get(reverse('series-list')) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('series-list'), + HTTP_IF_MODIFIED_SINCE=response['Last-Modified'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' + + +def test_series_detail_returns_304_for_if_none_match( + authenticated_client, + series, +): + """Detail responses support ETag cache validation.""" + response = authenticated_client.get( + reverse('series-detail', kwargs={'pk': series.pk}), + ) + + assert response.status_code == 200 + assert 'Last-Modified' in response + assert 'ETag' in response + + cached_response = authenticated_client.get( + reverse('series-detail', kwargs={'pk': series.pk}), + HTTP_IF_NONE_MATCH=response['ETag'], + ) + + assert cached_response.status_code == 304 + assert cached_response.content == b'' diff --git a/apps/api_v2/throttling.py b/apps/api_v2/throttling.py index 94c1963e4..637c9a064 100644 --- a/apps/api_v2/throttling.py +++ b/apps/api_v2/throttling.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Throttle classes for the v2 API. Three buckets per the PRD: one for anonymous traffic, one for users diff --git a/apps/api_v2/urls.py b/apps/api_v2/urls.py index 39ed68d10..43e38aca2 100644 --- a/apps/api_v2/urls.py +++ b/apps/api_v2/urls.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """URL dispatcher for the v2 API. The v2 API mirrors the project's existing two-instance deployment diff --git a/apps/api_v2/urls_my.py b/apps/api_v2/urls_my.py index 287d7d944..b5a76c984 100644 --- a/apps/api_v2/urls_my.py +++ b/apps/api_v2/urls_my.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """URL configuration for the user-data (my) v2 API surface. Mounted on the ``my.comics.org`` instance (``MYCOMICS=True``). Holds diff --git a/apps/api_v2/urls_www.py b/apps/api_v2/urls_www.py index 008b07bad..53b73605b 100644 --- a/apps/api_v2/urls_www.py +++ b/apps/api_v2/urls_www.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """URL configuration for the public-data (www) v2 API surface. Mounted on the ``www.comics.org`` instance (``MYCOMICS=False``). Holds @@ -9,8 +12,14 @@ from django.urls import include, path from apps.api_v2.routers import V2APIRouter +from apps.api_v2.views.issues import IssueViewSet +from apps.api_v2.views.publishers import PublisherViewSet +from apps.api_v2.views.series import SeriesViewSet router = V2APIRouter() +router.register('issues', IssueViewSet, basename='issue') +router.register('publishers', PublisherViewSet, basename='publisher') +router.register('series', SeriesViewSet, basename='series') urlpatterns = [ path('', include(router.urls)), diff --git a/apps/api_v2/utils/__init__.py b/apps/api_v2/utils/__init__.py index e69de29bb..baa66ed4e 100644 --- a/apps/api_v2/utils/__init__.py +++ b/apps/api_v2/utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only diff --git a/apps/api_v2/utils/conditional.py b/apps/api_v2/utils/conditional.py index 32c55c4ea..c10ec66aa 100644 --- a/apps/api_v2/utils/conditional.py +++ b/apps/api_v2/utils/conditional.py @@ -1,14 +1,37 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """Conditional request helpers for the v2 API. -Re-exports ``last_modified`` and ``etag`` from +Re-exports the DRF-aware conditional decorators from ``rest_framework_condition`` so v2 viewsets have a single import point, -and provides ``make_last_modified``, a factory that builds the -per-model callable described in the PRD: one query against ``modified``, -no serialization, no full queryset evaluation. +and provides factories that build the per-model callables described in +the PRD: one query against ``modified``, no serialization, no full +queryset evaluation. """ -from django.db.models import Max +import hashlib + from rest_framework_condition import etag, last_modified +from rest_framework_condition.decorators import condition + + +def _cache_key(model, request, pk): + """Return a stable request-local cache key for conditional metadata.""" + return f'{model._meta.label_lower}:{request.get_full_path()}:{pk}' + + +def _request_cache(request): + """Return a request-local cache dict, or ``None`` for simple sentinels.""" + if not hasattr(request, '__dict__') or not hasattr( + request, 'get_full_path' + ): + return None + cache = getattr(request, '_gcd_v2_conditional_cache', None) + if cache is None: + cache = {} + request._gcd_v2_conditional_cache = cache + return cache def make_last_modified(model, *, soft_delete=True, queryset_getter=None): @@ -29,6 +52,10 @@ def make_last_modified(model, *, soft_delete=True, queryset_getter=None): """ def _last_modified(request, pk=None, **kwargs): + cache = _request_cache(request) + key = None if cache is None else _cache_key(model, request, pk) + if key is not None and key in cache: + return cache[key] if queryset_getter is None: qs = model._default_manager.all() else: @@ -36,10 +63,55 @@ def _last_modified(request, pk=None, **kwargs): if soft_delete: qs = qs.filter(deleted=False) if pk is not None: - return qs.filter(pk=pk).values_list('modified', flat=True).first() - return qs.aggregate(latest=Max('modified'))['latest'] + latest = ( + qs.filter(pk=pk).values_list('modified', flat=True).first() + ) + else: + # Use the indexed modified column directly instead of an + # aggregate scan so broad list endpoints stay cheap on the + # production-sized tables. + latest = ( + qs.order_by('-modified') + .values_list('modified', flat=True) + .first() + ) + if key is not None: + cache[key] = latest + return latest return _last_modified -__all__ = ['etag', 'last_modified', 'make_last_modified'] +def make_etag(model, *, soft_delete=True, queryset_getter=None): + """Return an ``etag(request, pk=None, **kwargs)`` callable. + + The ETag is derived from the request path plus the latest modified + timestamp for the requested resource. Detail requests return + ``None`` when the row is absent so the wrapped view can still 404. + Empty list result sets still receive a stable ETag so clients can + cache the empty response body. + """ + last_modified_getter = make_last_modified( + model, + soft_delete=soft_delete, + queryset_getter=queryset_getter, + ) + + def _etag(request, pk=None, **kwargs): + latest = last_modified_getter(request, pk=pk, **kwargs) + if pk is not None and latest is None: + return None + latest_repr = 'empty' if latest is None else latest.isoformat() + payload = f'{request.get_full_path()}::{latest_repr}' + return hashlib.sha256(payload.encode('utf-8')).hexdigest() + + return _etag + + +__all__ = [ + 'condition', + 'etag', + 'last_modified', + 'make_etag', + 'make_last_modified', +] diff --git a/apps/api_v2/utils/credits.py b/apps/api_v2/utils/credits.py new file mode 100644 index 000000000..d952926c5 --- /dev/null +++ b/apps/api_v2/utils/credits.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Plain-text credit helpers for the v2 API.""" + +from apps.gcd.models import CREDIT_TYPES + + +def _split_legacy_values(value): + """Return semicolon-delimited legacy credit text as a clean list.""" + if not value: + return [] + return [part.strip() for part in value.split(';') if part.strip()] + + +def collect_credit_strings(obj, credit_type, *, prefetched_attr): + """Return plain-text credits for ``credit_type`` on ``obj``. + + v2 avoids the old template helpers and returns JSON-safe plain text. + Normalized credit rows are preferred when present; the legacy raw text + field is appended so older data remains visible. + """ + credit_type_id = CREDIT_TYPES[credit_type] + credits = getattr(obj, prefetched_attr, None) + if credits is None: + credits = obj.credits.filter(deleted=False).select_related( + 'creator', + 'credit_type', + ) + + entries = [ + credit.creator.name + for credit in credits + if credit.credit_type_id == credit_type_id and not credit.deleted + ] + entries.extend(_split_legacy_values(getattr(obj, credit_type, ''))) + return entries diff --git a/apps/api_v2/utils/spectacular.py b/apps/api_v2/utils/spectacular.py index 3e3a60485..93865ba76 100644 --- a/apps/api_v2/utils/spectacular.py +++ b/apps/api_v2/utils/spectacular.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """drf-spectacular helpers for the v2 API.""" diff --git a/apps/api_v2/views/__init__.py b/apps/api_v2/views/__init__.py index 08b0cba10..72cdc8ff7 100644 --- a/apps/api_v2/views/__init__.py +++ b/apps/api_v2/views/__init__.py @@ -1,5 +1,10 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + """View base classes for the v2 API.""" +from django.db import transaction +from django.utils.decorators import method_decorator from rest_framework.authentication import ( BasicAuthentication, SessionAuthentication, @@ -16,6 +21,7 @@ ) +@method_decorator(transaction.non_atomic_requests, name='dispatch') class GCDBaseViewSet(ReadOnlyModelViewSet): """Read-only viewset that hides soft-deleted records. diff --git a/apps/api_v2/views/issues.py b/apps/api_v2/views/issues.py new file mode 100644 index 000000000..79f921508 --- /dev/null +++ b/apps/api_v2/views/issues.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Viewsets for v2 issue endpoints.""" + +from django.db.models import Prefetch +from django_filters.rest_framework import DjangoFilterBackend + +from apps.api_v2.filters.issues import IssueFilterSet +from apps.api_v2.serializers.issues import ( + IssueDetailSerializer, + IssueListSerializer, +) +from apps.api_v2.utils.conditional import ( + condition, + make_etag, + make_last_modified, +) +from apps.api_v2.views import GCDBaseViewSet +from apps.gcd.models import Cover, Issue, IssueCredit, Story, StoryCredit + + +def _issue_filter_queryset(request, *, pk=None, **kwargs): + """Return the issue queryset scoped by request query params.""" + del pk, kwargs + return IssueFilterSet( + request.GET, + queryset=Issue.objects.all(), + ).qs + + +issue_last_modified = make_last_modified( + Issue, + queryset_getter=_issue_filter_queryset, +) +issue_etag = make_etag( + Issue, + queryset_getter=_issue_filter_queryset, +) + +ACTIVE_ISSUE_CREDIT_PREFETCH = Prefetch( + 'credits', + queryset=IssueCredit.objects.filter(deleted=False).select_related( + 'creator', + 'credit_type', + ), + to_attr='active_credit_list', +) +ACTIVE_COVER_PREFETCH = Prefetch( + 'cover_set', + queryset=Cover.objects.filter(deleted=False).order_by('id'), + to_attr='active_cover_list', +) +ACTIVE_VARIANT_COVER_PREFETCH = Prefetch( + 'variant_of__cover_set', + queryset=Cover.objects.filter(deleted=False).order_by('id'), + to_attr='active_cover_list', +) +ACTIVE_STORY_CREDIT_PREFETCH = Prefetch( + 'credits', + queryset=StoryCredit.objects.filter(deleted=False).select_related( + 'creator', + 'credit_type', + ), + to_attr='active_credit_list', +) +ACTIVE_STORY_PREFETCH = Prefetch( + 'story_set', + queryset=Story.objects.filter(deleted=False) + .select_related('type') + .prefetch_related( + 'keywords', + ACTIVE_STORY_CREDIT_PREFETCH, + ) + .order_by('sequence_number', 'id'), + to_attr='active_story_list', +) + + +class IssueViewSet(GCDBaseViewSet): + """Read-only issue endpoints for the public v2 API surface.""" + + queryset = ( + Issue.objects.select_related( + 'series', + 'indicia_publisher', + 'variant_of', + ) + .prefetch_related( + 'brand_emblem', + 'keywords', + ACTIVE_ISSUE_CREDIT_PREFETCH, + ACTIVE_COVER_PREFETCH, + ACTIVE_VARIANT_COVER_PREFETCH, + ) + .order_by( + # ``Issue.Meta.ordering`` uses the related Series default + # ordering, which forces a wide join-based sort on large tables. + 'series_id', + 'sort_code', + 'id', + ) + ) + filter_backends = (DjangoFilterBackend,) + filterset_class = IssueFilterSet + + def get_queryset(self): + """Add the nested story prefetch for detail requests only.""" + queryset = super().get_queryset() + if self.action == 'retrieve': + queryset = queryset.prefetch_related(ACTIVE_STORY_PREFETCH) + return queryset + + def get_serializer_class(self): + """Switch to the detail serializer for retrieve requests.""" + if self.action == 'retrieve': + return IssueDetailSerializer + return IssueListSerializer + + @condition( + etag_func=issue_etag, + last_modified_func=issue_last_modified, + ) + def list(self, request, *args, **kwargs): + """Return a filtered, paginated issue collection.""" + return super().list(request, *args, **kwargs) + + @condition( + etag_func=issue_etag, + last_modified_func=issue_last_modified, + ) + def retrieve(self, request, *args, **kwargs): + """Return a single issue detail record.""" + return super().retrieve(request, *args, **kwargs) diff --git a/apps/api_v2/views/publishers.py b/apps/api_v2/views/publishers.py new file mode 100644 index 000000000..3e4f5e39f --- /dev/null +++ b/apps/api_v2/views/publishers.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Viewsets for v2 publisher endpoints.""" + +from django_filters.rest_framework import DjangoFilterBackend + +from apps.api_v2.filters.publishers import PublisherFilterSet +from apps.api_v2.serializers.publishers import PublisherSerializer +from apps.api_v2.utils.conditional import ( + condition, + make_etag, + make_last_modified, +) +from apps.api_v2.views import GCDBaseViewSet +from apps.gcd.models import Publisher + + +def _publisher_filter_queryset(request, *, pk=None, **kwargs): + """Return the publisher queryset scoped by request query params.""" + del pk, kwargs + return PublisherFilterSet( + request.GET, + queryset=Publisher.objects.all(), + ).qs + + +publisher_last_modified = make_last_modified( + Publisher, + queryset_getter=_publisher_filter_queryset, +) +publisher_etag = make_etag( + Publisher, + queryset_getter=_publisher_filter_queryset, +) + + +class PublisherViewSet(GCDBaseViewSet): + """Read-only publisher endpoints for the public v2 API surface.""" + + queryset = Publisher.objects.select_related('country').prefetch_related( + 'keywords', + ) + serializer_class = PublisherSerializer + filter_backends = (DjangoFilterBackend,) + filterset_class = PublisherFilterSet + + @condition( + etag_func=publisher_etag, + last_modified_func=publisher_last_modified, + ) + def list(self, request, *args, **kwargs): + """Return a filtered, paginated publisher collection.""" + return super().list(request, *args, **kwargs) + + @condition( + etag_func=publisher_etag, + last_modified_func=publisher_last_modified, + ) + def retrieve(self, request, *args, **kwargs): + """Return a single publisher detail record.""" + return super().retrieve(request, *args, **kwargs) diff --git a/apps/api_v2/views/series.py b/apps/api_v2/views/series.py new file mode 100644 index 000000000..49aff6025 --- /dev/null +++ b/apps/api_v2/views/series.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Grand Comics Database contributors +# SPDX-License-Identifier: GPL-3.0-only + +"""Viewsets for v2 series endpoints.""" + +from django.db.models import Prefetch +from django_filters.rest_framework import DjangoFilterBackend + +from apps.api_v2.filters.series import SeriesFilterSet +from apps.api_v2.serializers.series import SeriesSerializer +from apps.api_v2.utils.conditional import ( + condition, + make_etag, + make_last_modified, +) +from apps.api_v2.views import GCDBaseViewSet +from apps.gcd.models import Issue, Series + + +def _series_filter_queryset(request, *, pk=None, **kwargs): + """Return the series queryset scoped by request query params.""" + del pk, kwargs + return SeriesFilterSet( + request.GET, + queryset=Series.objects.all(), + ).qs + + +series_last_modified = make_last_modified( + Series, + queryset_getter=_series_filter_queryset, +) +series_etag = make_etag( + Series, + queryset_getter=_series_filter_queryset, +) + +ACTIVE_ISSUE_PREFETCH = Prefetch( + 'issue_set', + queryset=Issue.objects.filter(deleted=False) + .only('id', 'series_id', 'sort_code') + .order_by('sort_code', 'id'), + to_attr='active_issue_list', +) + + +class SeriesViewSet(GCDBaseViewSet): + """Read-only series endpoints for the public v2 API surface.""" + + queryset = ( + Series.objects.select_related( + 'country', + 'language', + 'publisher', + 'publication_type', + ) + .only( + 'id', + 'created', + 'modified', + 'deleted', + 'name', + 'sort_name', + 'year_began', + 'year_ended', + 'color', + 'dimensions', + 'paper_stock', + 'binding', + 'publishing_format', + 'notes', + 'issue_count', + 'country_id', + 'language_id', + 'publisher_id', + 'publication_type_id', + 'country__id', + 'country__code', + 'language__id', + 'language__code', + 'publisher__id', + 'publisher__name', + 'publication_type__id', + 'publication_type__name', + ) + .prefetch_related( + 'keywords', + ACTIVE_ISSUE_PREFETCH, + ) + ) + serializer_class = SeriesSerializer + filter_backends = (DjangoFilterBackend,) + filterset_class = SeriesFilterSet + + @condition( + etag_func=series_etag, + last_modified_func=series_last_modified, + ) + def list(self, request, *args, **kwargs): + """Return a filtered, paginated series collection.""" + return super().list(request, *args, **kwargs) + + @condition( + etag_func=series_etag, + last_modified_func=series_last_modified, + ) + def retrieve(self, request, *args, **kwargs): + """Return a single series detail record.""" + return super().retrieve(request, *args, **kwargs)