mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**Replace Flake8 with Ruff, refactor type hints, and enhance archive processing logic**
- Removed `flake8` and `flake8-annotations` from dependencies, replaced by `ruff` for linting. - Introduced cleaner type hinting and modern Python annotations (PEP 604 style) across multiple modules. - Moved archive processing to a dedicated helper (`comic.archives`) for improved maintainability. - Updated and simplified logic for archive file retrieval, directory cleaning, and user access validation. - Improved error handling and added support for user classification restrictions in API responses.
This commit is contained in:
1 parent
147a3a54ef
commit
1148f4072b
13 files changed
+236
-227
No files matched your search
+1
-2
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Directory, ComicBook, ComicStatus, UserMisc
|
||||
from .models import ComicBook, ComicStatus, Directory, UserMisc
|
||||
|
||||
|
||||
@admin.register(Directory)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared helpers for listing the pages inside a comic archive."""
|
||||
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from typing import NamedTuple
|
||||
|
||||
import rarfile
|
||||
|
||||
|
||||
class ArchiveFile(NamedTuple):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
def get_archive_files(archive: zipfile.ZipFile | rarfile.RarFile) -> list[ArchiveFile]:
|
||||
"""Return the image entries of an archive in page order.
|
||||
|
||||
Metadata entries such as ComicInfo.xml are not pages. Counting them would make
|
||||
page_count disagree with the page list the reader uses, so a book could never
|
||||
be marked as finished.
|
||||
"""
|
||||
result = []
|
||||
for name in sorted(archive.namelist()):
|
||||
if name.endswith('/'):
|
||||
continue
|
||||
mime_type = mimetypes.guess_type(name)[0]
|
||||
if not mime_type or name.lower().endswith('.xml'):
|
||||
continue
|
||||
result.append(ArchiveFile(name, mime_type))
|
||||
return result
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet
|
||||
from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from comic.models import ComicBook, UserMisc, Directory
|
||||
from comic.models import ComicBook, Directory, UserMisc
|
||||
|
||||
|
||||
class RecentComicsAPI(Feed):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
@@ -27,7 +26,7 @@ class Command(BaseCommand):
|
||||
self.OUTPUT = options.get('out', False)
|
||||
self.scan_directory()
|
||||
|
||||
def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None:
|
||||
def scan_directory(self, user: AbstractBaseUser | None = None, directory: Directory | None = None) -> None:
|
||||
if not user:
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.first()
|
||||
|
||||
+17
-23
@@ -1,16 +1,13 @@
|
||||
import io
|
||||
import mimetypes
|
||||
import uuid
|
||||
import zipfile
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Union, Tuple, Final, IO
|
||||
from typing import IO, Final
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import pymupdf
|
||||
import rarfile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import Image as Image_type
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
@@ -19,7 +16,10 @@ from django.db.models import UniqueConstraint
|
||||
from django_boost.models.fields import AutoOneToOneField
|
||||
from imagekit.models import ProcessedImageField
|
||||
from imagekit.processors import ResizeToFill
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import Image as Image_type
|
||||
|
||||
from comic.archives import get_archive_files
|
||||
from comic.errors import NotCompatibleArchive
|
||||
|
||||
if settings.UNRAR_TOOL:
|
||||
@@ -84,7 +84,7 @@ class Directory(models.Model):
|
||||
return reduce(lambda x, y: Path(x, y), path_items)
|
||||
return Path(path_items[0])
|
||||
|
||||
def get_path_items(self, path_items: Optional[List] = None) -> List[Path]:
|
||||
def get_path_items(self, path_items: list | None = None) -> list[Path]:
|
||||
if path_items is None:
|
||||
path_items = []
|
||||
path_items.append(self.name)
|
||||
@@ -92,7 +92,7 @@ class Directory(models.Model):
|
||||
self.parent.get_path_items(path_items)
|
||||
return path_items
|
||||
|
||||
def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]:
|
||||
def get_path_objects(self, path_items: list | None = None) -> list[Directory]:
|
||||
if path_items is None:
|
||||
path_items = []
|
||||
path_items.append(self)
|
||||
@@ -141,7 +141,7 @@ class ComicBook(models.Model):
|
||||
return Path(base_dir, self.directory.get_path(), self.file_name)
|
||||
return Path(base_dir, self.file_name)
|
||||
|
||||
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
|
||||
def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]:
|
||||
if self.file_name.lower().endswith('.pdf'):
|
||||
# noinspection PyUnresolvedReferences
|
||||
doc = pymupdf.open(self.get_pdf())
|
||||
@@ -168,15 +168,16 @@ class ComicBook(models.Model):
|
||||
except zipfile.BadZipfile:
|
||||
return False, False
|
||||
|
||||
file_name, file_mime = self.get_archive_files(archive)[page]
|
||||
file_name, file_mime = get_archive_files(archive)[page]
|
||||
return archive.open(file_name), file_mime
|
||||
|
||||
def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]:
|
||||
def generate_thumbnail_pdf(self, page_index: int = 0) -> tuple[io.BytesIO, Image_type, str]:
|
||||
img, pil_data = self._get_pdf_image(page_index if page_index else 0)
|
||||
return img, pil_data, 'Image/JPEG'
|
||||
|
||||
def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str],
|
||||
Tuple[bool, bool, bool]]:
|
||||
def generate_thumbnail_archive(
|
||||
self, page_index: int = 0
|
||||
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
|
||||
img, content_type, pil_data = False, False, False
|
||||
if page_index:
|
||||
img, content_type = self.get_image(page_index)
|
||||
@@ -209,7 +210,7 @@ class ComicBook(models.Model):
|
||||
)
|
||||
self.save()
|
||||
|
||||
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
|
||||
def _get_pdf_image(self, page_index: int) -> tuple[io.BytesIO, Image_type]:
|
||||
doc = pymupdf.open(self.get_pdf())
|
||||
page = doc[page_index]
|
||||
pix = page.get_pixmap()
|
||||
@@ -222,7 +223,7 @@ class ComicBook(models.Model):
|
||||
return img, pil_data
|
||||
|
||||
@staticmethod
|
||||
def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]:
|
||||
def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path:
|
||||
try:
|
||||
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
|
||||
book.directory = directory
|
||||
@@ -240,7 +241,7 @@ class ComicBook(models.Model):
|
||||
return comic_file_path
|
||||
|
||||
if archive_type == 'archive':
|
||||
book.page_count = len(book.get_archive_files(archive))
|
||||
book.page_count = len(get_archive_files(archive))
|
||||
elif archive_type == 'pdf':
|
||||
book.page_count = archive.page_count
|
||||
|
||||
@@ -252,7 +253,7 @@ class ComicBook(models.Model):
|
||||
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
|
||||
return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
|
||||
|
||||
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, pymupdf.Document], str]:
|
||||
def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]:
|
||||
archive_path = self.get_archive_path
|
||||
try:
|
||||
return rarfile.RarFile(archive_path), 'archive'
|
||||
@@ -273,17 +274,10 @@ class ComicBook(models.Model):
|
||||
def get_page_count(self) -> int:
|
||||
archive, archive_type = self.get_archive()
|
||||
if archive_type == 'archive':
|
||||
return len(self.get_archive_files(archive))
|
||||
return len(get_archive_files(archive))
|
||||
elif archive_type == 'pdf':
|
||||
return archive.page_count
|
||||
|
||||
@staticmethod
|
||||
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[Tuple[str, str]]:
|
||||
return [
|
||||
(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
|
||||
if not x.endswith('/') and mimetypes.guess_type(x)[0] and not x.endswith('xml')
|
||||
]
|
||||
|
||||
|
||||
class ComicStatus(models.Model):
|
||||
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
|
||||
|
||||
+20
-26
@@ -1,21 +1,27 @@
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, List, Optional, Union
|
||||
|
||||
import rarfile
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \
|
||||
IntegerField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
IntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
Q,
|
||||
QuerySet,
|
||||
When,
|
||||
)
|
||||
|
||||
from comic import models
|
||||
from comic.archives import get_archive_files
|
||||
from comic.errors import NotCompatibleArchive
|
||||
|
||||
|
||||
def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \
|
||||
-> List[Union[models.Directory, models.ComicBook]]:
|
||||
def generate_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \
|
||||
-> list[models.Directory | models.ComicBook]:
|
||||
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
|
||||
files = []
|
||||
|
||||
@@ -63,9 +69,9 @@ def generate_directory(user: AbstractBaseUser, directory: Optional[models.Direct
|
||||
return files
|
||||
|
||||
|
||||
def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None:
|
||||
dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories)
|
||||
dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir())
|
||||
def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
|
||||
dir_db_set = {Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories}
|
||||
dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()}
|
||||
# Create new directories db instances
|
||||
for new_directory in dir_list - dir_db_set:
|
||||
models.Directory(name=new_directory.name, parent=directory).save()
|
||||
@@ -75,10 +81,10 @@ def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional
|
||||
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
|
||||
|
||||
|
||||
def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \
|
||||
def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: models.Directory | None = None) \
|
||||
-> None:
|
||||
file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file())
|
||||
files_db_set = set(Path(dir_path, x.file_name) for x in files)
|
||||
file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
|
||||
files_db_set = {Path(dir_path, x.file_name) for x in files}
|
||||
|
||||
# Parse new comics
|
||||
books_to_add = []
|
||||
@@ -106,15 +112,3 @@ def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directo
|
||||
# Remove stale comic instances
|
||||
for stale_comic in files_db_set - file_list:
|
||||
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
|
||||
|
||||
|
||||
class ArchiveFile(NamedTuple):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[ArchiveFile]:
|
||||
return [
|
||||
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
|
||||
if not x.endswith('/') and mimetypes.guess_type(x)[0]
|
||||
]
|
||||
+105
-87
@@ -1,17 +1,18 @@
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from http.client import HTTPResponse
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional, Dict, Iterable, List
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When
|
||||
from django.http import FileResponse
|
||||
from django.utils.crypto import get_random_string
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
|
||||
from rest_framework import mixins, permissions, renderers, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
@@ -23,6 +24,17 @@ from comic.processing import generate_directory
|
||||
from comic.util import generate_breadcrumbs_from_path
|
||||
|
||||
|
||||
def user_can_read(user: models.User, directory: models.Directory | None) -> bool:
|
||||
"""Whether the user's classification allowance covers this book.
|
||||
|
||||
Books that are not inside a directory are treated as the highest rating, which
|
||||
matches how the browse API reports them.
|
||||
"""
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=user)
|
||||
classification = directory.classification if directory else models.Directory.Classification.C_18
|
||||
return classification <= misc.allowed_to_read
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
|
||||
source='usermisc')
|
||||
@@ -61,16 +73,15 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=pk)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if target_user.username == serializer.data['username']:
|
||||
password = get_random_string(12)
|
||||
target_user.set_password(password)
|
||||
target_user.save()
|
||||
resp_serializer = self.get_serializer({
|
||||
'username': target_user.username,
|
||||
'password': password
|
||||
})
|
||||
return Response(resp_serializer.data)
|
||||
if serializer.is_valid() and target_user.username == serializer.data['username']:
|
||||
password = get_random_string(12)
|
||||
target_user.set_password(password)
|
||||
target_user.save()
|
||||
resp_serializer = self.get_serializer({
|
||||
'username': target_user.username,
|
||||
'password': password
|
||||
})
|
||||
return Response(resp_serializer.data)
|
||||
|
||||
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -89,7 +100,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class BrowseFileField(serializers.FileField):
|
||||
def to_representation(self, value: Optional[FileField]) -> Optional[str]:
|
||||
def to_representation(self, value: FileField | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return Path(settings.MEDIA_URL, value.name).as_posix()
|
||||
@@ -127,7 +138,7 @@ class BrowseViewSet(viewsets.GenericViewSet):
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
|
||||
def retrieve(self, request: Request, selector: UUID) -> Response:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
directory = get_object_or_404(models.Directory, selector=selector)
|
||||
serializer = self.get_serializer(generate_directory(request.user, directory), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -135,12 +146,8 @@ class BrowseViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
|
||||
def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
|
||||
queryset = []
|
||||
comic = False
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
directory = comic.directory
|
||||
comic = models.ComicBook.objects.filter(selector=selector).first()
|
||||
directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector)
|
||||
|
||||
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
|
||||
queryset.append({
|
||||
@@ -165,25 +172,17 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
|
||||
def retrieve(self, _request: Request, selector: UUID) -> Response:
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
if not directory.thumbnail:
|
||||
directory.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": directory.selector,
|
||||
"thumbnail": directory.thumbnail
|
||||
}).data
|
||||
)
|
||||
target = models.Directory.objects.get(selector=selector)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
if not comic.thumbnail:
|
||||
comic.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": comic.selector,
|
||||
"thumbnail": comic.thumbnail
|
||||
}).data
|
||||
)
|
||||
target = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not target.thumbnail:
|
||||
target.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": target.selector,
|
||||
"thumbnail": target.thumbnail
|
||||
}).data
|
||||
)
|
||||
|
||||
|
||||
class DirectionSerializer(serializers.Serializer):
|
||||
@@ -213,9 +212,12 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()})
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer(),
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed'})
|
||||
def retrieve(self, request: Request, selector: UUID) -> Response:
|
||||
comic = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(request.user, comic.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
_, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
|
||||
@@ -247,17 +249,17 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
|
||||
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
|
||||
status.HTTP_404_NOT_FOUND: 'Comic file not found'})
|
||||
@action(methods=['get'], detail=True)
|
||||
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response, HTTPResponse]:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
try:
|
||||
if book.directory.classification > misc.allowed_to_read:
|
||||
return Response(status=400, data={'errors': 'Not allowed to read.'})
|
||||
except AttributeError:
|
||||
pass
|
||||
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
|
||||
def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse:
|
||||
book = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(request.user, book.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
pdf_path = book.get_pdf()
|
||||
if not pdf_path.is_file():
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'})
|
||||
return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
|
||||
@action(methods=['get'], detail=True)
|
||||
@@ -272,15 +274,11 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \
|
||||
.get_or_create(comic_id=selector, user=request.user)
|
||||
comic = get_object_or_404(models.ComicBook, selector=selector)
|
||||
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
comic_status.last_read_page = serializer.data['page']
|
||||
comic_status.unread = False
|
||||
if comic_status.page_count - 1 == comic_status.last_read_page:
|
||||
comic_status.finished = True
|
||||
else:
|
||||
comic_status.finished = False
|
||||
|
||||
comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1
|
||||
comic_status.save()
|
||||
return Response(self.get_serializer(comic_status).data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -293,20 +291,30 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
|
||||
media_type = '*/*'
|
||||
format = ''
|
||||
|
||||
def render(self, data: bytes, accepted_media_type: Optional[str] = None, renderer_context: Optional[str] = None) \
|
||||
def render(self, data: bytes, accepted_media_type: str | None = None, renderer_context: str | None = None) \
|
||||
-> bytes:
|
||||
return data
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
return data
|
||||
# Error responses still need to be serialised.
|
||||
return json.dumps(data).encode()
|
||||
|
||||
|
||||
class ImageViewSet(viewsets.ViewSet):
|
||||
queryset = models.ComicBook.objects.all()
|
||||
lookup_field = 'page'
|
||||
renderer_classes = [PassthroughRenderer]
|
||||
renderer_classes = [PassthroughRenderer, renderers.JSONRenderer]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
|
||||
def retrieve(self, _request: Request, selector: UUID, page: int) -> FileResponse:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response",
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
|
||||
status.HTTP_404_NOT_FOUND: 'Comic or page not found'})
|
||||
def retrieve(self, _request: Request, selector: UUID, page: int) -> Response:
|
||||
book = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(_request.user, book.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
img, content = book.get_image(int(page) - 1)
|
||||
if not img:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Page could not be read.'})
|
||||
self.renderer_classes[0].media_type = content
|
||||
return FileResponse(img, content_type=content)
|
||||
|
||||
@@ -434,7 +442,7 @@ class ActionViewSet(viewsets.GenericViewSet):
|
||||
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
obj.unread = False
|
||||
obj.finished = True
|
||||
obj.last_read_page = comic.total_pages
|
||||
obj.last_read_page = max(comic.page_count - 1, 0)
|
||||
status_to_update.append(obj)
|
||||
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
|
||||
return Response({'status': 'marked_read'})
|
||||
@@ -466,7 +474,7 @@ class ActionViewSet(viewsets.GenericViewSet):
|
||||
return Response(serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]:
|
||||
def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]:
|
||||
data = set()
|
||||
data = data.union(
|
||||
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
|
||||
@@ -508,10 +516,10 @@ class PasswordResetSerializer(serializers.Serializer):
|
||||
try:
|
||||
validate_password(data)
|
||||
except ValidationError as err:
|
||||
raise serializers.ValidationError(err)
|
||||
raise serializers.ValidationError(err) from err
|
||||
return data
|
||||
|
||||
def validate(self, attrs: Dict[str, str]) -> Dict[str, str]:
|
||||
def validate(self, attrs: dict[str, str]) -> dict[str, str]:
|
||||
super().validate(attrs)
|
||||
if attrs['new_password'] != attrs['new_password_confirm']:
|
||||
raise serializers.ValidationError('New passwords do not match')
|
||||
@@ -582,37 +590,47 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@staticmethod
|
||||
def _descendants(directory: models.Directory) -> list[models.Directory]:
|
||||
"""The directory itself plus every directory below it."""
|
||||
to_update = [directory]
|
||||
to_visit = [directory]
|
||||
while to_visit:
|
||||
parent = to_visit.pop()
|
||||
for child in models.Directory.objects.filter(parent=parent):
|
||||
to_visit.append(child)
|
||||
to_update.append(child)
|
||||
return to_update
|
||||
|
||||
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
|
||||
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
|
||||
"""
|
||||
This will set the classification of a directory and all it's children.
|
||||
This will set the classification of a directory and all of its children.
|
||||
"""
|
||||
main_parent = get_object_or_404(models.Directory, selector=selector)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if serializer.is_valid():
|
||||
main_parent.classification = serializer.data['classification']
|
||||
to_update = {main_parent}
|
||||
to_visit = {main_parent}
|
||||
|
||||
while to_visit:
|
||||
parent = to_visit.pop()
|
||||
for child in models.Directory.objects.filter(parent=parent):
|
||||
child.classification = serializer.data['classification']
|
||||
to_visit.add(child)
|
||||
to_update.add(child)
|
||||
models.Directory.objects.bulk_update(to_update, fields=['classification'])
|
||||
data = models.Directory.objects.filter(directory__in=to_update)
|
||||
response = self.get_serializer(data, many=True)
|
||||
return Response(response.data)
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
to_update = self._descendants(main_parent)
|
||||
for directory in to_update:
|
||||
directory.classification = serializer.data['classification']
|
||||
models.Directory.objects.bulk_update(to_update, fields=['classification'])
|
||||
return Response(self.get_serializer(to_update, many=True).data)
|
||||
|
||||
@swagger_auto_schema(responses={200: DirectorySerializer()})
|
||||
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""
|
||||
This will set the classification of a directory and none of its children.
|
||||
"""
|
||||
return super().update(request, *args, **kwargs)
|
||||
directory = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
directory.classification = serializer.data['classification']
|
||||
directory.save()
|
||||
return Response(self.get_serializer(directory).data)
|
||||
|
||||
|
||||
class InitialSetupSerializer(serializers.Serializer):
|
||||
|
||||
+5
-6
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from comic.models import ComicBook, Directory
|
||||
|
||||
@@ -10,12 +9,12 @@ class Breadcrumb:
|
||||
selector: str = ''
|
||||
|
||||
|
||||
def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None):
|
||||
def generate_breadcrumbs_from_path(
|
||||
directory: Directory | None = None,
|
||||
book: ComicBook | None = None,
|
||||
) -> list[Breadcrumb]:
|
||||
output = [Breadcrumb()]
|
||||
if directory:
|
||||
folders = directory.get_path_objects()
|
||||
else:
|
||||
folders = []
|
||||
folders = directory.get_path_objects() if directory else []
|
||||
for item in folders[::-1]:
|
||||
output.append(
|
||||
Breadcrumb(
|
||||
|
||||
Reference in new issue
Block a user