drf-haystack 1.9.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- drf_haystack/__init__.py +6 -0
- drf_haystack/constants.py +5 -0
- drf_haystack/fields.py +102 -0
- drf_haystack/filters.py +257 -0
- drf_haystack/generics.py +102 -0
- drf_haystack/mixins.py +116 -0
- drf_haystack/query.py +331 -0
- drf_haystack/serializers.py +489 -0
- drf_haystack/utils.py +26 -0
- drf_haystack/viewsets.py +11 -0
- drf_haystack-1.9.3.dist-info/METADATA +101 -0
- drf_haystack-1.9.3.dist-info/RECORD +13 -0
- drf_haystack-1.9.3.dist-info/WHEEL +4 -0
drf_haystack/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
DRF_HAYSTACK_NEGATION_KEYWORD = getattr(settings, "DRF_HAYSTACK_NEGATION_KEYWORD", "not")
|
|
4
|
+
GEO_SRID = getattr(settings, "GEO_SRID", 4326)
|
|
5
|
+
DRF_HAYSTACK_SPATIAL_QUERY_PARAM = getattr(settings, "DRF_HAYSTACK_SPATIAL_QUERY_PARAM", "from")
|
drf_haystack/fields.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from rest_framework import fields
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DRFHaystackFieldMixin:
|
|
5
|
+
prefix_field_names = False
|
|
6
|
+
|
|
7
|
+
def __init__(self, **kwargs):
|
|
8
|
+
self.prefix_field_names = kwargs.pop("prefix_field_names", False)
|
|
9
|
+
super().__init__(**kwargs)
|
|
10
|
+
|
|
11
|
+
def bind(self, field_name, parent):
|
|
12
|
+
"""
|
|
13
|
+
Initializes the field name and parent for the field instance.
|
|
14
|
+
Called when a field is added to the parent serializer instance.
|
|
15
|
+
Taken from DRF and modified to support drf_haystack multiple index
|
|
16
|
+
functionality.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# In order to enforce a consistent style, we error if a redundant
|
|
20
|
+
# 'source' argument has been used. For example:
|
|
21
|
+
# my_field = serializer.CharField(source='my_field')
|
|
22
|
+
assert self.source != field_name, (
|
|
23
|
+
f"It is redundant to specify `source='{field_name}'` on field '{self.__class__.__name__}' in "
|
|
24
|
+
f"serializer '{parent.__class__.__name__}', because it is the same as the field name. "
|
|
25
|
+
"Remove the `source` keyword argument."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
self.field_name = field_name
|
|
29
|
+
self.parent = parent
|
|
30
|
+
|
|
31
|
+
# `self.label` should default to being based on the field name.
|
|
32
|
+
if self.label is None:
|
|
33
|
+
self.label = field_name.replace("_", " ").capitalize()
|
|
34
|
+
|
|
35
|
+
# self.source should default to being the same as the field name.
|
|
36
|
+
if self.source is None:
|
|
37
|
+
self.source = self.convert_field_name(field_name)
|
|
38
|
+
|
|
39
|
+
# self.source_attrs is a list of attributes that need to be looked up
|
|
40
|
+
# when serializing the instance, or populating the validated data.
|
|
41
|
+
if self.source == "*":
|
|
42
|
+
self.source_attrs = []
|
|
43
|
+
else:
|
|
44
|
+
self.source_attrs = self.source.split(".")
|
|
45
|
+
|
|
46
|
+
def convert_field_name(self, field_name):
|
|
47
|
+
if not self.prefix_field_names:
|
|
48
|
+
return field_name
|
|
49
|
+
return field_name.split("__")[-1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HaystackBooleanField(DRFHaystackFieldMixin, fields.BooleanField):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HaystackCharField(DRFHaystackFieldMixin, fields.CharField):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HaystackDateField(DRFHaystackFieldMixin, fields.DateField):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HaystackDateTimeField(DRFHaystackFieldMixin, fields.DateTimeField):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HaystackDecimalField(DRFHaystackFieldMixin, fields.DecimalField):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HaystackFloatField(DRFHaystackFieldMixin, fields.FloatField):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HaystackIntegerField(DRFHaystackFieldMixin, fields.IntegerField):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HaystackMultiValueField(DRFHaystackFieldMixin, fields.ListField):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FacetDictField(fields.DictField):
|
|
85
|
+
"""
|
|
86
|
+
A special DictField which passes the key attribute down to the children's
|
|
87
|
+
``to_representation()`` in order to let the serializer know what field they're
|
|
88
|
+
currently processing.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def to_representation(self, value):
|
|
92
|
+
return {str(key): self.child.to_representation(key, val) for key, val in value.items()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FacetListField(fields.ListField):
|
|
96
|
+
"""
|
|
97
|
+
The ``FacetListField`` just pass along the key derived from
|
|
98
|
+
``FacetDictField``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def to_representation(self, key, data):
|
|
102
|
+
return [self.child.to_representation(key, item) for item in data]
|
drf_haystack/filters.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import operator
|
|
2
|
+
from functools import reduce
|
|
3
|
+
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from haystack.query import SearchQuerySet
|
|
6
|
+
from rest_framework.filters import BaseFilterBackend, OrderingFilter
|
|
7
|
+
|
|
8
|
+
from drf_haystack.query import BoostQueryBuilder, FacetQueryBuilder, FilterQueryBuilder, SpatialQueryBuilder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseHaystackFilterBackend(BaseFilterBackend):
|
|
12
|
+
"""
|
|
13
|
+
A base class from which all Haystack filter backend classes should inherit.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
query_builder_class = None
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def get_request_filters(request):
|
|
20
|
+
return request.query_params.copy()
|
|
21
|
+
|
|
22
|
+
def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None):
|
|
23
|
+
"""
|
|
24
|
+
Apply constructed filters and excludes and return the queryset
|
|
25
|
+
|
|
26
|
+
:param queryset: queryset to filter
|
|
27
|
+
:param applicable_filters: filters which are passed directly to queryset.filter()
|
|
28
|
+
:param applicable_exclusions: filters which are passed directly to queryset.exclude()
|
|
29
|
+
:returns filtered queryset
|
|
30
|
+
"""
|
|
31
|
+
if applicable_filters:
|
|
32
|
+
queryset = queryset.filter(applicable_filters)
|
|
33
|
+
if applicable_exclusions:
|
|
34
|
+
queryset = queryset.exclude(applicable_exclusions)
|
|
35
|
+
return queryset
|
|
36
|
+
|
|
37
|
+
def build_filters(self, view, filters=None):
|
|
38
|
+
"""
|
|
39
|
+
Get the query builder instance and return constructed query filters.
|
|
40
|
+
"""
|
|
41
|
+
query_builder = self.get_query_builder(backend=self, view=view)
|
|
42
|
+
return query_builder.build_query(**(filters or {}))
|
|
43
|
+
|
|
44
|
+
def process_filters(self, filters, queryset, view):
|
|
45
|
+
"""
|
|
46
|
+
Convenient hook to do any post-processing of the filters before they
|
|
47
|
+
are applied to the queryset.
|
|
48
|
+
"""
|
|
49
|
+
return filters
|
|
50
|
+
|
|
51
|
+
def filter_queryset(self, request, queryset, view):
|
|
52
|
+
"""
|
|
53
|
+
Return the filtered queryset.
|
|
54
|
+
"""
|
|
55
|
+
applicable_filters, applicable_exclusions = self.build_filters(view, filters=self.get_request_filters(request))
|
|
56
|
+
return self.apply_filters(
|
|
57
|
+
queryset=queryset,
|
|
58
|
+
applicable_filters=self.process_filters(applicable_filters, queryset, view),
|
|
59
|
+
applicable_exclusions=self.process_filters(applicable_exclusions, queryset, view),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_query_builder(self, *args, **kwargs):
|
|
63
|
+
"""
|
|
64
|
+
Return the query builder class instance that should be used to
|
|
65
|
+
build the query which is passed to the search engine backend.
|
|
66
|
+
"""
|
|
67
|
+
query_builder = self.get_query_builder_class()
|
|
68
|
+
return query_builder(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def get_query_builder_class(self):
|
|
71
|
+
"""
|
|
72
|
+
Return the class to use for building the query.
|
|
73
|
+
Defaults to using `self.query_builder_class`.
|
|
74
|
+
|
|
75
|
+
You may want to override this if you need to provide different
|
|
76
|
+
methods of building the query sent to the search engine backend.
|
|
77
|
+
"""
|
|
78
|
+
assert self.query_builder_class is not None, (
|
|
79
|
+
f"'{self.__class__.__name__}' should either include a `query_builder_class` attribute, "
|
|
80
|
+
"or override the `get_query_builder_class()` method."
|
|
81
|
+
)
|
|
82
|
+
return self.query_builder_class
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HaystackFilter(BaseHaystackFilterBackend):
|
|
86
|
+
"""
|
|
87
|
+
A filter backend that compiles a haystack compatible filtering query.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
query_builder_class = FilterQueryBuilder
|
|
91
|
+
default_operator = operator.and_
|
|
92
|
+
default_same_param_operator = operator.or_
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class HaystackAutocompleteFilter(HaystackFilter):
|
|
96
|
+
"""
|
|
97
|
+
A filter backend to perform autocomplete search.
|
|
98
|
+
|
|
99
|
+
Must be run against fields that are either `NgramField` or
|
|
100
|
+
`EdgeNgramField`.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def process_filters(self, filters, queryset, view):
|
|
104
|
+
if not filters:
|
|
105
|
+
return filters
|
|
106
|
+
|
|
107
|
+
query_bits = []
|
|
108
|
+
for field_name, query in filters.children:
|
|
109
|
+
for word in query.split(" "):
|
|
110
|
+
bit = queryset.query.clean(word.strip())
|
|
111
|
+
kwargs = {field_name: bit}
|
|
112
|
+
query_bits.append(view.query_object(**kwargs))
|
|
113
|
+
return reduce(operator.and_, filter(lambda x: x, query_bits))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class HaystackGEOSpatialFilter(BaseHaystackFilterBackend):
|
|
117
|
+
"""
|
|
118
|
+
A base filter backend for doing geo spatial filtering.
|
|
119
|
+
If using this filter make sure to provide a `point_field` with the name of
|
|
120
|
+
your the `LocationField` of your index.
|
|
121
|
+
|
|
122
|
+
We'll always do the somewhat slower but more accurate `dwithin`
|
|
123
|
+
(radius) filter.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
query_builder_class = SpatialQueryBuilder
|
|
127
|
+
point_field = "coordinates"
|
|
128
|
+
|
|
129
|
+
def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None):
|
|
130
|
+
if applicable_filters:
|
|
131
|
+
queryset = queryset.dwithin(**applicable_filters["dwithin"]).distance(**applicable_filters["distance"])
|
|
132
|
+
return queryset
|
|
133
|
+
|
|
134
|
+
def filter_queryset(self, request, queryset, view):
|
|
135
|
+
return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request)))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class HaystackHighlightFilter(HaystackFilter):
|
|
139
|
+
"""
|
|
140
|
+
A filter backend which adds support for ``highlighting`` on the
|
|
141
|
+
SearchQuerySet level (the fast one).
|
|
142
|
+
Note that you need to use a search backend which supports highlighting
|
|
143
|
+
in order to use this.
|
|
144
|
+
|
|
145
|
+
This will add a ``hightlighted`` entry to your response, encapsulating the
|
|
146
|
+
highlighted words in an `<em>highlighted results</em>` block.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def filter_queryset(self, request, queryset, view):
|
|
150
|
+
queryset = super().filter_queryset(request, queryset, view)
|
|
151
|
+
if self.get_request_filters(request) and isinstance(queryset, SearchQuerySet):
|
|
152
|
+
queryset = queryset.highlight()
|
|
153
|
+
return queryset
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class HaystackBoostFilter(BaseHaystackFilterBackend):
|
|
157
|
+
"""
|
|
158
|
+
Filter backend for applying term boost on query time.
|
|
159
|
+
|
|
160
|
+
Apply by adding a comma separated ``boost`` query parameter containing
|
|
161
|
+
a the term you want to boost and a floating point or integer for
|
|
162
|
+
the boost value. The boost value is based around ``1.0`` as 100% - no boost.
|
|
163
|
+
|
|
164
|
+
Gives a slight increase in relevance for documents that include "banana":
|
|
165
|
+
/api/v1/search/?boost=banana,1.1
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
query_builder_class = BoostQueryBuilder
|
|
169
|
+
query_param = "boost"
|
|
170
|
+
|
|
171
|
+
def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None):
|
|
172
|
+
if applicable_filters:
|
|
173
|
+
queryset = queryset.boost(**applicable_filters)
|
|
174
|
+
return queryset
|
|
175
|
+
|
|
176
|
+
def filter_queryset(self, request, queryset, view):
|
|
177
|
+
return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class HaystackFacetFilter(BaseHaystackFilterBackend):
|
|
181
|
+
"""
|
|
182
|
+
Filter backend for faceting search results.
|
|
183
|
+
This backend does not apply regular filtering.
|
|
184
|
+
|
|
185
|
+
Faceting field options can be set by using the ``field_options`` attribute
|
|
186
|
+
on the serializer, and can be overridden by query parameters. Dates will be
|
|
187
|
+
parsed by the ``python-dateutil.parser()`` which can handle most date formats.
|
|
188
|
+
|
|
189
|
+
Query parameters is parsed in the following format:
|
|
190
|
+
?field1=option1:value1,option2:value2&field2=option1:value1,option2:value2
|
|
191
|
+
where each options ``key:value`` pair is separated by the ``view.lookup_sep`` attribute.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
query_builder_class = FacetQueryBuilder
|
|
195
|
+
|
|
196
|
+
def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None):
|
|
197
|
+
"""
|
|
198
|
+
Apply faceting to the queryset
|
|
199
|
+
"""
|
|
200
|
+
for field, options in applicable_filters["field_facets"].items():
|
|
201
|
+
queryset = queryset.facet(field, **options)
|
|
202
|
+
|
|
203
|
+
for field, options in applicable_filters["date_facets"].items():
|
|
204
|
+
queryset = queryset.date_facet(field, **options)
|
|
205
|
+
|
|
206
|
+
for field, options in applicable_filters["query_facets"].items():
|
|
207
|
+
queryset = queryset.query_facet(field, **options)
|
|
208
|
+
|
|
209
|
+
return queryset
|
|
210
|
+
|
|
211
|
+
def filter_queryset(self, request, queryset, view):
|
|
212
|
+
return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request)))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class HaystackOrderingFilter(OrderingFilter):
|
|
216
|
+
"""
|
|
217
|
+
Some docstring here!
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def get_default_valid_fields(self, queryset, view, context=None):
|
|
221
|
+
if context is None:
|
|
222
|
+
context = {}
|
|
223
|
+
valid_fields = super().get_default_valid_fields(queryset, view, context)
|
|
224
|
+
|
|
225
|
+
# Check if we need to support aggregate serializers
|
|
226
|
+
serializer_class = view.get_serializer_class()
|
|
227
|
+
if hasattr(serializer_class.Meta, "serializers"):
|
|
228
|
+
raise NotImplementedError("Ordering on aggregate serializers is not yet implemented.")
|
|
229
|
+
|
|
230
|
+
return valid_fields
|
|
231
|
+
|
|
232
|
+
def get_valid_fields(self, queryset, view, context=None):
|
|
233
|
+
if context is None:
|
|
234
|
+
context = {}
|
|
235
|
+
valid_fields = getattr(view, "ordering_fields", self.ordering_fields)
|
|
236
|
+
|
|
237
|
+
if valid_fields is None:
|
|
238
|
+
return self.get_default_valid_fields(queryset, view, context)
|
|
239
|
+
|
|
240
|
+
elif valid_fields == "__all__":
|
|
241
|
+
# View explicitly allows filtering on all model fields.
|
|
242
|
+
if not queryset.query.models:
|
|
243
|
+
raise ImproperlyConfigured(
|
|
244
|
+
f"Cannot use {self.__class__.__name__} with '__all__' as 'ordering_fields' attribute on a view "
|
|
245
|
+
"which has no 'index_models' set. Either specify some 'ordering_fields', "
|
|
246
|
+
"set the 'index_models' attribute or override the 'get_queryset' "
|
|
247
|
+
"method and pass some 'index_models'."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
model_fields = (
|
|
251
|
+
[(field.name, field.verbose_name) for field in model._meta.fields] for model in queryset.query.models
|
|
252
|
+
)
|
|
253
|
+
valid_fields = list(set(reduce(operator.concat, model_fields)))
|
|
254
|
+
else:
|
|
255
|
+
valid_fields = [(item, item) if isinstance(item, str) else item for item in valid_fields]
|
|
256
|
+
|
|
257
|
+
return valid_fields
|
drf_haystack/generics.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from django.contrib.contenttypes.models import ContentType
|
|
2
|
+
from django.http import Http404
|
|
3
|
+
from haystack.backends import SQ
|
|
4
|
+
from haystack.query import SearchQuerySet
|
|
5
|
+
from rest_framework.generics import GenericAPIView
|
|
6
|
+
|
|
7
|
+
from drf_haystack.filters import HaystackFilter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HaystackGenericAPIView(GenericAPIView):
|
|
11
|
+
"""
|
|
12
|
+
Base class for all haystack generic views.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Use `index_models` to filter on which search index models we
|
|
16
|
+
# should include in the search result.
|
|
17
|
+
index_models = []
|
|
18
|
+
|
|
19
|
+
object_class = SearchQuerySet
|
|
20
|
+
query_object = SQ
|
|
21
|
+
|
|
22
|
+
# Override document_uid_field with whatever field in your index
|
|
23
|
+
# you use to uniquely identify a single document. This value will be
|
|
24
|
+
# used wherever the view references the `lookup_field` kwarg.
|
|
25
|
+
document_uid_field = "id"
|
|
26
|
+
lookup_sep = ","
|
|
27
|
+
|
|
28
|
+
# If set to False, DB lookups are done on a per-object basis,
|
|
29
|
+
# resulting in in many individual trips to the database. If True,
|
|
30
|
+
# the SearchQuerySet will group similar objects into a single query.
|
|
31
|
+
load_all = False
|
|
32
|
+
|
|
33
|
+
filter_backends = [HaystackFilter]
|
|
34
|
+
|
|
35
|
+
def get_queryset(self, index_models=None):
|
|
36
|
+
"""
|
|
37
|
+
Get the list of items for this view.
|
|
38
|
+
Returns ``self.queryset`` if defined and is a ``self.object_class``
|
|
39
|
+
instance.
|
|
40
|
+
|
|
41
|
+
@:param index_models: override `self.index_models`
|
|
42
|
+
"""
|
|
43
|
+
if index_models is None:
|
|
44
|
+
index_models = []
|
|
45
|
+
if self.queryset is not None and isinstance(self.queryset, self.object_class):
|
|
46
|
+
queryset = self.queryset.all()
|
|
47
|
+
else:
|
|
48
|
+
queryset = self.object_class()._clone()
|
|
49
|
+
if len(index_models):
|
|
50
|
+
queryset = queryset.models(*index_models)
|
|
51
|
+
elif len(self.index_models):
|
|
52
|
+
queryset = queryset.models(*self.index_models)
|
|
53
|
+
return queryset
|
|
54
|
+
|
|
55
|
+
def get_object(self):
|
|
56
|
+
"""
|
|
57
|
+
Fetch a single document from the data store according to whatever
|
|
58
|
+
unique identifier is available for that document in the
|
|
59
|
+
SearchIndex.
|
|
60
|
+
|
|
61
|
+
In cases where the view has multiple ``index_models``, add a ``model`` query
|
|
62
|
+
parameter containing a single `app_label.model` name to the request in order
|
|
63
|
+
to override which model to include in the SearchQuerySet.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
/api/v1/search/42/?model=myapp.person
|
|
67
|
+
"""
|
|
68
|
+
queryset = self.get_queryset()
|
|
69
|
+
if "model" in self.request.query_params:
|
|
70
|
+
try:
|
|
71
|
+
app_label, model = map(str.lower, self.request.query_params["model"].split(".", 1))
|
|
72
|
+
ctype = ContentType.objects.get(app_label=app_label, model=model)
|
|
73
|
+
queryset = self.get_queryset(index_models=[ctype.model_class()])
|
|
74
|
+
except (ValueError, ContentType.DoesNotExist):
|
|
75
|
+
raise Http404(
|
|
76
|
+
"Could not find any models matching '{}'. Make sure to use a valid "
|
|
77
|
+
"'app_label.model' name for the 'model' query parameter.".format(self.request.query_params["model"])
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
|
81
|
+
if lookup_url_kwarg not in self.kwargs:
|
|
82
|
+
raise AttributeError(
|
|
83
|
+
f"Expected view {self.__class__.__name__} to be called with a URL keyword argument "
|
|
84
|
+
f"named '{lookup_url_kwarg}'. Fix your URL conf, or set the `.lookup_field` "
|
|
85
|
+
"attribute on the view correctly."
|
|
86
|
+
)
|
|
87
|
+
queryset = queryset.filter(self.query_object((self.document_uid_field, self.kwargs[lookup_url_kwarg])))
|
|
88
|
+
count = queryset.count()
|
|
89
|
+
if count == 1:
|
|
90
|
+
return queryset[0]
|
|
91
|
+
elif count > 1:
|
|
92
|
+
raise Http404("Multiple results matches the given query. Expected a single result.")
|
|
93
|
+
|
|
94
|
+
raise Http404("No result matches the given query.")
|
|
95
|
+
|
|
96
|
+
def filter_queryset(self, queryset):
|
|
97
|
+
queryset = super().filter_queryset(queryset)
|
|
98
|
+
|
|
99
|
+
if self.load_all:
|
|
100
|
+
queryset = queryset.load_all()
|
|
101
|
+
|
|
102
|
+
return queryset
|
drf_haystack/mixins.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from rest_framework.decorators import action
|
|
2
|
+
from rest_framework.response import Response
|
|
3
|
+
|
|
4
|
+
from drf_haystack.filters import HaystackFacetFilter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MoreLikeThisMixin:
|
|
8
|
+
"""
|
|
9
|
+
Mixin class for supporting "more like this" on an API View.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@action(detail=True, methods=["get"], url_path="more-like-this")
|
|
13
|
+
def more_like_this(self, request, pk=None):
|
|
14
|
+
"""
|
|
15
|
+
Sets up a detail route for ``more-like-this`` results.
|
|
16
|
+
Note that you'll need backend support in order to take advantage of this.
|
|
17
|
+
|
|
18
|
+
This will add ie. ^search/{pk}/more-like-this/$ to your existing ^search pattern.
|
|
19
|
+
"""
|
|
20
|
+
obj = self.get_object().object
|
|
21
|
+
queryset = self.filter_queryset(self.get_queryset()).more_like_this(obj)
|
|
22
|
+
|
|
23
|
+
page = self.paginate_queryset(queryset)
|
|
24
|
+
if page is not None:
|
|
25
|
+
serializer = self.get_serializer(page, many=True)
|
|
26
|
+
return self.get_paginated_response(serializer.data)
|
|
27
|
+
|
|
28
|
+
serializer = self.get_serializer(queryset, many=True)
|
|
29
|
+
return Response(serializer.data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FacetMixin:
|
|
33
|
+
"""
|
|
34
|
+
Mixin class for supporting faceting on an API View.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
facet_filter_backends = [HaystackFacetFilter]
|
|
38
|
+
facet_serializer_class = None
|
|
39
|
+
facet_objects_serializer_class = None
|
|
40
|
+
facet_query_params_text = "selected_facets"
|
|
41
|
+
|
|
42
|
+
@action(detail=False, methods=["get"], url_path="facets")
|
|
43
|
+
def facets(self, request):
|
|
44
|
+
"""
|
|
45
|
+
Sets up a list route for ``faceted`` results.
|
|
46
|
+
This will add ie ^search/facets/$ to your existing ^search pattern.
|
|
47
|
+
"""
|
|
48
|
+
queryset = self.filter_facet_queryset(self.get_queryset())
|
|
49
|
+
|
|
50
|
+
for facet in request.query_params.getlist(self.facet_query_params_text):
|
|
51
|
+
if ":" not in facet:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
field, value = facet.split(":", 1)
|
|
55
|
+
if value:
|
|
56
|
+
queryset = queryset.narrow(f'{field}:"{queryset.query.clean(value)}"')
|
|
57
|
+
|
|
58
|
+
serializer = self.get_facet_serializer(queryset.facet_counts(), objects=queryset, many=False)
|
|
59
|
+
return Response(serializer.data)
|
|
60
|
+
|
|
61
|
+
def filter_facet_queryset(self, queryset):
|
|
62
|
+
"""
|
|
63
|
+
Given a search queryset, filter it with whichever facet filter backends
|
|
64
|
+
in use.
|
|
65
|
+
"""
|
|
66
|
+
for backend in list(self.facet_filter_backends):
|
|
67
|
+
queryset = backend().filter_queryset(self.request, queryset, self)
|
|
68
|
+
|
|
69
|
+
if self.load_all:
|
|
70
|
+
queryset = queryset.load_all()
|
|
71
|
+
|
|
72
|
+
return queryset
|
|
73
|
+
|
|
74
|
+
def get_facet_serializer(self, *args, **kwargs):
|
|
75
|
+
"""
|
|
76
|
+
Return the facet serializer instance that should be used for
|
|
77
|
+
serializing faceted output.
|
|
78
|
+
"""
|
|
79
|
+
assert "objects" in kwargs, "`objects` is a required argument to `get_facet_serializer()`"
|
|
80
|
+
|
|
81
|
+
facet_serializer_class = self.get_facet_serializer_class()
|
|
82
|
+
kwargs["context"] = self.get_serializer_context()
|
|
83
|
+
kwargs["context"].update({
|
|
84
|
+
"objects": kwargs.pop("objects"),
|
|
85
|
+
"facet_query_params_text": self.facet_query_params_text,
|
|
86
|
+
})
|
|
87
|
+
return facet_serializer_class(*args, **kwargs)
|
|
88
|
+
|
|
89
|
+
def get_facet_serializer_class(self):
|
|
90
|
+
"""
|
|
91
|
+
Return the class to use for serializing facets.
|
|
92
|
+
Defaults to using ``self.facet_serializer_class``.
|
|
93
|
+
"""
|
|
94
|
+
if self.facet_serializer_class is None:
|
|
95
|
+
raise AttributeError(
|
|
96
|
+
f"{self.__class__.__name__} should either include a `facet_serializer_class` attribute, "
|
|
97
|
+
f"or override {self.__class__.__name__}.get_facet_serializer_class() method."
|
|
98
|
+
)
|
|
99
|
+
return self.facet_serializer_class
|
|
100
|
+
|
|
101
|
+
def get_facet_objects_serializer(self, *args, **kwargs):
|
|
102
|
+
"""
|
|
103
|
+
Return the serializer instance which should be used for
|
|
104
|
+
serializing faceted objects.
|
|
105
|
+
"""
|
|
106
|
+
facet_objects_serializer_class = self.get_facet_objects_serializer_class()
|
|
107
|
+
kwargs["context"] = self.get_serializer_context()
|
|
108
|
+
return facet_objects_serializer_class(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
def get_facet_objects_serializer_class(self):
|
|
111
|
+
"""
|
|
112
|
+
Return the class to use for serializing faceted objects.
|
|
113
|
+
Defaults to using the views ``self.serializer_class`` if not
|
|
114
|
+
``self.facet_objects_serializer_class`` is set.
|
|
115
|
+
"""
|
|
116
|
+
return self.facet_objects_serializer_class or super().get_serializer_class()
|