• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mangadventure / MangAdventure / 7138272196

08 Dec 2023 06:39AM UTC coverage: 95.55% (-0.001%) from 95.551%
7138272196

push

github

ObserverOfTime
Ensure scheduled chapters don't show up

2 of 4 new or added lines in 2 files covered. (50.0%)

9 existing lines in 2 files now uncovered.

4724 of 4944 relevant lines covered (95.55%)

2.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.18
/reader/api.py
1
"""API viewsets for the reader app."""
2

3
from __future__ import annotations
3✔
4

5
from typing import TYPE_CHECKING, Type
3✔
6
from warnings import filterwarnings
3✔
7

8
from django.db.models import Count, F, Max, Prefetch, Q, Sum
3✔
9
from django.utils import timezone as tz
3✔
10
from django.utils.decorators import method_decorator
3✔
11
from django.views.decorators.cache import cache_control
3✔
12

13
from rest_framework.decorators import action
3✔
14
from rest_framework.exceptions import APIException, NotFound, ParseError
3✔
15
from rest_framework.mixins import (
3✔
16
    CreateModelMixin, DestroyModelMixin, ListModelMixin,
17
    RetrieveModelMixin, UpdateModelMixin
18
)
19
from rest_framework.parsers import MultiPartParser
3✔
20
from rest_framework.response import Response
3✔
21
from rest_framework.viewsets import GenericViewSet, ModelViewSet
3✔
22

23
from api.v2.mixins import METHODS, CORSMixin
3✔
24
from api.v2.pagination import DummyPagination, PageLimitPagination
3✔
25
from api.v2.schema import OpenAPISchema
3✔
26
from groups.models import Group
3✔
27

28
from . import filters, models, serializers
3✔
29

30
if TYPE_CHECKING:  # pragma: no cover
31
    from django.db.models.query import QuerySet  # isort:skip
32
    from rest_framework.request import Request  # isort:skip
33

34
# XXX: We are overriding the "Series" schema on purpose.
35
filterwarnings('ignore', '^Schema', module=OpenAPISchema.__base__.__module__)
3✔
36

37

38
class _LegalException(APIException):
3✔
39
    status_code = 451
3✔
40
    default_detail = 'This series is licensed.'
3✔
41
    default_code = 'licensed_series'
3✔
42

43

44
@method_decorator(cache_control(public=True, max_age=1800), 'dispatch')
3✔
45
class ArtistViewSet(CORSMixin, ModelViewSet):
3✔
46
    """
47
    API endpoints for artists.
48

49
    * list: List artists.
50
    * read: View a certain artist.
51
    * create: Create a new artist.
52
    * patch: Edit the given artist.
53
    * delete: Delete the given artist.
54
    """
55
    schema = OpenAPISchema(tags=('artists',))
3✔
56
    queryset = models.Artist.objects.all()
3✔
57
    serializer_class = serializers.ArtistSerializer
3✔
58
    http_method_names = METHODS
3✔
59

60

61
@method_decorator(cache_control(public=True, max_age=1800), 'dispatch')
3✔
62
class AuthorViewSet(CORSMixin, ModelViewSet):
3✔
63
    """
64
    API endpoints for authors.
65

66
    * list: List authors.
67
    * read: View a certain author.
68
    * create: Create a new author.
69
    * patch: Edit the given author.
70
    * delete: Delete the given author.
71
    """
72
    schema = OpenAPISchema(tags=('authors',))
3✔
73
    queryset = models.Author.objects.all()
3✔
74
    serializer_class = serializers.AuthorSerializer
3✔
75
    http_method_names = METHODS
3✔
76

77

78
@method_decorator(cache_control(public=True, max_age=900), 'dispatch')
3✔
79
class CategoryViewSet(CORSMixin, ModelViewSet):
3✔
80
    """
81
    API endpoints for categories.
82

83
    * list: List categories.
84
    * read: View a certain category.
85
    * create: Create a new category.
86
    * patch: Edit the given category.
87
    * delete: Delete the given category.
88
    """
89
    schema = OpenAPISchema(tags=('categories',), component_name='Category')
3✔
90
    serializer_class = serializers.CategorySerializer
3✔
91
    queryset = models.Category.objects.all()
3✔
92
    lookup_field = 'name'
3✔
93
    http_method_names = METHODS
3✔
94

95

