From dba25d7b9a1e71e33bd4a0172a262ebfb39f3d2e Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Thu, 3 Jul 2025 22:52:16 +0700 Subject: [PATCH 1/3] feat:add API --- apps/books/api/filters.py | 41 ++++++++++++++++++++ apps/books/api/serializers.py | 71 +++++++++++++++++++++++++++++++++++ apps/books/api/urls.py | 23 ++++++++++++ apps/books/api/views.py | 55 +++++++++++++++++++++++++++ config/settings.py | 14 ++++++- config/urls.py | 3 +- 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 apps/books/api/filters.py create mode 100644 apps/books/api/serializers.py create mode 100644 apps/books/api/urls.py create mode 100644 apps/books/api/views.py diff --git a/apps/books/api/filters.py b/apps/books/api/filters.py new file mode 100644 index 0000000..861abd4 --- /dev/null +++ b/apps/books/api/filters.py @@ -0,0 +1,41 @@ +from django_filters import CharFilter, DateFilter, FilterSet +from apps.books.models import Book + + +class BookFilter(FilterSet): + title = CharFilter( + lookup_expr="icontains", + ) + author = CharFilter( + field_name="author__last_name", + lookup_expr="icontains", + ) + publisher = CharFilter( + field_name="publisher__name", + lookup_expr="icontains", + ) + tag = CharFilter( + field_name="tags__name", + lookup_expr="iexact", + ) + language = CharFilter( + lookup_expr="iexact", + ) + published_after = DateFilter( + field_name="published_at", + lookup_expr="gte", + ) + published_before = DateFilter( + field_name="published_at", + lookup_expr="lte", + ) + + class Meta: + model = Book + fields = [ + "title", + "author", + "publisher", + "tag", + "language", + ] diff --git a/apps/books/api/serializers.py b/apps/books/api/serializers.py new file mode 100644 index 0000000..4d5fa74 --- /dev/null +++ b/apps/books/api/serializers.py @@ -0,0 +1,71 @@ +from rest_framework import serializers + +from apps.books.models import ( + Publisher, + Author, + Tag, + Book, + Comment, +) + + +class PublisherSerializer(serializers.ModelSerializer): + class Meta: + model = Publisher + fields = "__all__" + + +class AuthorSerializer(serializers.ModelSerializer): + class Meta: + model = Author + fields = "__all__" + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = "__all__" + + +class BookSerializer(serializers.ModelSerializer): + author = AuthorSerializer(many=True, read_only=True) + publisher = PublisherSerializer(read_only=True) + + class Meta: + model = Book + fields = [ + "id", + "title", + "author", + "publisher", + "published_at", + ] + + +class BookDetailSerializer(BookSerializer): + tags = TagSerializer(many=True, read_only=True) + comments = serializers.SerializerMethodField() + + class Meta(BookSerializer.Meta): + fields = BookSerializer.Meta.fields + [ + "description", + "isbn_code", + "total_pages", + "cover_image", + "language", + "tags", + "comments", + ] + + def get_comments(self, obj): + comments = obj.comments.all()[:5] + return CommentSerializer(comments, many=True).data + + +class CommentSerializer(serializers.ModelSerializer): + user = serializers.StringRelatedField() + + class Meta: + model = Comment + fields = ["id", "text", "user", "created", "modified"] + read_only_fields = ["user", "created", "modified"] diff --git a/apps/books/api/urls.py b/apps/books/api/urls.py new file mode 100644 index 0000000..76f9069 --- /dev/null +++ b/apps/books/api/urls.py @@ -0,0 +1,23 @@ +from rest_framework import routers + +from django.urls import include, path + +from apps.books.api.views import ( + AuthorViewSet, + BookViewSet, + CommentViewSet, + PublisherViewSet, + TagViewSet, +) + +router = routers.DefaultRouter() + +router.register(r"authors", AuthorViewSet) +router.register(r"books", BookViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"publishers", PublisherViewSet) +router.register(r"tags", TagViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/books/api/views.py b/apps/books/api/views.py new file mode 100644 index 0000000..49d1b25 --- /dev/null +++ b/apps/books/api/views.py @@ -0,0 +1,55 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import viewsets + +from apps.books.api.filters import BookFilter +from apps.books.api.serializers import ( + AuthorSerializer, + BookDetailSerializer, + BookSerializer, + CommentSerializer, + PublisherSerializer, + TagSerializer, +) +from apps.books.models import ( + Author, + Book, + Comment, + Publisher, + Tag, +) + + +class PublisherViewSet(viewsets.ModelViewSet): + queryset = Publisher.objects.all() + serializer_class = PublisherSerializer + + +class AuthorViewSet(viewsets.ModelViewSet): + queryset = Author.objects.all() + serializer_class = AuthorSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + + +class BookViewSet(viewsets.ModelViewSet): + queryset = Book.objects.select_related("publisher").prefetch_related( + "author__books", "tags" + ) + filter_backends = [DjangoFilterBackend] + filterset_class = BookFilter + + def get_serializer_class(self): + if self.action == "retrieve": + return BookDetailSerializer + return BookSerializer + + +class CommentViewSet(viewsets.ModelViewSet): + queryset = Comment.objects.select_related("user", "book") + serializer_class = CommentSerializer + + def perform_create(self, serializer): + serializer.save(user=self.request.user) diff --git a/config/settings.py b/config/settings.py index 32012cf..d102cc2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -9,7 +9,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent -# Take environment variables from .env file +# Take environment variables from .env.example file environ.Env.read_env(os.path.join(BASE_DIR, ".env")) # ======================== @@ -33,6 +33,8 @@ "django.contrib.staticfiles", "apps.books.apps.BooksConfig", "django_extensions", + "rest_framework", + "django_filters", ] MIDDLEWARE = [ @@ -119,3 +121,13 @@ # DEFAULT PRIMARY KEY # ==================== DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], +} diff --git a/config/urls.py b/config/urls.py index 083932c..cd6173c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), + path("api/v1/", include("apps.books.api.urls")), ] From bdfdcb3ed8e04f82a8bfeb86b525121fe753cd2f Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 11 Jul 2025 01:00:27 +0700 Subject: [PATCH 2/3] fix: made some improvements --- apps/books/api/__init__.py | 0 apps/books/api/v1/__init__.py | 0 apps/books/api/{ => v1}/filters.py | 11 +++-------- apps/books/api/{ => v1}/serializers.py | 6 +++--- apps/books/api/{ => v1}/urls.py | 5 ++--- apps/books/api/{ => v1}/views.py | 7 ++++--- apps/books/models.py | 3 ++- config/urls.py | 2 +- 8 files changed, 15 insertions(+), 19 deletions(-) create mode 100644 apps/books/api/__init__.py create mode 100644 apps/books/api/v1/__init__.py rename apps/books/api/{ => v1}/filters.py (81%) rename apps/books/api/{ => v1}/serializers.py (98%) rename apps/books/api/{ => v1}/urls.py (92%) rename apps/books/api/{ => v1}/views.py (91%) diff --git a/apps/books/api/__init__.py b/apps/books/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/books/api/v1/__init__.py b/apps/books/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/books/api/filters.py b/apps/books/api/v1/filters.py similarity index 81% rename from apps/books/api/filters.py rename to apps/books/api/v1/filters.py index 861abd4..fb1d0e6 100644 --- a/apps/books/api/filters.py +++ b/apps/books/api/v1/filters.py @@ -1,5 +1,6 @@ from django_filters import CharFilter, DateFilter, FilterSet -from apps.books.models import Book + +from ...models import Book class BookFilter(FilterSet): @@ -32,10 +33,4 @@ class BookFilter(FilterSet): class Meta: model = Book - fields = [ - "title", - "author", - "publisher", - "tag", - "language", - ] + fields = [] diff --git a/apps/books/api/serializers.py b/apps/books/api/v1/serializers.py similarity index 98% rename from apps/books/api/serializers.py rename to apps/books/api/v1/serializers.py index 4d5fa74..c7040e6 100644 --- a/apps/books/api/serializers.py +++ b/apps/books/api/v1/serializers.py @@ -1,11 +1,11 @@ from rest_framework import serializers -from apps.books.models import ( - Publisher, +from ...models import ( Author, - Tag, Book, Comment, + Publisher, + Tag, ) diff --git a/apps/books/api/urls.py b/apps/books/api/v1/urls.py similarity index 92% rename from apps/books/api/urls.py rename to apps/books/api/v1/urls.py index 76f9069..72fddb9 100644 --- a/apps/books/api/urls.py +++ b/apps/books/api/v1/urls.py @@ -1,8 +1,7 @@ -from rest_framework import routers - from django.urls import include, path +from rest_framework import routers -from apps.books.api.views import ( +from .views import ( AuthorViewSet, BookViewSet, CommentViewSet, diff --git a/apps/books/api/views.py b/apps/books/api/v1/views.py similarity index 91% rename from apps/books/api/views.py rename to apps/books/api/v1/views.py index 49d1b25..ee9c72c 100644 --- a/apps/books/api/views.py +++ b/apps/books/api/v1/views.py @@ -1,8 +1,8 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets -from apps.books.api.filters import BookFilter -from apps.books.api.serializers import ( +from .filters import BookFilter +from .serializers import ( AuthorSerializer, BookDetailSerializer, BookSerializer, @@ -10,7 +10,8 @@ PublisherSerializer, TagSerializer, ) -from apps.books.models import ( + +from ...models import ( Author, Book, Comment, diff --git a/apps/books/models.py b/apps/books/models.py index 3e69fb7..e393583 100644 --- a/apps/books/models.py +++ b/apps/books/models.py @@ -1,5 +1,6 @@ -from django.db import models from django.contrib.auth import get_user_model +from django.db import models + from django_extensions.db.models import TimeStampedModel User = get_user_model() diff --git a/config/urls.py b/config/urls.py index cd6173c..5cef32f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,5 +3,5 @@ urlpatterns = [ path("admin/", admin.site.urls), - path("api/v1/", include("apps.books.api.urls")), + path("api/v1/", include("apps.books.api.v1.urls")), ] From 548616aed9e5ade280461457e4e3b03d7511846c Mon Sep 17 00:00:00 2001 From: Stanislav Khoshov Date: Thu, 17 Jul 2025 21:48:12 +0300 Subject: [PATCH 3/3] adds some improvements --- apps/books/api/v1/views.py | 15 +++++++-------- apps/books/management/commands/parse_books.py | 9 +++++---- apps/books/models.py | 1 - apps/books/scrapers/base_scraper.py | 2 +- apps/books/scrapers/piter_publ/book_parser.py | 5 +++-- apps/books/scrapers/piter_publ/link_extractor.py | 5 +++-- apps/books/scrapers/piter_publ/paginator.py | 3 ++- apps/books/scrapers/piter_publ/piter_scraper.py | 5 +++-- apps/books/services/book_saver.py | 5 +++-- apps/books/tasks.py | 2 +- apps/books/validators/validators.py | 3 ++- config/celery_app.py | 2 +- config/settings.py | 4 ++-- config/urls.py | 2 +- 14 files changed, 34 insertions(+), 29 deletions(-) diff --git a/apps/books/api/v1/views.py b/apps/books/api/v1/views.py index ee9c72c..e1da3f1 100644 --- a/apps/books/api/v1/views.py +++ b/apps/books/api/v1/views.py @@ -1,6 +1,13 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from ...models import ( + Author, + Book, + Comment, + Publisher, + Tag, +) from .filters import BookFilter from .serializers import ( AuthorSerializer, @@ -11,14 +18,6 @@ TagSerializer, ) -from ...models import ( - Author, - Book, - Comment, - Publisher, - Tag, -) - class PublisherViewSet(viewsets.ModelViewSet): queryset = Publisher.objects.all() diff --git a/apps/books/management/commands/parse_books.py b/apps/books/management/commands/parse_books.py index 3cb2238..457a64f 100644 --- a/apps/books/management/commands/parse_books.py +++ b/apps/books/management/commands/parse_books.py @@ -1,14 +1,15 @@ import asyncio +from urllib.parse import urljoin + from asgiref.sync import sync_to_async from django.core.management.base import BaseCommand -from urllib.parse import urljoin -from apps.books.models import Book, Author, Publisher -from apps.books.services.book_saver import BookSaver +from apps.books.models import Author, Book, Publisher +from apps.books.scrapers.base_scraper import BaseScraper from apps.books.scrapers.piter_publ.book_parser import BookParser from apps.books.scrapers.piter_publ.piter_scraper import PiterScraper -from apps.books.scrapers.base_scraper import BaseScraper from apps.books.services.author_service import AuthorService +from apps.books.services.book_saver import BookSaver from apps.books.services.publisher_service import PublisherService from logger.books.log import get_logger diff --git a/apps/books/models.py b/apps/books/models.py index e393583..8306b54 100644 --- a/apps/books/models.py +++ b/apps/books/models.py @@ -1,6 +1,5 @@ from django.contrib.auth import get_user_model from django.db import models - from django_extensions.db.models import TimeStampedModel User = get_user_model() diff --git a/apps/books/scrapers/base_scraper.py b/apps/books/scrapers/base_scraper.py index e47d582..4ea165d 100644 --- a/apps/books/scrapers/base_scraper.py +++ b/apps/books/scrapers/base_scraper.py @@ -1,6 +1,6 @@ import asyncio -import httpx +import httpx from bs4 import BeautifulSoup diff --git a/apps/books/scrapers/piter_publ/book_parser.py b/apps/books/scrapers/piter_publ/book_parser.py index 74f5883..13427cf 100644 --- a/apps/books/scrapers/piter_publ/book_parser.py +++ b/apps/books/scrapers/piter_publ/book_parser.py @@ -1,7 +1,8 @@ -from bs4 import BeautifulSoup -from typing import List, Dict +from typing import Dict, List from urllib.parse import urljoin +from bs4 import BeautifulSoup + from logger.books.log import get_logger logger = get_logger(__name__) diff --git a/apps/books/scrapers/piter_publ/link_extractor.py b/apps/books/scrapers/piter_publ/link_extractor.py index 843d202..99da1e1 100644 --- a/apps/books/scrapers/piter_publ/link_extractor.py +++ b/apps/books/scrapers/piter_publ/link_extractor.py @@ -1,6 +1,7 @@ -from bs4 import BeautifulSoup -from urllib.parse import urljoin from typing import List +from urllib.parse import urljoin + +from bs4 import BeautifulSoup from logger.books.log import get_logger diff --git a/apps/books/scrapers/piter_publ/paginator.py b/apps/books/scrapers/piter_publ/paginator.py index 55fcf62..44745cb 100644 --- a/apps/books/scrapers/piter_publ/paginator.py +++ b/apps/books/scrapers/piter_publ/paginator.py @@ -1,7 +1,8 @@ import re -from bs4 import BeautifulSoup from urllib.parse import urljoin +from bs4 import BeautifulSoup + from logger.books.log import get_logger logger = get_logger(__name__) diff --git a/apps/books/scrapers/piter_publ/piter_scraper.py b/apps/books/scrapers/piter_publ/piter_scraper.py index a5035c1..c390103 100644 --- a/apps/books/scrapers/piter_publ/piter_scraper.py +++ b/apps/books/scrapers/piter_publ/piter_scraper.py @@ -1,9 +1,10 @@ import asyncio +from logger.books.log import get_logger + from ..base_scraper import BaseScraper -from .paginator import Paginator from .link_extractor import LinkExtractor -from logger.books.log import get_logger +from .paginator import Paginator logger = get_logger(__name__) BASE_DOMAIN = "https://www.piter.com" diff --git a/apps/books/services/book_saver.py b/apps/books/services/book_saver.py index 25dbfde..292e4ac 100644 --- a/apps/books/services/book_saver.py +++ b/apps/books/services/book_saver.py @@ -1,11 +1,12 @@ from datetime import datetime + from django.db import transaction from pydantic import ValidationError -from apps.books.models import Book, Author, Publisher -from apps.books.validators.validators import BookInput +from apps.books.models import Author, Book, Publisher from apps.books.services.author_service import AuthorService from apps.books.services.publisher_service import PublisherService +from apps.books.validators.validators import BookInput from logger.books.log import get_logger logger = get_logger(__name__) diff --git a/apps/books/tasks.py b/apps/books/tasks.py index c043edf..5ecfe1a 100644 --- a/apps/books/tasks.py +++ b/apps/books/tasks.py @@ -3,12 +3,12 @@ from asgiref.sync import sync_to_async from celery import shared_task -from apps.books.scrapers.piter_publ.piter_scraper import PiterScraper from apps.books.management.commands.parse_books import ( AsyncBookFetcher, book_saver, logger, ) +from apps.books.scrapers.piter_publ.piter_scraper import PiterScraper @shared_task diff --git a/apps/books/validators/validators.py b/apps/books/validators/validators.py index 9dfb915..fd6e7d3 100644 --- a/apps/books/validators/validators.py +++ b/apps/books/validators/validators.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, field_validator from typing import List, Optional +from pydantic import BaseModel, Field, field_validator + class AuthorInput(BaseModel): first_name: str = "" diff --git a/config/celery_app.py b/config/celery_app.py index 58f5eaa..bd41a1c 100644 --- a/config/celery_app.py +++ b/config/celery_app.py @@ -1,6 +1,6 @@ import os -from celery import Celery +from celery import Celery os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") diff --git a/config/settings.py b/config/settings.py index 4e34c98..b8c78ae 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,9 +1,8 @@ import os - from pathlib import Path -from celery.schedules import crontab import environ +from celery.schedules import crontab # Initialize environment variables env = environ.Env() @@ -135,6 +134,7 @@ "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", ], +} # ==================== # CELERY SETTINGS diff --git a/config/urls.py b/config/urls.py index 5cef32f..f513c10 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.urls import path, include +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls),