Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/books/api/v1/book_elastic_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django_elasticsearch_dsl_drf.serializers import DocumentSerializer
from rest_framework import serializers

from apps.books.documents import BookDocument


class BookDocumentSerializer(DocumentSerializer):
"""Сериализатор для Elasticsearch документа Book"""

author_display = serializers.SerializerMethodField()
publisher_name = serializers.SerializerMethodField()
tags_display = serializers.SerializerMethodField()
search_score = serializers.SerializerMethodField()

class Meta:
document = BookDocument
fields = [
"id",
"title",
"description",
"published_at",
"isbn_code",
"total_pages",
"cover_image",
"language",
"author",
"publisher",
"tags",
"author_display",
"publisher_name",
"tags_display",
"search_score",
]

def get_author_display(self, obj):
"""Форматирует авторов в строку: 'Иван Петров, Мария Сидорова'"""
if not obj.author:
return ""

authors_list = []
for author in obj.author:
parts = []
if hasattr(author, "first_name") and author.first_name:
parts.append(author.first_name)
if hasattr(author, "last_name") and author.last_name:
parts.append(author.last_name)

if parts:
authors_list.append(" ".join(parts))

return ", ".join(authors_list) if authors_list else ""

def get_publisher_name(self, obj):
"""Возвращает только название издательства"""
if (
hasattr(obj, "publisher")
and obj.publisher
and hasattr(obj.publisher, "name")
):
return obj.publisher.name
return ""

def get_tags_display(self, obj):
"""Возвращает список названий тегов"""
if not obj.tags:
return []

tag_names = []
for tag in obj.tags:
if hasattr(tag, "name") and tag.name:
tag_names.append(tag.name)

return tag_names

def get_search_score(self, obj):
"""Возвращает score релевантности из Elasticsearch"""
return getattr(obj.meta, "score", None)
71 changes: 71 additions & 0 deletions apps/books/api/v1/book_elastic_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from django_elasticsearch_dsl_drf.constants import (
SUGGESTER_COMPLETION,
)
from django_elasticsearch_dsl_drf.filter_backends import (
FilteringFilterBackend,
OrderingFilterBackend,
SearchFilterBackend,
SuggesterFilterBackend,
)
from django_elasticsearch_dsl_drf.viewsets import DocumentViewSet

from apps.books.documents import BookDocument

from .book_elastic_serializer import BookDocumentSerializer
from .pagination import CustomPageNumberPagination


class BookDocumentView(DocumentViewSet):
"""
ViewSet для поиска книг через Elasticsearch
Отдельный от обычных CRUD операций
"""

document = BookDocument
serializer_class = BookDocumentSerializer
pagination_class = CustomPageNumberPagination
# Настройки поиска
filter_backends = [
SearchFilterBackend, # Полнотекстовый поиск
FilteringFilterBackend, # Фильтрация
OrderingFilterBackend, # Сортировка
SuggesterFilterBackend, # Подсказки (для фронтенда)
]

# Поля для полнотекстового поиска
search_fields = {
"title": {"boost": 4, "analyzer": "standard"},
"description": {"boost": 2, "analyzer": "standard"},
"author.first_name": {"boost": 3, "analyzer": "standard"},
"author.last_name": {"boost": 3, "analyzer": "standard"},
"publisher.name": {"boost": 1, "analyzer": "standard"},
"tags.name": {"boost": 1, "analyzer": "standard"},
}

# Поля для точной фильтрации
filter_fields = {
"language": "language",
"total_pages": "total_pages",
"published_at": "published_at",
"isbn_code": "isbn_code.raw",
"tags.slug": "tags.slug",
}

# Поля для сортировки
ordering_fields = {
"title": "title.raw",
"published_at": "published_at",
"total_pages": "total_pages",
"score": "_score", # релевантность
}

# Сортировка по умолчанию
ordering = ("-published_at",)

# Подсказки для автодополнения (для фронтенда)
suggester_fields = {
"title_suggest": {
"field": "title.suggest",
"suggesters": [SUGGESTER_COMPLETION],
},
}
7 changes: 7 additions & 0 deletions apps/books/api/v1/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from rest_framework.pagination import PageNumberPagination


class CustomPageNumberPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"
max_page_size = 1000
5 changes: 5 additions & 0 deletions apps/books/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import include, path
from rest_framework import routers

