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.
@@ -0,0 +1,6 @@
1
+ __title__ = "drf-haystack"
2
+ __version__ = "1.9.3"
3
+ __author__ = "Rolf Haavard Blindheim"
4
+ __license__ = "MIT License"
5
+
6
+ VERSION = __version__
@@ -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]
@@ -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
@@ -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()