Skip to content

Commit 4536c76

Browse files
authored
Merge pull request #58 from khoshov/feature/add_elasticsearch
feat:add elasticsearch
2 parents 132a89e + 7e82c99 commit 4536c76

9 files changed

Lines changed: 840 additions & 24 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from django_elasticsearch_dsl_drf.serializers import DocumentSerializer
2+
from rest_framework import serializers
3+
4+
from apps.books.documents import BookDocument
5+
6+
7+
class BookDocumentSerializer(DocumentSerializer):
8+
"""Сериализатор для Elasticsearch документа Book"""
9+
10+
author_display = serializers.SerializerMethodField()
11+
publisher_name = serializers.SerializerMethodField()
12+
tags_display = serializers.SerializerMethodField()
13+
search_score = serializers.SerializerMethodField()
14+
15+
class Meta:
16+
document = BookDocument
17+
fields = [
18+
"id",
19+
"title",
20+
"description",
21+
"published_at",
22+
"isbn_code",
23+
"total_pages",
24+
"cover_image",
25+
"language",
26+
"author",
27+
"publisher",
28+
"tags",
29+
"author_display",
30+
"publisher_name",
31+
"tags_display",
32+
"search_score",
33+
]
34+
35+
def get_author_display(self, obj):
36+
"""Форматирует авторов в строку: 'Иван Петров, Мария Сидорова'"""
37+
if not obj.author:
38+
return ""
39+
40+
authors_list = []
41+
for author in obj.author:
42+
parts = []
43+
if hasattr(author, "first_name") and author.first_name:
44+
parts.append(author.first_name)
45+
if hasattr(author, "last_name") and author.last_name:
46+
parts.append(author.last_name)
47+
48+
if parts:
49+
authors_list.append(" ".join(parts))
50+
51+
return ", ".join(authors_list) if authors_list else ""
52+
53+
def get_publisher_name(self, obj):
54+
"""Возвращает только название издательства"""
55+
if (
56+
hasattr(obj, "publisher")
57+
and obj.publisher
58+
and hasattr(obj.publisher, "name")
59+
):
60+
return obj.publisher.name
61+
return ""
62+
63+
def get_tags_display(self, obj):
64+
"""Возвращает список названий тегов"""
65+
if not obj.tags:
66+
return []
67+
68+
tag_names = []
69+
for tag in obj.tags:
70+
if hasattr(tag, "name") and tag.name:
71+
tag_names.append(tag.name)
72+
73+
return tag_names
74+
75+
def get_search_score(self, obj):
76+
"""Возвращает score релевантности из Elasticsearch"""
77+
return getattr(obj.meta, "score", None)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from django_elasticsearch_dsl_drf.constants import (
2+
SUGGESTER_COMPLETION,
3+
)
4+
from django_elasticsearch_dsl_drf.filter_backends import (
5+
FilteringFilterBackend,
6+
OrderingFilterBackend,
7+
SearchFilterBackend,
8+
SuggesterFilterBackend,
9+
)
10+
from django_elasticsearch_dsl_drf.viewsets import DocumentViewSet
11+
12+
from apps.books.documents import BookDocument
13+
14+
from .book_elastic_serializer import BookDocumentSerializer
15+
from .pagination import CustomPageNumberPagination
16+
17+
18+
class BookDocumentView(DocumentViewSet):
19+
"""
20+
ViewSet для поиска книг через Elasticsearch
21+
Отдельный от обычных CRUD операций
22+
"""
23+
24+
document = BookDocument
25+
serializer_class = BookDocumentSerializer
26+
pagination_class = CustomPageNumberPagination
27+
# Настройки поиска
28+
filter_backends = [
29+
SearchFilterBackend, # Полнотекстовый поиск
30+
FilteringFilterBackend, # Фильтрация
31+
OrderingFilterBackend, # Сортировка
32+
SuggesterFilterBackend, # Подсказки (для фронтенда)
33+
]
34+
35+
# Поля для полнотекстового поиска
36+
search_fields = {
37+
"title": {"boost": 4, "analyzer": "standard"},
38+
"description": {"boost": 2, "analyzer": "standard"},
39+
"author.first_name": {"boost": 3, "analyzer": "standard"},
40+
"author.last_name": {"boost": 3, "analyzer": "standard"},
41+
"publisher.name": {"boost": 1, "analyzer": "standard"},
42+
"tags.name": {"boost": 1, "analyzer": "standard"},
43+
}
44+
45+
# Поля для точной фильтрации
46+
filter_fields = {
47+
"language": "language",
48+
"total_pages": "total_pages",
49+
"published_at": "published_at",
50+
"isbn_code": "isbn_code.raw",
51+
"tags.slug": "tags.slug",
52+
}
53+
54+
# Поля для сортировки
55+
ordering_fields = {
56+
"title": "title.raw",
57+
"published_at": "published_at",
58+
"total_pages": "total_pages",
59+
"score": "_score", # релевантность
60+
}
61+
62+
# Сортировка по умолчанию
63+
ordering = ("-published_at",)
64+
65+
# Подсказки для автодополнения (для фронтенда)
66+
suggester_fields = {
67+
"title_suggest": {
68+
"field": "title.suggest",
69+
"suggesters": [SUGGESTER_COMPLETION],
70+
},
71+
}

apps/books/api/v1/pagination.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from rest_framework.pagination import PageNumberPagination
2+
3+
4+
class CustomPageNumberPagination(PageNumberPagination):
5+
page_size = 100
6+
page_size_query_param = "page_size"
7+
max_page_size = 1000

apps/books/api/v1/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.urls import include, path
22
from rest_framework import routers
33

4+
from .book_elastic_views import BookDocumentView
45
from .views import (
56
AuthorViewSet,
67
BookViewSet,
@@ -17,6 +18,10 @@
1718
router.register(r"publishers", PublisherViewSet)
1819
router.register(r"tags", TagViewSet)
1920

21+
search_router = routers.DefaultRouter()
22+
search_router.register(r"", BookDocumentView, basename="book-search")
23+
2024
urlpatterns = [
2125
path("", include(router.urls)),
26+
path("search/", include(search_router.urls)),
2227
]

apps/books/documents.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from django_elasticsearch_dsl import (
2+
Document,
3+
Index,
4+
fields,
5+
)
6+
from django_elasticsearch_dsl.registries import registry
7+
8+
from .models import (
9+
Author,
10+
Book,
11+
Comment,
12+
Publisher,
13+
Tag,
14+
)
15+
16+
# Определяем индекс
17+
book_index = Index("books")
18+
book_index.settings(
19+
number_of_shards=1,
20+
number_of_replicas=0,
21+
)
22+
23+
24+
@registry.register_document
25+
class BookDocument(Document):
26+
# Связанные поля
27+
author = fields.NestedField(
28+
properties={
29+
"first_name": fields.TextField(analyzer="russian"),
30+
"last_name": fields.TextField(analyzer="russian"),
31+
"bio": fields.TextField(analyzer="russian"),
32+
}
33+
)
34+
35+
publisher = fields.ObjectField(
36+
properties={
37+
"name": fields.TextField(analyzer="russian"),
38+
"website": fields.TextField(),
39+
}
40+
)
41+
42+
tags = fields.NestedField(
43+
properties={
44+
"name": fields.TextField(),
45+
"slug": fields.KeywordField(),
46+
"color": fields.KeywordField(),
47+
}
48+
)
49+
50+
comments = fields.NestedField(
51+
properties={
52+
"text": fields.TextField(analyzer="russian"),
53+
"user": fields.ObjectField(
54+
properties={
55+
"id": fields.IntegerField(),
56+
"username": fields.TextField(),
57+
"email": fields.TextField(),
58+
}
59+
),
60+
"created": fields.DateField(),
61+
"modified": fields.DateField(),
62+
}
63+
)
64+
65+
class Index:
66+
# Имя индекса
67+
name = "books"
68+
settings = {
69+
"number_of_shards": 1,
70+
"number_of_replicas": 0,
71+
}
72+
73+
class Django:
74+
model = Book # Модель
75+
fields = [
76+
"title",
77+
"description",
78+
"published_at",
79+
"isbn_code",
80+
"total_pages",
81+
"cover_image",
82+
"language",
83+
]
84+
exclude = [
85+
"created",
86+
"modified",
87+
]
88+
89+
related_models = [Author, Publisher, Tag, Comment]
90+
91+
def get_instances_from_related(self, related_instance):
92+
"""
93+
Когда обновляется связанная модель — обновляем индекс книги
94+
"""
95+
if isinstance(related_instance, Author):
96+
return related_instance.books.all()
97+
elif isinstance(related_instance, Publisher):
98+
return related_instance.books.all()
99+
elif isinstance(related_instance, Tag):
100+
return related_instance.books.all()
101+
elif isinstance(related_instance, Comment):
102+
return [related_instance.book]

config/settings.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"django_extensions",
4343
"django_filters",
4444
"rest_framework",
45+
"django_elasticsearch_dsl",
46+
"django_elasticsearch_dsl_drf",
4547
# Project apps
4648
"books",
4749
]
@@ -137,8 +139,8 @@
137139
# ====================
138140
REST_FRAMEWORK = {
139141
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
140-
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
141-
"PAGE_SIZE": 20,
142+
"DEFAULT_PAGINATION_CLASS": "books.api.v1.pagination.CustomPageNumberPagination",
143+
"PAGE_SIZE": 100,
142144
"DEFAULT_AUTHENTICATION_CLASSES": [
143145
"rest_framework.authentication.SessionAuthentication",
144146
"rest_framework.authentication.TokenAuthentication",
@@ -157,6 +159,12 @@
157159
}
158160
}
159161

162+
# ====================
163+
# Elasticsearch configuration
164+
# ====================
165+
ELASTICSEARCH_DSL = {
166+
"default": {"hosts": os.getenv("ELASTICSEARCH_HOSTS", "elasticsearch:9200")},
167+
}
160168
# ====================
161169
# CORS SETTINGS
162170
# ====================

docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,25 @@ services:
115115
networks:
116116
- pythonbooks-network
117117

118+
elasticsearch:
119+
image: elasticsearch:7.17.9
120+
environment:
121+
- discovery.type=single-node
122+
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
123+
volumes:
124+
- elasticsearch_data:/usr/share/elasticsearch/data
125+
ports:
126+
- "9200:9200"
127+
networks:
128+
- pythonbooks-network
129+
118130
volumes:
119131
postgres_data:
120132
driver: local
121133
redis_data:
122134
driver: local
135+
elasticsearch_data:
136+
driver: local
123137

124138
networks:
125139
pythonbooks-network:

pyproject.toml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,24 @@
22
name = "pythonbooks"
33
version = "0.1.0"
44
description = "Add your description here"
5-
requires-python = ">=3.13"
5+
requires-python = ">=3.11"
6+
67
dependencies = [
78
"django-ckeditor-5==0.2.17",
89
"django-environ>=0.12.0",
910
"django-extensions>=4.1",
10-
"django==5.1.7",
11-
"djangorestframework>=3.16.0",
11+
"django==4.2.10", # стабильная версия для совместимости с ES 7
12+
"djangorestframework>=3.15.0",
1213
"drf-spectacular>=0.28.0",
1314
"psycopg2-binary>=2.9.10",
1415
"sorl-thumbnail==12.11.0",
1516
"python-dotenv>=1.0.0",
16-
"celery==5.5.3",
17+
"celery==5.3.0", # проверенная версия
1718
"billiard==4.2.1",
18-
"kombu==5.5.4",
19+
"kombu==5.3.0",
1920
"vine==5.1.0",
2021
"redis>=6.2.0",
22+
"setuptools>=60.0",
2123
"django-filter>=23.5",
2224
"pydantic>=2.10.4",
2325
"aiohttp>=3.11.11",
@@ -26,6 +28,10 @@ dependencies = [
2628
"loguru>=0.7.0",
2729
"httpx>=0.28.1",
2830
"django-celery-beat>=2.8.1",
31+
"django-elasticsearch-dsl==7.4.0",
32+
"elasticsearch==7.17.0",
33+
"elasticsearch-dsl==7.4.0",
34+
"django-elasticsearch-dsl-drf==0.22.1",
2935
"django-cors-headers>=4.7.0",
3036
"gunicorn>=23.0.0",
3137
]

0 commit comments

Comments
 (0)