Source code for reader.views

"""The views of the reader app."""

from __future__ import annotations

from typing import TYPE_CHECKING

from django.core.cache import cache
from django.db.models import Count, F, Prefetch, Q
from django.http import FileResponse, Http404
from django.shortcuts import redirect, render
from django.urls.exceptions import NoReverseMatch
from django.utils import timezone as tz
from django.views.decorators.cache import cache_control
from django.views.decorators.http import condition

from MangAdventure import jsonld
from MangAdventure.utils import HttpResponseUnauthorized

from groups.models import Group

from .models import Category, Chapter, Series

if TYPE_CHECKING:  # pragma: no cover
    from datetime import datetime  # isort:skip
    from typing import Optional, Union  # isort:skip
    from django.http import (  # isort:skip
        HttpRequest, HttpResponse, HttpResponsePermanentRedirect
    )


def _latest(request: HttpRequest, slug: Optional[str] = None,
            vol: Optional[int] = None, num: Optional[float] = None,
            page: Optional[int] = None) -> Optional[datetime]:
    try:
        if slug is None:
            q = Q(chapters__published__lte=tz.now())
            return Series.objects.only('modified').alias(
                chapter_count=Count('chapters', filter=q)
            ).filter(chapter_count__gt=0).latest().modified
        if vol is None:
            return Series.objects.only('modified').filter(
                chapters__published__lte=tz.now(), slug=slug
            ).distinct().get().modified
        return Chapter.objects.only('modified').filter(
            series__slug=slug, volume=vol or None,
            number=num, published__lte=tz.now()
        ).latest().modified
    except (Series.DoesNotExist, Chapter.DoesNotExist):
        return None


def _cbz_etag(request: HttpRequest, slug: str, vol: int, num: float) -> str:
    return 'W/"%x"' % (hash(f'{slug}-{vol}-{num}.cbz') & (1 << 64) - 1)


