-
Notifications
You must be signed in to change notification settings - Fork 358
Expand file tree
/
Copy pathelasticsearch_dsl_views.py
More file actions
172 lines (145 loc) · 6.88 KB
/
elasticsearch_dsl_views.py
File metadata and controls
172 lines (145 loc) · 6.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
from __future__ import annotations
import abc
import datetime
import typing
import elasticsearch6_dsl as edsl
from rest_framework import generics, exceptions as drf_exceptions
from rest_framework.settings import api_settings as drf_settings
from api.base.settings.defaults import REPORT_FILENAME_FORMAT
if typing.TYPE_CHECKING:
from rest_framework import serializers
from api.base.filters import FilterMixin
from api.base.views import JSONAPIBaseView
from api.metrics.renderers import (
MetricsReportsCsvRenderer,
MetricsReportsTsvRenderer,
MetricsReportsJsonRenderer,
)
from api.base.pagination import ElasticsearchQuerySizeMaximumPagination, JSONAPIPagination
from api.base.renderers import JSONAPIRenderer
class ElasticsearchListView(FilterMixin, JSONAPIBaseView, generics.ListAPIView, abc.ABC):
'''abstract view class using `elasticsearch6_dsl.Search` as a queryset-analogue
builds a `Search` based on `self.get_default_search()` and the request's
query parameters for filtering, sorting, and pagination -- fetches only
the data required for the response, just like with a queryset!
'''
serializer_class: type[serializers.BaseSerializer] # required on subclasses
default_ordering: str | None = None # name of a serializer field, prepended with "-" for descending sort
ordering_fields: frozenset[str] = frozenset() # serializer field names
@abc.abstractmethod
def get_default_search(self) -> edsl.Search | None:
'''the base `elasticsearch6_dsl.Search` for this list, based on url path
(common jsonapi query parameters will be considered automatically)
'''
...
FILE_RENDERER_CLASSES = {
MetricsReportsCsvRenderer,
MetricsReportsTsvRenderer,
MetricsReportsJsonRenderer,
}
def set_content_disposition(self, response, renderer: str):
"""Set the Content-Disposition header to prompt a file download with the appropriate filename.
Args:
response: The HTTP response object to modify.
renderer: The renderer instance used for the response, which determines the file extension.
"""
current_date = datetime.datetime.now().strftime('%Y-%m')
if isinstance(renderer, JSONAPIRenderer):
extension = 'json'
else:
extension = getattr(renderer, 'extension', renderer.format)
filename = REPORT_FILENAME_FORMAT.format(
view_name=self.view_name,
date_created=current_date,
extension=extension,
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
def finalize_response(self, request, response, *args, **kwargs):
# Call the parent method to finalize the response first
response = super().finalize_response(request, response, *args, **kwargs)
# Check if this is a direct download request or file renderer classes, set to the Content-Disposition header
# so filename and attachment for browser download
if isinstance(request.accepted_renderer, tuple(self.FILE_RENDERER_CLASSES)):
self.set_content_disposition(response, request.accepted_renderer)
return response
###
# beware! inheritance shenanigans below
# override FilterMixin to disable all operators besides 'eq' and 'ne'
MATCHABLE_FIELDS = ()
COMPARABLE_FIELDS = ()
DEFAULT_OPERATOR_OVERRIDES = {}
# (if you want to add fulltext-search or range-filter support, remove the override
# and update `__add_search_filter` to handle those operators -- tho note that the
# underlying elasticsearch field mapping will need to be compatible with the query)
# override DEFAULT_FILTER_BACKENDS rest_framework setting
# (filtering handled in-view to reuse logic from FilterMixin)
filter_backends = ()
# note: because elasticsearch6_dsl.Search supports slicing and gives results when iterated on,
# it works fine with default pagination
# override rest_framework.generics.GenericAPIView
@property
def pagination_class(self):
"""
When downloading a file assume no pagination is necessary unless the user specifies
"""
is_file_download = any(
self.request.accepted_renderer.format == renderer.format
for renderer in self.FILE_RENDERER_CLASSES
)
# if it's a file download of the JSON respect default page size
if is_file_download:
return ElasticsearchQuerySizeMaximumPagination
return JSONAPIPagination
def get_queryset(self):
_search = self.get_default_search()
if _search is None:
return []
# using parsing logic from FilterMixin (oddly nested dict and all)
for _parsed_param in self.parse_query_params(self.request.query_params).values():
for _parsed_filter in _parsed_param.values():
_search = self.__add_search_filter(
_search,
elastic_field_name=_parsed_filter['source_field_name'],
operator=_parsed_filter['op'],
value=_parsed_filter['value'],
)
return self.__add_sort(_search)
###
# private methods
def __add_sort(self, search: edsl.Search) -> edsl.Search:
_elastic_sort = self.__get_elastic_sort()
return (search if _elastic_sort is None else search.sort(_elastic_sort))
def __get_elastic_sort(self) -> str | None:
_sort_param = self.request.query_params.get(drf_settings.ORDERING_PARAM, self.default_ordering)
if not _sort_param:
return None
_sort_field, _ascending = (
(_sort_param[1:], False)
if _sort_param.startswith('-')
else (_sort_param, True)
)
if _sort_field not in self.ordering_fields:
raise drf_exceptions.ValidationError(
f'invalid value for {drf_settings.ORDERING_PARAM} query param (valid values: {", ".join(self.ordering_fields)})',
)
_serializer_field = self.get_serializer().fields[_sort_field]
_elastic_sort_field = _serializer_field.source
return (_elastic_sort_field if _ascending else f'-{_elastic_sort_field}')
def __add_search_filter(
self,
search: edsl.Search,
elastic_field_name: str,
operator: str,
value: str,
) -> edsl.Search:
match operator: # operators from FilterMixin
case 'eq':
if value == '':
return search.exclude('exists', field=elastic_field_name)
return search.filter('term', **{elastic_field_name: value})
case 'ne':
if value == '':
return search.filter('exists', field=elastic_field_name)
return search.exclude('term', **{elastic_field_name: value})
case _:
raise NotImplementedError(f'unsupported filter operator "{operator}"')