96
@method_decorator(cache_control(public=True, max_age=600), 'dispatch')
3✔
97
class PageViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin,
3✔
98
                  UpdateModelMixin, CORSMixin, GenericViewSet):
99
    """
100
    API endpoints for pages.
101

102
    * list: List a chapter's pages.
103
    * create: Create a new page.
104
    * patch: Edit the given page.
105
    * delete: Delete the given page.
106
    """
107
    schema = OpenAPISchema(tags=('pages',),)
3✔
108
    queryset = models.Page.objects.all()
3✔
109
    serializer_class = serializers.PageSerializer
3✔
110
    filter_backends = filters.PAGE_FILTERS  # type: ignore
3✔
111
    parser_classes = (MultiPartParser,)
3✔
112
    http_method_names = METHODS
3✔
113

114

115
@method_decorator(cache_control(public=True, max_age=600), 'dispatch')
3✔
116
class ChapterViewSet(CORSMixin, ModelViewSet):
3✔
117
    """
118
    API endpoints for chapters.
119

120
    * list: List chapters.
121
    * read: View a certain chapter.
122
    * create: Create a new chapter.
123
    * patch: Edit the given chapter.
124
    * delete: Delete the given chapter.
125
    """
126
    schema = OpenAPISchema(tags=('chapters',))
3✔
127
    serializer_class = serializers.ChapterSerializer
3✔
128
    filter_backends = filters.CHAPTER_FILTERS
3✔
129
    parser_classes = (MultiPartParser,)
3✔
130
    http_method_names = METHODS
3✔
131

132
    @action(methods=['get'], detail=True, name='Chapter Pages',
3✔
133
            serializer_class=serializers.PageSerializer,
134
            pagination_class=DummyPagination,
135
            filter_backends=[filters.TrackingFilter])
136
    def pages(self, request: Request, pk: int) -> Response:
3✔
137
        """Get the pages of the chapter."""
138
        try:
×
139
            instance = models.Chapter.objects.filter(
×
140
                published__lte=tz.now()
141
            ).select_related('series').only(
142
                'series__slug', 'volume',
143
                'series__licensed', 'number'
144
            ).prefetch_related('pages').get(id=pk)
145
        except ValueError:
×
146
            raise ParseError()
×
147
        except models.Chapter.DoesNotExist:
×
148
            raise NotFound()
×
149
        if instance.series.licensed:
×
150
            raise _LegalException()
×
151
        serializer = serializers.PageSerializer(
×
152
            instance.pages.all(), many=True,
153
            context=self.get_serializer_context()
154
        )
155
        return self.get_paginated_response(serializer.data)
×
156

157
    @action(methods=['get'], detail=True, name='Read Chapter')
3✔
158
    def read(self, request: Request, pk: int) -> Response:
3✔
159
        """Redirect to the reader."""
160
        try:
×
161
            instance = models.Chapter.objects.filter(
×
162
                published__lte=tz.now()
163
            ).select_related('series').only(
164
                'series__slug', 'volume',
165
                'series__licensed', 'number'
166
            ).get(id=pk)
167
        except ValueError:
×
168
            raise ParseError()
×
169
        except models.Chapter.DoesNotExist:
×
170
            raise NotFound()
×
171
        if instance.series.licensed:
×
172
            raise _LegalException()
×
173
        url = request.build_absolute_uri(instance.get_absolute_url())
×
174
        return Response(status=308, headers={'Location': url})
×
175

176
    def retrieve(self, request: Request, *args, **kwargs) -> Response:
3✔
177
        instance = self.get_object()
×
178
        if instance.series.licensed:
×
179
            raise _LegalException()
×
180
        serializer = self.get_serializer(instance)
×
181
        return Response(serializer.data)
×
182

183
    def get_queryset(self) -> QuerySet:
3✔
184
        return models.Chapter.objects.select_related('series') \
×
185
            .filter(published__lte=tz.now()).order_by('-published')
186

187

188
@method_decorator(cache_control(public=True, max_age=300), 'dispatch')
3✔
189
class SeriesViewSet(CORSMixin, ModelViewSet):
3✔
190
    """
191
    API endpoints for series.
192

193
    * list: List or search for series.
194
    * read: View the details of a series.
195
    * create: Create a new series.
196
    * patch: Edit the given series.
197
    * delete: Delete the given series.
198
    """