[docs]@condition(last_modified_func=_latest) @cache_control(max_age=600, stale_if_error=300, must_revalidate=True) def directory(request: HttpRequest) -> HttpResponse: """ View that serves a page which lists all the series. :param request: The original request. :return: A response with the rendered ``all_series.html`` template. """ now = tz.now() chapters = Chapter.objects.defer( 'file', 'views', 'modified' ).filter(published__lte=now).order_by('-published') groups = Group.objects.only('name') q = Q(chapters__published__lte=now) series = list(Series.objects.alias( chapter_count=Count('chapters', filter=q) ).filter(chapter_count__gt=0).prefetch_related( Prefetch('chapters', queryset=chapters), Prefetch('chapters__groups', queryset=groups) ).distinct().order_by('title').only( 'title', 'slug', 'format', 'cover' ).exclude(licensed=True)) uri = request.build_absolute_uri(request.path) crumbs = jsonld.breadcrumbs([('Reader', uri)]) library = jsonld.carousel([s.get_absolute_url() for s in series]) return render(request, 'directory.html', { 'all_series': series, 'library': library, 'breadcrumbs': crumbs })
[docs]@condition(last_modified_func=_latest) @cache_control(max_age=1800, stale_if_error=900, must_revalidate=True) def series(request: HttpRequest, slug: str) -> HttpResponse: """ View that serves the page of a single series. If the series doesn't have any published chapters, only staff members will be able to see it. :param request: The original request. :param slug: The slug of the series. :return: A response with the rendered ``series.html`` template. :raises Http404: If there is no series with the specified ``slug``. """ try: qs = Chapter.objects.filter(published__lte=tz.now()).order_by( 'series', F('volume').asc(nulls_last=True), 'number' ).reverse().defer('file', 'views', 'modified') groups = Group.objects.only('name') series = Series.objects.prefetch_related( Prefetch('chapters', queryset=qs), Prefetch('chapters__groups', queryset=groups), Prefetch('authors'), Prefetch('artists') ).defer('manager').get(slug=slug) except Series.DoesNotExist as e: raise Http404 from e chapters = None if series.licensed else list(series.chapters.all()) if not series.licensed and not chapters: return render(request, 'error.html', { 'error_message': 'Sorry. This series is not yet available.', 'error_status': 403 }, status=403) marked = request.user.is_authenticated and \ request.user.bookmarks.filter(series=series).exists() url = request.path p_url = url.rsplit('/', 2)[0] + '/' uri = request.build_absolute_uri(url) crumbs = jsonld.breadcrumbs([ ('Reader', request.build_absolute_uri(p_url)), (series.title, uri) ]) tags = list(series.categories.values_list('name', flat=True)) authors = list(series.authors.all()) artists = list(series.artists.all()) aliases = series.aliases.names() book = jsonld.schema('Book', { 'url': uri, 'name': series.title, 'abstract': series.description, 'author': [{ '@type': 'Person', 'name': au.name, # 'alternateName': au.aliases.names() } for au in authors], 'illustrator': [{ '@type': 'Person', 'name': ar.name, # 'alternateName': ar.aliases.names() } for ar in artists], 'alternateName': aliases, 'creativeWorkStatus': series.status, 'isAccessibleForFree': not series.licensed, 'dateCreated': series.created.strftime('%F'), 'dateModified': series.modified.strftime('%F'), 'bookFormat': 'GraphicNovel', 'genre': tags, }) return render(request, 'series.html', { 'series': series, 'chapters': chapters, 'marked': marked, 'breadcrumbs': crumbs, 'book_ld': book, 'authors': authors, 'artists': artists, 'aliases': aliases, 'tags': tags })
[docs]@condition(last_modified_func=_latest) @cache_control(max_age=3600, stale_if_error=1800, must_revalidate=True) def chapter_page(request: HttpRequest, slug: str, vol: int, num: float, page: int) -> HttpResponse: """ View that serves a chapter page. :param request: The original request. :param slug: The slug of the series. :param vol: The volume of the chapter. :param num: The number of the chapter. :param page: The number of the page. :return: A response with the rendered ``chapter.html`` template. :raises Http404: If there is no matching chapter or page. """ if page < 1: raise Http404('Page number must be positive') chapters = cache.get_or_set( f'reader.chapters.{slug}', list(Chapter.objects.filter( series__slug=slug, series__licensed=False, published__lte=tz.now() ).select_related('series').only( 'title', 'number', 'volume', 'published', 'final', 'series__slug', 'series__cover', 'series__title', 'series__format' ).order_by( 'series', F('volume').asc(nulls_last=True), 'number' ).reverse()), timeout=1800 ) if not chapters: raise Http404('No chapters for this series') max_, found = len(chapters) - 1, False for idx, current in enumerate(chapters): if current == (vol or float('inf'), num): next_ = chapters[idx - 1] if idx > 0 else None prev_ = chapters[idx + 1] if idx < max_ else None found = True break if not found: raise Http404('No such chapter') all_pages = list(current.pages.all()) try: curr_page = next(p for p in all_pages if p.number == page) except StopIteration as e: # pragma: no cover raise Http404('No such page') from e prefetch = list(filter( lambda p: curr_page < p < curr_page.number + 3, all_pages )) tags = current.series.categories.values_list('name', flat=True) url = request.path p_url = url.rsplit('/', 4)[0] + '/' p2_url = url.rsplit('/', 5)[0] + '/' crumbs = jsonld.breadcrumbs([ ('Reader', request.build_absolute_uri(p2_url)), (current.series.title, request.build_absolute_uri(p_url)), (current.title, request.build_absolute_uri(url)) ]) return render(request, 'chapter.html', { 'all_chapters': chapters, 'curr_chapter': current, 'next_chapter': next_, 'prev_chapter': prev_, 'all_pages': all_pages, 'curr_page': curr_page, 'prefetch': prefetch, 'breadcrumbs': crumbs, 'tags': ','.join(tags) })
[docs]def chapter_redirect(request: HttpRequest, slug: str, vol: int, num: float) -> HttpResponsePermanentRedirect: """ View that redirects a chapter to its first page. :param request: The original request. :param slug: The slug of the series. :param vol: The volume of the chapter. :param num: The number of the chapter. :return: A redirect to :func:`chapter_page`. :raises Http404: If the chapter does not exist. """ try: return redirect('reader:page', slug, vol, num, 1, permanent=True) except NoReverseMatch as e: raise Http404 from e
[docs]@condition(etag_func=_cbz_etag, last_modified_func=_latest) @cache_control(max_age=3600, must_revalidate=True) def chapter_download(request: HttpRequest, slug: str, vol: int, num: float ) -> Union[FileResponse, HttpResponseUnauthorized]: """ View that generates a ``.cbz`` file from a chapter. :param request: The original request. :param slug: The slug of the chapter's series. :param vol: The volume of the chapter. :param num: The number of the chapter. :return: A response with the ``.cbz`` file if the user is logged in. :raises Http404: If the chapter does not exist. """ if not request.user.is_authenticated: return HttpResponseUnauthorized( b'You must be logged in to download this file.', content_type='text/plain', realm='chapter archive' ) try: groups = Group.objects.only('name') categories = Category.objects.only('name') chapter = Chapter.objects.only( 'title', 'volume', 'number', 'published', 'series__format', 'series__title', 'series__slug' ).select_related('series').prefetch_related( 'pages', 'series__authors', 'series__artists', Prefetch('groups', queryset=groups), Prefetch('series__categories', queryset=categories) ).get( series__slug=slug, series__licensed=False, volume=vol or None, number=num, published__lte=tz.now() ) except Chapter.DoesNotExist as e: raise Http404 from e if chapter.volume: # pragma: no cover name = '{0.series} - v{0.volume} c{0.number:g}.cbz'.format(chapter) else: name = '{0.series} - c{0.number:g}.cbz'.format(chapter) return FileResponse( chapter.zip(), as_attachment=True, filename=name, content_type='application/vnd.comicbook+zip' )
__all__ = [ 'directory', 'series', 'chapter_page', 'chapter_redirect', 'chapter_download' ]