diff --git a/apps/library/admin.py b/apps/library/admin.py index db85697..6a89fd3 100644 --- a/apps/library/admin.py +++ b/apps/library/admin.py @@ -31,9 +31,9 @@ class BookAdminForm(forms.ModelForm): class BookAdmin(ModelAdmin): form = BookAdminForm - list_display = ('title', 'display_categories', 'display_collections', 'status', 'view_count', 'created_at') + list_display = ('title', 'display_author', 'display_categories', 'display_collections', 'status', 'view_count', 'created_at') list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at') - search_fields = ('title', 'slug', 'summary', 'description') + search_fields = ('title', 'slug', 'summary', 'description', 'author__full_name') # autocomplete_fields = ('categories', 'collections', ) list_filter_submit = True warn_unsaved_form = True @@ -54,13 +54,17 @@ class BookAdmin(ModelAdmin): if collections: return ', '.join([collection.title for collection in collections]) return '-' + + @display(description=_('Author')) + def display_author(self, obj): + return obj.author.full_name if obj.author_id else '-' fieldsets = ( (None, { 'fields': () }), ('Detail', { - 'fields': ('title', 'slogan', 'thumbnail', 'pages_count', 'publisher', 'year_of_publication', 'isbn', 'numnber_of_volume') + 'fields': ('title', 'author', 'slogan', 'thumbnail', 'pages_count', 'publisher', 'year_of_publication', 'isbn', 'numnber_of_volume') }), ("Summary", { 'fields': ('summary_title', 'summary') @@ -242,8 +246,29 @@ class CategoryAdmin(ModelAdmin): self.message_user(request, _("Category '%(title)s' is now %(status)s.") % {"title": obj.title, "status": status_text}) +class AuthorAdmin(ModelAdmin): + list_display = ('full_name', 'books_count_display', 'birth_date', 'death_date', 'created_at') + search_fields = ('full_name', 'slug') + readonly_fields = ('created_at', 'updated_at') + + fieldsets = ( + (None, { + 'fields': ('full_name', 'slug', 'thumbnail', 'birth_date', 'death_date') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + @display(description=_('Number of Books')) + def books_count_display(self, obj): + return obj.books.count() + + # Register models with the custom admin site dovoodi_admin_site.register(Book, BookAdmin) +dovoodi_admin_site.register(Author, AuthorAdmin) dovoodi_admin_site.register(PinnedBookCollection, PinnedBookCollectionAdmin) dovoodi_admin_site.register(MiddleBookCollection, MiddleBookCollectionAdmin) dovoodi_admin_site.register(Category, CategoryAdmin) diff --git a/apps/library/doc.py b/apps/library/doc.py index c08c521..99ead32 100644 --- a/apps/library/doc.py +++ b/apps/library/doc.py @@ -17,22 +17,6 @@ collection_id_param = openapi.Parameter( required=False ) -middle_param = openapi.Parameter( - 'middle', - openapi.IN_QUERY, - description="Filter books by middle section collection (any value will trigger the filter)", - type=openapi.TYPE_STRING, - required=False -) - -bottom_param = openapi.Parameter( - 'bottom', - openapi.IN_QUERY, - description="Filter books by bottom section collection (any value will trigger the filter)", - type=openapi.TYPE_STRING, - required=False -) - is_bookmark_param = openapi.Parameter( 'is_bookmark', openapi.IN_QUERY, @@ -49,6 +33,14 @@ category_param = openapi.Parameter( required=False ) +author_param = openapi.Parameter( + 'author', + openapi.IN_QUERY, + description="Filter books by author slug", + type=openapi.TYPE_STRING, + required=False +) + sort_param = openapi.Parameter( 'sort', openapi.IN_QUERY, @@ -65,44 +57,45 @@ search_param = openapi.Parameter( required=False ) + +thumbs_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + nullable=True, + properties={ + 'sm': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'md': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'lg': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + } +) + +author_summary_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'full_name': openapi.Schema(type=openapi.TYPE_STRING), + 'slug': openapi.Schema(type=openapi.TYPE_STRING), + 'thumbnail': thumbs_schema, + } +) + # Response schemas book_schema = openapi.Schema( type=openapi.TYPE_OBJECT, properties={ - 'id': openapi.Schema( - type=openapi.TYPE_INTEGER, - description="Unique identifier for the book" - ), - 'title': openapi.Schema( - type=openapi.TYPE_STRING, - description="Title of the book" - ), - 'slug': openapi.Schema( - type=openapi.TYPE_STRING, - description="URL-friendly slug for the book" - ), - 'summary': openapi.Schema( - type=openapi.TYPE_STRING, - description="Brief summary of the book" - ), - 'description': openapi.Schema( - type=openapi.TYPE_STRING, - description="Detailed description of the book" - ), - 'thumbnail_url': openapi.Schema( - type=openapi.TYPE_STRING, - description="URL to the book's thumbnail image", - nullable=True - ), - 'author': openapi.Schema( - type=openapi.TYPE_STRING, - description="Author of the book" - ), - 'language': openapi.Schema( - type=openapi.TYPE_INTEGER, - description="Language ID of the book", - nullable=True - ), + 'id': openapi.Schema(type=openapi.TYPE_INTEGER, description="Unique identifier for the book"), + 'title': openapi.Schema(type=openapi.TYPE_STRING, description="Title of the book"), + 'slug': openapi.Schema(type=openapi.TYPE_STRING, description="URL-friendly slug for the book"), + 'summary': openapi.Schema(type=openapi.TYPE_STRING, description="Brief summary of the book"), + 'summary_title': openapi.Schema(type=openapi.TYPE_STRING, description="Summary title", nullable=True), + 'slogan': openapi.Schema(type=openapi.TYPE_STRING, description="Short slogan", nullable=True), + 'thumbnail': thumbs_schema, + 'author': openapi.Schema(type=openapi.TYPE_STRING, description="Author full name", nullable=True), + 'author_detail': author_summary_schema, + 'publisher': openapi.Schema(type=openapi.TYPE_STRING, description="Publisher", nullable=True), + 'year_of_publication': openapi.Schema(type=openapi.TYPE_STRING, description="Year of publication", nullable=True), + 'isbn': openapi.Schema(type=openapi.TYPE_STRING, description="ISBN", nullable=True), + 'numnber_of_volume': openapi.Schema(type=openapi.TYPE_STRING, description="Volume number", nullable=True), + 'language': openapi.Schema(type=openapi.TYPE_STRING, description="Language code", nullable=True), 'main_themes': openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_STRING), @@ -113,36 +106,14 @@ book_schema = openapi.Schema( items=openapi.Schema(type=openapi.TYPE_STRING), description="List of notable works" ), - 'status': openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether the book is active/visible" - ), - 'pin': openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether the book is pinned to the top" - ), - 'view_count': openapi.Schema( - type=openapi.TYPE_INTEGER, - description="Number of views for the book" - ), - 'download_count': openapi.Schema( - type=openapi.TYPE_INTEGER, - description="Number of downloads for the book" - ), - 'file_type': openapi.Schema( - type=openapi.TYPE_STRING, - description="Type of the book file (PDF, EPUB, etc.)" - ), - 'book_file': openapi.Schema( - type=openapi.TYPE_STRING, - description="URL to the book file", - nullable=True - ), - 'created_at': openapi.Schema( - type=openapi.TYPE_STRING, - format=openapi.FORMAT_DATETIME, - description="Creation timestamp" - ) + 'status': openapi.Schema(type=openapi.TYPE_BOOLEAN, description="Whether the book is active/visible"), + 'pin': openapi.Schema(type=openapi.TYPE_BOOLEAN, description="Whether the book is pinned to the top"), + 'view_count': openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of views for the book"), + 'download_count': openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of downloads for the book"), + 'file_type': openapi.Schema(type=openapi.TYPE_STRING, description="Type of the book file (PDF, EPUB, etc.)"), + 'book_file': openapi.Schema(type=openapi.TYPE_STRING, description="URL to the book file", nullable=True), + 'created_at': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATETIME, description="Creation timestamp"), + 'share_link': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), }, required=['id', 'title', 'slug', 'status', 'created_at'] ) @@ -155,38 +126,61 @@ books_response = openapi.Response( 'count': openapi.Schema(type=openapi.TYPE_INTEGER), 'next': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), 'previous': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), - 'results': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=book_schema - ) + 'results': openapi.Schema(type=openapi.TYPE_ARRAY, items=book_schema), } ) ) -# Book detail response book_detail_response = openapi.Response( description="Detailed information about a specific book", schema=book_schema ) -# Swagger decorators for views +author_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER, description="Unique identifier for the author"), + 'full_name': openapi.Schema(type=openapi.TYPE_STRING, description="Full name of the author"), + 'slug': openapi.Schema(type=openapi.TYPE_STRING, description="URL-friendly slug for the author"), + 'thumbnail': thumbs_schema, + 'birth_date': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATE, nullable=True, description="Birth date"), + 'death_date': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATE, nullable=True, description="Death date"), + 'books_count': openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of active books"), + }, + required=['id', 'full_name', 'slug', 'books_count'] +) + +authors_response = openapi.Response( + description="List of authors with pagination", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'count': openapi.Schema(type=openapi.TYPE_INTEGER), + 'next': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'previous': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'results': openapi.Schema(type=openapi.TYPE_ARRAY, items=author_schema), + } + ) +) + +author_detail_response = openapi.Response( + description="Detailed information about a specific author", + schema=author_schema +) + + book_detail_swagger = swagger_auto_schema( operation_id="get_book_detail", operation_description=""" Retrieve detailed information about a specific book. This endpoint returns comprehensive information about a book, including: - - Basic book details (title, slug, summary, description) - - Thumbnail image URL + - Basic book details + - Thumbnail image URLs - Author information - Status and pin information - View and download counts - File type and book file URL - - Creation and update timestamps - - Categories and collections the book belongs to - - Number of pages - - The book is specified by its slug in the URL path. """, operation_summary="Get Book Detail", tags=["Dobodbi - Library"], @@ -203,29 +197,17 @@ book_list_swagger = swagger_auto_schema( operation_description=""" Retrieve a list of books with filtering and search capabilities. - This endpoint returns a paginated list of books. Each book includes its title, slug, - summary, description, thumbnail, author, status, pin, view count, download count, - file type, book file URL, and creation timestamp. - You can filter books by: - - Collection ID using the query parameter 'collection_id' - - Category slug(s) using the query parameter 'category' (single slug or comma-separated list) - - Bookmarked books using the query parameter 'is_bookmark=true' - - You can also search for books by title, summary, publisher, or isbn using the query parameter 'search'. - - You can sort books by: - - created_at, -created_at - - view_count, -view_count - - download_count, -download_count - - title, -title - - pin, -pin, -pin,-created_at - - Note: To get downloaded books, use the separate endpoint /books/downloaded/ + - Collection ID + - Category slug(s) + - Author slug + - Bookmarked books + + You can search for books by title, summary, publisher, or isbn. """, operation_summary="List Books", tags=["Dobodbi - Library"], - manual_parameters=[collection_id_param, category_param, is_bookmark_param, search_param, sort_param], + manual_parameters=[collection_id_param, category_param, author_param, is_bookmark_param, search_param, sort_param], responses={ 200: books_response, 401: "Authentication credentials were not provided or are invalid.", @@ -237,9 +219,6 @@ category_list_swagger = swagger_auto_schema( operation_id="list_categories", operation_description=""" Retrieve a list of book categories. - - This endpoint returns a paginated list of book categories. Each category includes its - title, slug, status, books count, and timestamps. """, operation_summary="List Book Categories", tags=["Dobodbi - Library"], @@ -254,9 +233,6 @@ pinned_collection_list_swagger = swagger_auto_schema( operation_id="list_pinned_collections", operation_description=""" Retrieve a list of pinned book collections with their top book covers. - - This endpoint returns a list of pinned book collections. Each collection includes its - title and the covers of its top books by view count. """, operation_summary="List Pinned Book Collections", tags=["Dobodbi - Library"], @@ -271,12 +247,6 @@ middle_collection_list_swagger = swagger_auto_schema( operation_id="list_middle_collections", operation_description=""" Retrieve a list of middle section book collections with their books. - - This endpoint returns a list of middle section book collections. Each collection includes its - title, slug, summary, status, order, and a list of books in the collection. - - Each book in the collection includes its id, title, slug, summary, thumbnail, author, - view count, download count, and file type. """, operation_summary="List Middle Section Book Collections", tags=["Dobodbi - Library"], @@ -285,4 +255,55 @@ middle_collection_list_swagger = swagger_auto_schema( 401: "Authentication credentials were not provided or are invalid.", 500: "Internal server error occurred." } -) \ No newline at end of file +) + +author_list_swagger = swagger_auto_schema( + operation_id="list_library_authors", + operation_description=""" + Retrieve a paginated list of library authors. + + Each author includes thumbnail, full name, and number of active books. + """, + operation_summary="List Authors", + tags=["Dobodbi - Library"], + responses={ + 200: authors_response, + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +author_detail_swagger = swagger_auto_schema( + operation_id="get_author_detail", + operation_description=""" + Retrieve detailed information about a specific author. + + This endpoint returns full name, birth date, death date, thumbnail, and books count. + """, + operation_summary="Get Author Detail", + tags=["Dobodbi - Library"], + responses={ + 200: author_detail_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified author does not exist.", + 500: "Internal server error occurred." + } +) + +author_books_swagger = swagger_auto_schema( + operation_id="list_author_books", + operation_description=""" + Retrieve a paginated list of books for a specific author. + + The author is identified by slug in the URL path. + """, + operation_summary="List Author Books", + tags=["Dobodbi - Library"], + manual_parameters=[is_bookmark_param, search_param, sort_param], + responses={ + 200: books_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified author does not exist.", + 500: "Internal server error occurred." + } +) diff --git a/apps/library/management/commands/populate_books.py b/apps/library/management/commands/populate_books.py index 7e434c0..fc7b0da 100644 --- a/apps/library/management/commands/populate_books.py +++ b/apps/library/management/commands/populate_books.py @@ -10,7 +10,7 @@ Usage: from django.core.management.base import BaseCommand from django.utils.text import slugify -from apps.library.models import Book, Category, BookCollection +from apps.library.models import Author, Book, Category, BookCollection from dj_language.models import Language import random from django.conf import settings @@ -394,6 +394,10 @@ class Command(BaseCommand): for idx, book_info in enumerate(book_data): try: slug = slugify(book_info['title']) + author_obj, _ = Author.objects.get_or_create( + full_name=book_info['author'], + defaults={'slug': slugify(book_info['author'])} + ) # Create the book instance (WITHOUT the image/file first) book = Book( @@ -405,7 +409,7 @@ class Command(BaseCommand): description=book_info['description'], publisher=book_info['publisher'], year_of_publication=book_info['year_of_publication'], - author=book_info['author'], + author=author_obj, isbn=book_info['isbn'], numnber_of_volume=book_info['numnber_of_volume'], language=language, @@ -511,4 +515,4 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS(f'Successfully created {len(created_books)} books!') ) - self.stdout.write(self.style.SUCCESS('='*50)) \ No newline at end of file + self.stdout.write(self.style.SUCCESS('='*50)) diff --git a/apps/library/migrations/0002_author_and_book_author_fk.py b/apps/library/migrations/0002_author_and_book_author_fk.py new file mode 100644 index 0000000..2e5f11a --- /dev/null +++ b/apps/library/migrations/0002_author_and_book_author_fk.py @@ -0,0 +1,74 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.utils.text import slugify + + +def forwards(apps, schema_editor): + Author = apps.get_model('library', 'Author') + Book = apps.get_model('library', 'Book') + + slug_counters = {} + + def build_unique_slug(name): + base = slugify(name) or 'author' + count = slug_counters.get(base, 0) + slug_counters[base] = count + 1 + return base if count == 0 else f"{base}-{count}" + + for book in Book.objects.exclude(author__isnull=True).exclude(author='').iterator(): + author_name = (book.author or '').strip() + if not author_name: + continue + + author_obj = Author.objects.filter(full_name=author_name).first() + if not author_obj: + author_obj = Author.objects.create( + full_name=author_name, + slug=build_unique_slug(author_name), + ) + + book.author_temp_id = author_obj.id + book.save(update_fields=['author_temp']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255, verbose_name='Full Name')), + ('slug', models.SlugField(max_length=255, unique=True)), + ('thumbnail', models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='author_thumbnails/', verbose_name='Thumbnail')), + ('birth_date', models.DateField(blank=True, null=True, verbose_name='Birth Date')), + ('death_date', models.DateField(blank=True, null=True, verbose_name='Death Date')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Author', + 'verbose_name_plural': 'Authors', + 'ordering': ['full_name', 'id'], + }, + ), + migrations.AddField( + model_name='book', + name='author_temp', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='library.author', verbose_name='Author'), + ), + migrations.RunPython(forwards, migrations.RunPython.noop), + migrations.RemoveField( + model_name='book', + name='author', + ), + migrations.RenameField( + model_name='book', + old_name='author_temp', + new_name='author', + ), + ] diff --git a/apps/library/models.py b/apps/library/models.py index 0fa5326..4a0509f 100644 --- a/apps/library/models.py +++ b/apps/library/models.py @@ -92,6 +92,39 @@ class Category(models.Model): verbose_name = _('Category') verbose_name_plural = _('Categories') + +class Author(models.Model): + full_name = models.CharField(max_length=255, verbose_name=_('Full Name')) + slug = models.SlugField(max_length=255, unique=True) + thumbnail = models.ImageField( + upload_to='author_thumbnails/', + null=True, + blank=True, + help_text=_('image allowed'), + verbose_name=_('Thumbnail') + ) + birth_date = models.DateField(null=True, blank=True, verbose_name=_('Birth Date')) + death_date = models.DateField(null=True, blank=True, verbose_name=_('Death Date')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Author') + verbose_name_plural = _('Authors') + ordering = ['full_name', 'id'] + + def __str__(self): + return self.full_name + + def save(self, *args, **kwargs): + if not self.slug or not self.slug.strip(): + self.slug = generate_smart_slug(self.full_name, Author, instance=self) + super().save(*args, **kwargs) + + @property + def books_count(self): + return self.books.filter(status=True).count() + class Book(models.Model): class FileType(models.TextChoices): @@ -111,7 +144,14 @@ class Book(models.Model): publisher = models.CharField(max_length=655, null=True, blank=True) year_of_publication = models.CharField(max_length=255, null=True, blank=True) - author = models.CharField(max_length=255, null=True, blank=True) + author = models.ForeignKey( + 'library.Author', + related_name='books', + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('Author') + ) isbn = models.CharField(max_length=255, null=True, blank=True) numnber_of_volume = models.CharField(max_length=255, null=True, blank=True) @@ -139,7 +179,8 @@ class Book(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) def __str__(self): - return f'<{self.id}>-{self.title}' + author_name = self.author.full_name if self.author_id else "-" + return f'<{self.id}>-{self.title} ({author_name})' @property def share_link(self): diff --git a/apps/library/serializers.py b/apps/library/serializers.py index 3e99b9e..67ca24d 100644 --- a/apps/library/serializers.py +++ b/apps/library/serializers.py @@ -46,6 +46,57 @@ class PinnedBookCollectionSerializer(serializers.ModelSerializer): model = BookCollection fields = ('id', 'title', 'summary', 'covers') + +class AuthorSummarySerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Author + fields = ('id', 'full_name', 'slug', 'thumbnail') + + def get_thumbnail(self, obj): + if obj.thumbnail: + return get_thumbs(obj.thumbnail, self.context.get('request')) + return None + + +class AuthorListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + books_count = serializers.SerializerMethodField() + + class Meta: + model = Author + fields = ('id', 'full_name', 'slug', 'thumbnail', 'books_count') + + def get_thumbnail(self, obj): + if obj.thumbnail: + return get_thumbs(obj.thumbnail, self.context.get('request')) + return None + + def get_books_count(self, obj): + if hasattr(obj, 'books_count_annotation'): + return obj.books_count_annotation + return obj.books_count + + +class AuthorDetailSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + books_count = serializers.SerializerMethodField() + + class Meta: + model = Author + fields = ('id', 'full_name', 'slug', 'thumbnail', 'birth_date', 'death_date', 'books_count') + + def get_thumbnail(self, obj): + if obj.thumbnail: + return get_thumbs(obj.thumbnail, self.context.get('request')) + return None + + def get_books_count(self, obj): + if hasattr(obj, 'books_count_annotation'): + return obj.books_count_annotation + return obj.books_count + from dj_language.models import Language class BookSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() @@ -53,6 +104,8 @@ class BookSerializer(serializers.ModelSerializer): user_rate = serializers.SerializerMethodField() average_rate = serializers.SerializerMethodField() share_link = serializers.CharField(read_only=True) + author = serializers.SerializerMethodField() + author_detail = AuthorSummarySerializer(source='author', read_only=True) language = serializers.SlugRelatedField( slug_field='code', queryset=Language.objects.all(), @@ -69,12 +122,15 @@ class BookSerializer(serializers.ModelSerializer): model = Book fields = ( 'id', 'title', 'slug', 'summary', 'summary_title', 'thumbnail', 'slogan', - 'status', 'pin', 'view_count', 'download_count', 'publisher', 'year_of_publication', 'author', 'isbn', 'numnber_of_volume', + 'status', 'pin', 'view_count', 'download_count', 'publisher', 'year_of_publication', 'author', 'author_detail', 'isbn', 'numnber_of_volume', 'language', 'main_themes', 'notable_works', 'file_type', 'book_file', 'created_at', 'bookmark', 'user_rate', 'average_rate', 'share_link' ) + def get_author(self, obj): + return obj.author.full_name if obj.author_id else None + def get_bookmark(self, obj): """ Get bookmark information for this book. diff --git a/apps/library/serializers_admin.py b/apps/library/serializers_admin.py index f4e6bef..4df0630 100644 --- a/apps/library/serializers_admin.py +++ b/apps/library/serializers_admin.py @@ -2,7 +2,7 @@ from rest_framework import serializers from django.core.files.uploadedfile import SimpleUploadedFile from utils.image_compression import maybe_compress_uploaded_file -from .models import Book, BookCollection, Category +from .models import Author, Book, BookCollection, Category class AbsoluteImageField(serializers.ImageField): @@ -51,10 +51,27 @@ class AdminLibraryCollectionSerializer(serializers.ModelSerializer): fields = ["id", "title", "slug", "summary", "display_position", "status", "order", "pin_top"] +class AdminAuthorSerializer(serializers.ModelSerializer): + thumbnail = AbsoluteImageField(required=False, allow_null=True) + books_count = serializers.SerializerMethodField() + + class Meta: + model = Author + fields = ["id", "full_name", "slug", "thumbnail", "birth_date", "death_date", "books_count", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at", "books_count"] + + def get_books_count(self, obj): + if hasattr(obj, 'books_count_annotation'): + return obj.books_count_annotation + return obj.books_count + + class AdminBookListSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(required=False, allow_null=True) book_file = AbsoluteFileField(required=False, allow_null=True) categories_detail = AdminLibraryCategorySerializer(source="categories", many=True, read_only=True) + author = serializers.CharField(source='author.full_name', read_only=True) + author_id = serializers.IntegerField(read_only=True) class Meta: model = Book @@ -74,6 +91,7 @@ class AdminBookListSerializer(serializers.ModelSerializer): "created_at", "updated_at", "categories_detail", + "author_id", "book_file", ] read_only_fields = ["id", "view_count", "download_count", "created_at", "updated_at"] @@ -82,6 +100,8 @@ from dj_language.models import Language class AdminBookDetailSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(required=False, allow_null=True) book_file = AbsoluteFileField(required=False, allow_null=True) + author = serializers.PrimaryKeyRelatedField(queryset=Author.objects.all(), required=False, allow_null=True) + author_detail = AdminAuthorSerializer(source='author', read_only=True) categories = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True, required=False) collections = serializers.PrimaryKeyRelatedField(queryset=BookCollection.objects.all(), many=True, required=False) categories_detail = AdminLibraryCategorySerializer(source="categories", many=True, read_only=True) @@ -109,6 +129,7 @@ class AdminBookDetailSerializer(serializers.ModelSerializer): "publisher", "year_of_publication", "author", + "author_detail", "isbn", "numnber_of_volume", "language", diff --git a/apps/library/urls.py b/apps/library/urls.py index b7f3570..e07eb45 100644 --- a/apps/library/urls.py +++ b/apps/library/urls.py @@ -7,13 +7,17 @@ from apps.library.views import ( MiddleBookCollectionListView, BookListView, BookDetailView, + AuthorListView, + AuthorDetailView, + AuthorBookListView, DownloadedBooksListView, BookDownloadCreateAPIView, ) -from apps.library.views_admin import AdminBookViewSet, AdminLibraryCategoryViewSet, AdminLibraryCollectionViewSet +from apps.library.views_admin import AdminAuthorViewSet, AdminBookViewSet, AdminLibraryCategoryViewSet, AdminLibraryCollectionViewSet admin_router = SimpleRouter() admin_router.register(r'books', AdminBookViewSet, basename='admin-library-books') +admin_router.register(r'authors', AdminAuthorViewSet, basename='admin-library-authors') admin_router.register(r'categories', AdminLibraryCategoryViewSet, basename='admin-library-categories') admin_router.register(r'collections', AdminLibraryCollectionViewSet, basename='admin-library-collections') @@ -22,6 +26,9 @@ urlpatterns = [ path('categories/', CategoryListView.as_view(), name='category-list'), path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), path('collections/', MiddleBookCollectionListView.as_view(), name='collection-list'), + path('authors/', AuthorListView.as_view(), name='author-list'), + path('authors//', AuthorDetailView.as_view(), name='author-detail'), + path('authors//books/', AuthorBookListView.as_view(), name='author-books'), path('books/', BookListView.as_view(), name='book-list'), path('books//', BookDetailView.as_view(), name='book-detail'), path('books/downloaded/', DownloadedBooksListView.as_view(), name='downloaded-books-list'), diff --git a/apps/library/views.py b/apps/library/views.py index ff42c48..1354523 100644 --- a/apps/library/views.py +++ b/apps/library/views.py @@ -14,6 +14,9 @@ from apps.library.models import * from apps.library.serializers import * from apps.account.models import User from apps.library.doc import ( + author_books_swagger, + author_detail_swagger, + author_list_swagger, book_list_swagger, book_detail_swagger, category_list_swagger, @@ -106,7 +109,7 @@ class BookListView(ListAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - queryset = Book.objects.filter(status=True) + queryset = Book.objects.filter(status=True).select_related('author') # Filter by collection if provided collection_id = self.request.query_params.get('collection_id') @@ -120,6 +123,10 @@ class BookListView(ListAPIView): category_slugs = [slug.strip() for slug in category.split(',')] queryset = queryset.filter(categories__slug__in=category_slugs).distinct() + author_slug = self.request.query_params.get('author') + if author_slug: + queryset = queryset.filter(author__slug=author_slug) + # Filter by middle collection if requested # if self.request.query_params.get('middle'): # middle_collections = BookCollection.objects.filter( @@ -241,6 +248,47 @@ class MiddleBookCollectionListView(ListAPIView): ).order_by('order') +class AuthorListView(ListAPIView): + serializer_class = AuthorListSerializer + permission_classes = (IsAuthenticated,) + authentication_classes = [TokenAuthentication] + pagination_class = StandardResultsSetPagination + + @author_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return Author.objects.annotate( + books_count_annotation=Count('books', filter=Q(books__status=True)) + ).order_by('full_name', 'id') + + +class AuthorDetailView(RetrieveAPIView): + serializer_class = AuthorDetailSerializer + permission_classes = (IsAuthenticated,) + authentication_classes = [TokenAuthentication] + lookup_field = 'slug' + + @author_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return Author.objects.annotate( + books_count_annotation=Count('books', filter=Q(books__status=True)) + ) + + +class AuthorBookListView(BookListView): + @author_books_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return super().get_queryset().filter(author__slug=self.kwargs['slug']) + + class DownloadedBooksListView(ListAPIView): """ API view to list books that have been downloaded by the current user diff --git a/apps/library/views_admin.py b/apps/library/views_admin.py index 603c255..ca1fbee 100644 --- a/apps/library/views_admin.py +++ b/apps/library/views_admin.py @@ -1,4 +1,4 @@ -from django.db.models import Q +from django.db.models import Count, Q from rest_framework.authentication import TokenAuthentication from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated @@ -7,8 +7,9 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination -from .models import Book, BookCollection, Category +from .models import Author, Book, BookCollection, Category from .serializers_admin import ( + AdminAuthorSerializer, AdminBookDetailSerializer, AdminBookListSerializer, AdminLibraryCategorySerializer, @@ -36,6 +37,26 @@ class AdminLibraryCollectionViewSet(ReadOnlyModelViewSet): return BookCollection.objects.all().order_by("display_position", "order", "title") +class AdminAuthorViewSet(ModelViewSet): + serializer_class = AdminAuthorSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = StandardResultsSetPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_queryset(self): + queryset = Author.objects.annotate(books_count_annotation=Count('books')).all() + + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(full_name__icontains=search_query) + | Q(slug__icontains=search_query) + ) + + return queryset.order_by("full_name", "id") + + class AdminBookViewSet(ModelViewSet): permission_classes = [IsAuthenticated, IsSuperAdmin] authentication_classes = [TokenAuthentication] @@ -48,14 +69,14 @@ class AdminBookViewSet(ModelViewSet): return AdminBookDetailSerializer def get_queryset(self): - queryset = Book.objects.all().prefetch_related("categories", "collections") + queryset = Book.objects.all().select_related("author").prefetch_related("categories", "collections") search_query = self.request.query_params.get("search") if search_query: queryset = queryset.filter( Q(title__icontains=search_query) | Q(slug__icontains=search_query) - | Q(author__icontains=search_query) + | Q(author__full_name__icontains=search_query) | Q(publisher__icontains=search_query) | Q(isbn__icontains=search_query) | Q(summary__icontains=search_query) @@ -77,4 +98,8 @@ class AdminBookViewSet(ModelViewSet): if collection_id and collection_id != "all": queryset = queryset.filter(collections__id=collection_id) + author_id = self.request.query_params.get("author") + if author_id and author_id != "all": + queryset = queryset.filter(author_id=author_id) + return queryset.distinct().order_by("-created_at")