from .book_elastic_views import BookDocumentView
from .views import (
AuthorViewSet,
BookViewSet,
Expand All @@ -17,6 +18,10 @@
router.register(r"publishers", PublisherViewSet)
router.register(r"tags", TagViewSet)

search_router = routers.DefaultRouter()
search_router.register(r"", BookDocumentView, basename="book-search")

urlpatterns = [
path("", include(router.urls)),
path("search/", include(search_router.urls)),
]
102 changes: 102 additions & 0 deletions apps/books/documents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from django_elasticsearch_dsl import (
Document,
Index,
fields,
)
from django_elasticsearch_dsl.registries import registry

from .models import (
Author,
Book,
Comment,
Publisher,
Tag,
)

# Определяем индекс
book_index = Index("books")
book_index.settings(
number_of_shards=1,
number_of_replicas=0,
)


@registry.register_document
class BookDocument(Document):
# Связанные поля
author = fields.NestedField(
properties={
"first_name": fields.TextField(analyzer="russian"),
"last_name": fields.TextField(analyzer="russian"),
"bio": fields.TextField(analyzer="russian"),
}
)

publisher = fields.ObjectField(
properties={
"name": fields.TextField(analyzer="russian"),
"website": fields.TextField(),
}
)

tags = fields.NestedField(
properties={
"name": fields.TextField(),
"slug": fields.KeywordField(),
"color": fields.KeywordField(),
}
)

comments = fields.NestedField(
properties={
"text": fields.TextField(analyzer="russian"),
"user": fields.ObjectField(
properties={
"id": fields.IntegerField(),
"username": fields.TextField(),
"email": fields.TextField(),
}
),
"created": fields.DateField(),
"modified": fields.DateField(),
}
)

class Index:
# Имя индекса
name = "books"
settings = {
"number_of_shards": 1,
"number_of_replicas": 0,
}

class Django:
model = Book # Модель
fields = [
"title",
"description",
"published_at",
"isbn_code",
"total_pages",
"cover_image",
"language",
]
exclude = [
"created",
"modified",
]

related_models = [Author, Publisher, Tag, Comment]

def get_instances_from_related(self, related_instance):
"""
Когда обновляется связанная модель — обновляем индекс книги
"""
if isinstance(related_instance, Author):
return related_instance.books.all()
elif isinstance(related_instance, Publisher):
return related_instance.books.all()
elif isinstance(related_instance, Tag):
return related_instance.books.all()
elif isinstance(related_instance, Comment):
return [related_instance.book]
12 changes: 10 additions & 2 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"django_extensions",
"django_filters",
"rest_framework",
"django_elasticsearch_dsl",
"django_elasticsearch_dsl_drf",
# Project apps
"books",
]
Expand Down Expand Up @@ -137,8 +139,8 @@
# ====================
REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_PAGINATION_CLASS": "books.api.v1.pagination.CustomPageNumberPagination",
"PAGE_SIZE": 100,
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
Expand All @@ -157,6 +159,12 @@
}
}

# ====================
# Elasticsearch configuration
# ====================
ELASTICSEARCH_DSL = {
"default": {"hosts": os.getenv("ELASTICSEARCH_HOSTS", "elasticsearch:9200")},
}
# ====================
# CORS SETTINGS
# ====================
Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,25 @@ services:
networks:
- pythonbooks-network

elasticsearch:
image: elasticsearch:7.17.9
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
networks:
- pythonbooks-network

volumes:
postgres_data:
driver: local
redis_data:
driver: local
elasticsearch_data:
driver: local

networks:
pythonbooks-network:
Expand Down
16 changes: 11 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@
name = "pythonbooks"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
requires-python = ">=3.11"

dependencies = [
"django-ckeditor-5==0.2.17",
"django-environ>=0.12.0",
"django-extensions>=4.1",
"django==5.1.7",
"djangorestframework>=3.16.0",
"django==4.2.10", # стабильная версия для совместимости с ES 7
"djangorestframework>=3.15.0",
"drf-spectacular>=0.28.0",
"psycopg2-binary>=2.9.10",
"sorl-thumbnail==12.11.0",
"python-dotenv>=1.0.0",
"celery==5.5.3",
"celery==5.3.0", # проверенная версия
"billiard==4.2.1",
"kombu==5.5.4",
"kombu==5.3.0",
"vine==5.1.0",
"redis>=6.2.0",
"setuptools>=60.0",
"django-filter>=23.5",
"pydantic>=2.10.4",
"aiohttp>=3.11.11",
Expand All @@ -26,6 +28,10 @@ dependencies = [
"loguru>=0.7.0",
"httpx>=0.28.1",
"django-celery-beat>=2.8.1",
"django-elasticsearch-dsl==7.4.0",
"elasticsearch==7.17.0",
"elasticsearch-dsl==7.4.0",
"django-elasticsearch-dsl-drf==0.22.1",
"django-cors-headers>=4.7.0",
"gunicorn>=23.0.0",
]
Expand Down
Loading