199
    schema = OpenAPISchema(
3✔
200
        operation_id_base='Series',
201
        tags=('series',), component_name='Series'
202
    )
203
    filter_backends = filters.SERIES_FILTERS
3✔
204
    parser_classes = (MultiPartParser,)
3✔
205
    pagination_class = PageLimitPagination
3✔
206
    ordering = ('title',)
3✔
207
    lookup_field = 'slug'
3✔
208
    http_method_names = METHODS
3✔
209

210
    @action(methods=['get'], detail=True, name='Series Chapters',
3✔
211
            serializer_class=serializers.ChapterSerializer,
212
            pagination_class=DummyPagination,
213
            filter_backends=[filters.DateFormat])
214
    def chapters(self, request: Request, slug: str) -> Response:
3✔
215
        """Get the chapters of the series."""
216
        try:
×
NEW
217
            now = tz.now()
×
218
            groups = Group.objects.only('name')
×
NEW
219
            chapters = models.Chapter.objects.filter(
×
220
                published__lte=now
221
            ).order_by('-published')
UNCOV
222
            instance = models.Series.objects.annotate(
×
223
                chapter_count=Count('chapters', filter=Q(
224
                    chapters__published__lte=now
225
                )),
226
            ).filter(chapter_count__gt=0).prefetch_related(
227
                Prefetch('chapters', queryset=chapters),
228
                Prefetch('chapters__groups', queryset=groups)
229
            ).only('title', 'slug').get(slug=slug)
230
        except models.Series.DoesNotExist:
×
231
            raise NotFound()
×
232
        if instance.licensed:
×
233
            raise _LegalException()
×
234
        serializer = serializers.ChapterSerializer(
×
235
            instance.chapters.all(), many=True,
236
            context=self.get_serializer_context()
237
        )
238
        return self.get_paginated_response(serializer.data)
×
239

240
    def get_queryset(self) -> QuerySet:
3✔
241
        q = Q(chapters__published__lte=tz.now())
×
242
        return models.Series.objects.annotate(
×
243
            chapter_count=Count('chapters', filter=q),
244
            latest_upload=Max('chapters__published', filter=q),
245
            views=Sum('chapters__views', distinct=True)
246
        ).filter(chapter_count__gt=0).distinct()
247

248
    def get_serializer_class(self) -> Type[serializers.SeriesSerializer]:
3✔
249
        return serializers.SeriesSerializer[self.action]  # type: ignore
3✔
250

251

252
@method_decorator(cache_control(public=True, max_age=600), 'dispatch')
3✔
253
class CubariViewSet(RetrieveModelMixin, CORSMixin, GenericViewSet):
3✔
254
    """
255
    API endpoints for Cubari.
256

257
    * read: Generate JSON for cubari.moe.
258
    """
259
    schema = OpenAPISchema(tags=('cubari',), operation_id_base='Cubari')
3✔
260
    serializer_class = serializers.CubariSerializer
3✔
261
    lookup_field = 'slug'
3✔
262
    http_method_names = ['get', 'head', 'options']
3✔
263

264
    def retrieve(self, request: Request, *args, **kwargs) -> Response:
3✔
265
        instance = self.get_object()
×
266
        if instance.licensed:
×
267
            raise _LegalException()
×
268
        serializer = self.get_serializer(instance)
×
269
        return Response(serializer.data)
×
270

271
    def get_queryset(self) -> QuerySet:
3✔
272
        pages = models.Page.objects.order_by('number')
×
273
        groups = Group.objects.only('name')
×
274
        chapters = models.Chapter.objects.prefetch_related(
×
275
            Prefetch('pages', queryset=pages),
276
            Prefetch('groups', queryset=groups)
277
        ).order_by(F('volume').asc(nulls_last=True), 'number').only(
278
            'id', 'title', 'number', 'volume', 'modified', 'series_id'
279
        )
280
        return models.Series.objects.defer(
×
281
            'manager_id', 'modified', 'created', 'status'
282
        ).prefetch_related(
283
            Prefetch('chapters', queryset=chapters),
284
            Prefetch('authors'), Prefetch('artists')
285
        )
286

287

288
__all__ = [
3✔
289
    'ArtistViewSet', 'AuthorViewSet', 'CategoryViewSet',
290
    'PageViewSet', 'ChapterViewSet', 'SeriesViewSet', 'CubariViewSet'
291
]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc