**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:
ajurna committed 2026-10-13 11:36:28 +01:00
1 parent 147a3a54ef
commit 1148f4072b
13 files changed
+226 -217

No files matched your search

+5
View File
@@ -8,3 +8,8 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.0
hooks:
- id: ruff
args: [ --fix ]
+4 -3
View File
@@ -23,10 +23,11 @@ from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
from rest_framework.routers import DefaultRouter
# from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView
from comic import rest, feeds
# from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenBlacklistView, TokenObtainPairView, TokenRefreshView
from comic import feeds, rest
schema_view = get_schema_view(
openapi.Info(
+1 -2
View File
@@ -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)
+30
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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]
]
+95 -77
View File
@@ -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,8 +73,7 @@ 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']:
if serializer.is_valid() and target_user.username == serializer.data['username']:
password = get_random_string(12)
target_user.set_password(password)
target_user.save()
@@ -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,23 +172,15 @@ 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()
target = get_object_or_404(models.ComicBook, selector=selector)
if not target.thumbnail:
target.generate_thumbnail()
return Response(
self.serializer_class({
"selector": comic.selector,
"thumbnail": comic.thumbnail
"selector": target.selector,
"thumbnail": target.thumbnail
}).data
)
@@ -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:
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'
@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.
"""
main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
main_parent.classification = serializer.data['classification']
to_update = {main_parent}
to_visit = {main_parent}
@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):
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)
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 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)
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
View File
@@ -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(
+19 -2
View File
@@ -39,13 +39,30 @@ dependencies = [
[dependency-groups]
dev = [
"coverage>=7.13.5",
"flake8>=7.3.0",
"flake8-annotations>=3.2.0",
"ipython>=9.13.0",
"mypy>=2",
"pre-commit>=4.6",
"pylint>=4.0.5",
"pylint-django>=2.7.0",
"pyopenssl>=26.2.0",
"ruff>=0.17.0",
"werkzeug>=3.1.8",
]
[tool.ruff]
target-version = "py314"
line-length = 120
extend-exclude = [
"*/migrations/*",
"cbreader/settings",
"frontend",
]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"]
# These were ignored by the previous flake8-annotations setup (ANN001 is the
# modern name for the old ANN101).
ignore = ["ANN001", "ANN002", "ANN003"]
[tool.ruff.lint.isort]
combine-as-imports = true
-26
View File
@@ -1,26 +0,0 @@
[tool:pytest]
addopts = --flake8
[flake8]
max-line-length = 120
ignore =
* ANN101
* ANN002
* ANN003
# Ignore rules which contradicts black's formatting choices:
; * E501
; * W503
; * W504
; * E266
exclude =
# Exclude these files
*/migrations/*
cbreader/settings
frontend
[tool:isort]
line_length = 119
indent = ' '
multi_line_output = 3
include_trailing_comma = 1
Generated
+27 -48
View File
@@ -129,14 +129,13 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "coverage" },
{ name = "flake8" },
{ name = "flake8-annotations" },
{ name = "ipython" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pylint" },
{ name = "pylint-django" },
{ name = "pyopenssl" },
{ name = "ruff" },
{ name = "werkzeug" },
]
@@ -173,14 +172,13 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = ">=7.13.5" },
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "flake8-annotations", specifier = ">=3.2.0" },
{ name = "ipython", specifier = ">=9.13.0" },
{ name = "mypy", specifier = ">=2" },
{ name = "pre-commit", specifier = ">=4.6" },
{ name = "pylint", specifier = ">=4.0.5" },
{ name = "pylint-django", specifier = ">=2.7.0" },
{ name = "pyopenssl", specifier = ">=26.2.0" },
{ name = "ruff", specifier = ">=0.17.0" },
{ name = "werkzeug", specifier = ">=3.1.8" },
]
@@ -607,32 +605,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
]
[[package]]
name = "flake8"
version = "7.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mccabe" },
{ name = "pycodestyle" },
{ name = "pyflakes" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
]
[[package]]
name = "flake8-annotations"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flake8" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/ea/f36a8e013154f214715de01d46d98f12a673c7b978533079ac5caf4cbcce/flake8_annotations-3.2.0.tar.gz", hash = "sha256:71b52ec85a4ec82a10edfad497fe2b390609c95b2ad7d61cc94704bc88a498a0", size = 14169, upload-time = "2025-10-09T21:17:57.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/49/ae687154c2cfe623e273143000b81dbd3901d29e13ef30155f1652c18e7c/flake8_annotations-3.2.0-py3-none-any.whl", hash = "sha256:2b0203cac0eb33c43463f1a986c3cc70a448d9d593da75567173f46f64e9213c", size = 16873, upload-time = "2025-10-09T21:17:56.265Z" },
]
[[package]]
name = "gprof2dot"
version = "2025.4.14"
@@ -1130,15 +1102,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "pycodestyle"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
@@ -1148,15 +1111,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pyflakes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -1379,6 +1333,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" },
]
[[package]]
name = "ruff"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
{ url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
{ url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
{ url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
{ url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
{ url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
{ url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
]
[[package]]
name = "setuptools"
version = "84.0.0"