-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathviews.py
More file actions
1851 lines (1603 loc) · 78.6 KB
/
Copy pathviews.py
File metadata and controls
1851 lines (1603 loc) · 78.6 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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import abc
import collections
import copy
import inspect
import json
import re
import warnings
from datetime import datetime
import elasticsearch_dsl as dsl
import six
from django.conf import settings
from django.contrib import messages
from django.forms.forms import Form
from django.http import Http404, JsonResponse, QueryDict, StreamingHttpResponse
from django.http.response import HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import redirect, render
from django.template import Context, RequestContext, TemplateDoesNotExist, loader
from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.html import escape
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.views.generic import View
from django.views.generic.edit import CreateView, FormView
from elasticsearch_dsl import Q
from elasticsearch_dsl.utils import AttrList
from .facets import TermsFacet, RangeFilter, TextFacet
from .mapping import DEFAULT_ANALYZER
from .signals import advanced_search_performed, search_complete
from .templatetags.seeker import seeker_format
seekerview_field_templates = {}
class Column(object):
"""
"""
view = None
visible = False
def __init__(self, field, label=None, sort=None, value_format=None, template=None, header=None, export=True, highlight=None, field_definition=None, custom_cls=None):
self.field = field
self.label = label if label is not None else field.replace('_', ' ').replace('.raw', '').capitalize()
self.sort = sort
self.template = template
self.value_format = value_format
self.header_html = escape(self.label) if header is None else header
self.export = export
self.highlight = highlight
self.field_definition = field_definition
self.custom_cls = custom_cls
def __str__(self):
return self.label
def __repr__(self):
return 'Column(%s)' % self.field
def bind(self, view, visible):
self.view = view
self.visible = visible
if self.visible:
if self.template:
self.template_obj = loader.get_template(self.template)
else:
self.template_obj = self.view.get_field_template(self.field)
#Set the model_lower variable on Column to the lowercased name of the model on the mapping once view is set above
try:
self.model_lower = self.view.document._model
except AttributeError:
document = self.view.document
if hasattr(document, 'model'):
self.model_lower = document.model.__name__.lower()
elif hasattr(document, 'queryset'):
self.model_lower = document.queryset().model.__name__.lower()
else:
self.model_lower = ''
self.view.document._model = self.model_lower
return self
def header(self):
cls = '%s_%s' % (self.view.document._doc_type.name, self.field.replace('.', '_'))
cls += ' %s_%s' % (self.model_lower, self.field.replace('.', '_'))
if self.custom_cls:
cls += ' %s' % (self.custom_cls)
if not self.sort:
return mark_safe('<th class="%s">%s</th>' % (cls, self.header_html))
q = self.view.request.GET.copy()
field = q.get('s', '')
sort = None
cls += ' sort'
if field.lstrip('-') == self.field:
# If the current sort field is this field, give it a class a change direction.
sort = 'Descending' if field.startswith('-') else 'Ascending'
cls += ' desc' if field.startswith('-') else ' asc'
d = '' if field.startswith('-') else '-'
q['s'] = '%s%s' % (d, self.field)
else:
q['s'] = self.field
next_sort = 'descending' if sort == 'Ascending' else 'ascending'
sr_label = (' <span class="sr-only">(%s)</span>' % sort) if sort else ''
if self.field_definition:
span = '<span title="{}" class ="fa fa-question-circle"></span>'.format(self.field_definition)
else:
span = ''
html = '<th class="%s"><a href="?%s" title="Click to sort %s" data-sort="%s">%s%s %s</a></th>' % (cls, q.urlencode(), next_sort, q['s'], self.header_html, sr_label, span)
return mark_safe(html)
def context(self, result, **kwargs):
return kwargs
def render(self, result, **kwargs):
value = getattr(result, self.field, None)
try:
if '*' in self.highlight:
# If highlighting was requested for multiple fields, grab any matching fields as a dictionary.
r = self.highlight.replace('*', r'\w+').replace('.', r'\.')
highlight = {f.replace('.', '_'): result.meta.highlight[f] for f in result.meta.highlight if re.match(r, f)}
else:
highlight = result.meta.highlight[self.highlight]
except Exception:
highlight = []
# If the value is a list (AttrList is DSL's custom list) then highlight won't work properly
# The "meta.highlight" will only contain the matched item, not the others
if highlight and isinstance(value, AttrList):
# We are going to modify this copy with the appropriate highlights
modified_values = copy.deepcopy(value)
for highlighted_value in highlight:
# Remove the <em> tags elasticsearch added
stripped_value = highlighted_value.replace('<em>', '').replace('</em>', '')
index_to_replace = None
# Iterate over all of the values and try to find the item that caused the "hit"
for index, individual_value in enumerate(value):
if stripped_value == individual_value:
index_to_replace = index
break
# Specifically check against None because "0" is falsy (but a valid index)
if index_to_replace is not None:
modified_values[index_to_replace] = highlighted_value
highlight = modified_values
if self.value_format:
value = self.value_format(value)
if highlight:
highlight = self.value_format(highlight)
params = {
'result': result,
'field': self.field,
'value': value,
'highlight': highlight,
'model_lower': self.model_lower,
'doc_class_name': result.__class__.__name__.lower(),
'view': self.view,
'user': self.view.request.user,
'query': self.view.get_keywords(self.view.request.GET),
}
params.update(self.context(result, **kwargs))
return self.template_obj.render(params)
def export_value(self, result):
export_field = self.field if self.export is True else self.export
if export_field:
value = getattr(result, export_field, '')
if isinstance(value, datetime) and timezone.is_aware(value):
value = timezone.localtime(value)
export_val = ', '.join(force_text(v.to_dict() if hasattr(v, 'to_dict') else v) for v in value) if isinstance(value, AttrList) else seeker_format(value)
else:
export_val = ''
return export_val
class SeekerView(View):
document = None
"""
A :class:`elasticsearch_dsl.DocType` class to present a view for.
"""
using = None
"""
The ES connection alias to use.
"""
index = None
"""
The ES index to use. Will use the index set on the mapping if this is not set.
"""
template_name = 'seeker/seeker.html'
"""
The overall seeker template to render.
"""
search_form_template = 'seeker/form.html'
"""
The template to render seeker form
"""
header_template = 'seeker/header.html'
"""
The template used to render the search results header.
"""
results_template = 'seeker/results.html'
"""
The template used to render the search results.
"""
footer_template = 'seeker/footer.html'
"""
The template used to render the search results footer.
"""
columns = None
"""
A list of Column objects, or strings representing mapping field names. If None, all mapping fields will be available.
"""
exclude = None
"""
A list of field names to exclude when generating columns.
"""
display = None
"""
A list of field/column names to display by default.
"""
post_filter_facets = False
"""
A boolean set to optionally define a dynamic response in the facets and results after a change to the form
You will need to set javascript and ajax on the seeker template in order to fully enable these features
"""
required_display = []
"""
A list of tuples, ex. ('field name', 0), representing field/column names that will always be displayed (cannot be hidden by the user).
The second value is the index/position of the field (used as the index in list.insert(index, 'field name')).
"""
@property
def required_display_fields(self):
return [t[0] for t in self.required_display]
sort = None
"""
A list of field/column names to sort by default, or None for no default sort order. For reverse order prefix the field with '-'.
"""
search = None
"""
A list of field names to search. By default, will included all fields defined on the document mapping.
"""
highlight = True
"""
A list of field names to highlight, or True/False to enable/disable highlighting for all fields.
"""
highlight_encoder = 'html'
"""
An 'encoder' parameter is used when highlighting to define how highlighted text will be encoded. It can be either
'default' (no encoding) or 'html' (will escape html, if you use html highlighting tags).
"""
number_of_fragments = 0
"""
The number of fragments returned by highlighted search, set to 0 by default (which gives all results)
"""
facets = []
"""
A list of :class:`seeker.Facet` objects that are available to facet the results by.
"""
initial_facets = {}
"""
A dictionary of initial facets, mapping fields to lists of initial values.
"""
page_size = 10
"""
The number of results to show per page.
"""
available_page_sizes = []
"""
If set allows user to set options for page size to be changed, (must include the default page_size)
"""
page_spread = 7
"""
The number of pages (not including first and last) to show in the paginator widget.
"""
can_save = True
"""
Whether searches for this view can be saved.
"""
export_name = 'seeker'
"""
The filename (without extension, which will be .csv) to use when exporting data from this view.
"""
export_timestamp = False
"""
Whether or not to append a timestamp of the current time to the export filename when exporting data from this view.
"""
show_rank = True
"""
Whether or not to show a Rank column when performing keyword searches.
"""
field_columns = {}
"""
A dictionary of field column overrides.
"""
field_labels = {}
"""
A dictionary of field label overrides.
"""
field_definitions = {}
"""
A dictionary of field definitions. These appear in the header of a column.
"""
sort_fields = {}
"""
A dictionary of sort field overrides.
"""
highlight_fields = {}
"""
A dictionary of highlight field overrides.
"""
query_type = getattr(settings, 'SEEKER_QUERY_TYPE', 'query_string')
"""
The query type to use when performing keyword queries (either 'query_string' (default) or 'simple_query_string').
"""
operator = getattr(settings, 'SEEKER_DEFAULT_OPERATOR', 'AND')
"""
The query operator to use by default.
"""
permission = None
"""
If specified, a permission to check (using ``request.user.has_perm``) for this view.
"""
extra_context = {}
"""
This property is slated to be deprecated in the future. Please use "modify_context".
Extra context variables to use when rendering. May be passed via as_view(), or overridden as a property.
"""
field_templates = {}
"""
A dictionary of field template overrides.
"""
_field_templates = {}
"""
A dictionary of default templates for each field
"""
use_save_form = False
"""
Indicates if a form should be used for saving searches.
NOTE: This functionality ONLY works with AJAX.
NOTE: The form used is defined in "get_saved_search_form"
"""
form_template = 'seeker/save_form.html'
"""
The form template used to display the save search form.
NOTE: This is only used if the request is AJAX and 'use_save_form' is True.
NOTE: This template will be used to render the form defined in 'get_saved_search_form"
TODO: This form does not exist in template and is unknown if this functionality works on SeekerView...
TODO: Change name for clarity on next major release (save_form_template)
"""
enforce_unique_name = True
"""
The system will enforce the unique name requirement.
All previously existing saved searches (in the same group) with the same name as the new one will be deleted.
"""
display_column_sort_order = []
"""
This list defines a custom sort order for the display fields (both visible & non-visible). If the list is empty, default sorting will be applied.
If there is at least one field in the list, any missing field will be appended to the end of the list in alphabetical order by column label.
NOTE: The indexes defined in required_display are not used if display_column_sort_order has a value.
"""
custom_header_class = None
"""
Customizable html class to add to all (non-rank) seeker column headers
"""
custom_column_headers = {}
"""
This dictionary can be used to set custom text for a fields column header. The key is the field_name.
"""
custom_column_header_classes = {}
"""
This dictionary can be used to set custom html classes for a fields column header. The key is the field_name.
"""
analyzer = DEFAULT_ANALYZER
"""
The ES analyzer used for keyword searching.
"""
missing_sort = None
"""
Whether to sort missing values first or last. Valid values are "_first", "_last", "_low", "_high", or None.
"""
def modify_context(self, context, request):
"""
This function allows modifications to the context that will be used to render the initial seeker page.
NOTE: The changes to context should be done in place. This function does not have a return (similar to 'dict.update()').
"""
pass
def get_page_size(self):
ps = self.request.GET.get('page_size', '').strip()
try:
return int(ps) if int(ps) > 0 and int(ps) in self.available_page_sizes else self.page_size
except ValueError:
return self.page_size
def modify_results_context(self, context):
"""
This function allows modifications to the context that will be used to render the results table.
NOTE: The changes to context should be done in place. This function does not have a return (similar to 'dict.update()').
"""
pass
view_name = None
"""
An optional name to call this view, used to differentiate two views using the same mapping and class.
"""
def get_saved_search_form(self):
"""
Get the form used to save searches.
NOTE: This will only be used if 'use_save_form' is set to True and with AJAX requests.
NOTE: This form will be passed the "saved_searches" kwarg when instantiated.
"""
from .forms import SavedSearchForm
return SavedSearchForm
def get_view_name(self):
"""
Returns the view_name if set, otherwise return the class name and document name.
"""
if self.view_name:
return self.view_name
else:
return self.__class__.__name__ + self.document._doc_type.name
def normalized_querystring(self, qs=None, ignore=None):
"""
Returns a querystring with empty keys removed and keys in sorted order.
Suitable for saving and comparing searches.
:param qs: (Optional) querystring to use; defaults to request.GET
:param ignore: (Optional) list of keys to ignore when building the querystring
"""
data = QueryDict(qs) if qs is not None else self.request.GET
parts = []
for key in sorted(data):
if ignore and key in ignore:
continue
values = data.getlist(key)
if not any(values):
continue
if key == 'p' and data[key] == '1':
continue
parts.extend(urlencode({key: val}) for val in values)
return '&'.join(parts)
def get_field_label(self, field_name):
"""
Given a field name, returns a human readable label for the field.
"""
if field_name.endswith('.raw'):
field_name = field_name[:-4]
if field_name in self.field_labels:
return self.field_labels[field_name]
try:
# If the document is a ModelIndex, try to get the verbose_name of the Django field.
f = self.document.model._meta.get_field(field_name)
return f.verbose_name[0].upper() + f.verbose_name[1:]
except Exception:
try:
f = self.document.queryset().model._meta.get_field(field_name)
return f.verbose_name[0].upper() + f.verbose_name[1:]
except Exception:
# Otherwise, just make the field name more human-readable.
return field_name.replace('_', ' ').capitalize()
def get_field_sort(self, field_name):
"""
Given a field name, returns the field name that should be used for sorting. If a mapping defines
a .raw sub-field, that is used, otherwise the field name itself is used if index=not_analyzed.
"""
if field_name.endswith('.raw'):
return field_name
if field_name in self.sort_fields:
return self.sort_fields[field_name]
if field_name in self.document._doc_type.mapping:
dsl_field = self.document._doc_type.mapping[field_name]
if isinstance(dsl_field, (dsl.Object, dsl.Nested)):
return None
if not isinstance(dsl_field, dsl.Text):
return field_name
if 'raw' in dsl_field.fields:
return '%s.raw' % field_name
elif getattr(dsl_field, 'index', None) == 'not_analyzed':
return field_name
return None
def get_field_template(self, field_name):
"""
Returns the default template instance for the given field name.
"""
if not self._field_templates:
try:
self._field_templates = seekerview_field_templates[self.get_view_name()]
except KeyError:
seekerview_field_templates.update({self.get_view_name(): {}})
self._field_templates = seekerview_field_templates[self.get_view_name()]
try:
return self._field_templates[field_name]
except KeyError:
return self._find_field_template(field_name)
def _find_field_template(self, field_name):
"""
finds and sets the default template instance for the given field name with the given template.
"""
search_templates = []
if field_name in self.field_templates:
search_templates.append(self.field_templates[field_name])
if hasattr(self.document, 'model'):
search_templates.append('seeker/{}/{}.html'.format(self.document.model.__name__.lower(), field_name))
elif hasattr(self.document, 'queryset'):
search_templates.append('seeker/{}/{}.html'.format(self.document.queryset().model.__name__.lower(), field_name))
for _cls in inspect.getmro(self.document):
if issubclass(_cls, dsl.DocType):
search_templates.append('seeker/{}/{}.html'.format(_cls.__name__.lower(), field_name))
search_templates.append('seeker/{}/{}.html'.format(_cls._doc_type.name, field_name))
search_templates.append('seeker/column.html')
template = loader.select_template(search_templates)
existing_templates = list(set(self._field_templates.values()))
for existing_template in existing_templates:
# If the template object already exists just re-use the existing one.
if template.template.name == existing_template.template.name:
template = existing_template
break
self._field_templates.update({field_name: template})
return template
def get_field_highlight(self, field_name):
if field_name in self.highlight_fields:
return self.highlight_fields[field_name]
if field_name in self.document._doc_type.mapping:
dsl_field = self.document._doc_type.mapping[field_name]
if isinstance(dsl_field, (dsl.Object, dsl.Nested)):
return '%s.*' % field_name
return field_name
return None
def make_column(self, field_name):
"""
Creates a :class:`seeker.Column` instance for the given field name.
"""
if field_name in self.field_columns:
return self.field_columns[field_name]
label = self.get_field_label(field_name)
sort = self.get_field_sort(field_name)
highlight = self.get_field_highlight(field_name)
header = self.custom_column_headers.get(field_name, None)
custom_cls = self.custom_header_class
if custom_cls:
custom_cls += ' ' + self.custom_column_header_classes.get(field_name, '')
else:
custom_cls = self.custom_column_header_classes.get(field_name, '')
field_definition = self.field_definitions.get(field_name)
return Column(field_name, label=label, sort=sort, highlight=highlight, header=header, field_definition=field_definition, custom_cls=custom_cls)
def get_columns(self, display=None):
"""
Returns a list of :class:`seeker.Column` objects based on self.columns, converting any strings.
"""
columns = []
if not self.columns:
# If not specified, all mapping fields will be available.
for f in self.document._doc_type.mapping:
if self.exclude and f in self.exclude:
continue
columns.append(self.make_column(f))
else:
# Otherwise, go through and convert any strings to Columns.
for c in self.columns:
if isinstance(c, six.string_types):
if self.exclude and c in self.exclude:
continue
columns.append(self.make_column(c))
elif isinstance(c, Column):
if self.exclude and c.field in self.exclude:
continue
columns.append(c)
# Make sure the columns are bound and ordered based on the display fields (selected or default).
if not display:
display = self.get_display()
if self.display_column_sort_order:
for c in columns:
c.bind(self, c.field in display)
sort_order = self.get_sort_order(columns)
columns.sort(key=lambda c: sort_order.index(c.field))
return columns
else:
visible_columns = []
non_visible_columns=[]
for c in columns:
c.bind(self, c.field in display)
if c.visible:
visible_columns.append(c)
else:
non_visible_columns.append(c)
visible_columns.sort(key=lambda c: display.index(c.field))
non_visible_columns.sort(key=lambda c: c.label)
return visible_columns + non_visible_columns
def get_sorted_display_list(self):
return self.request.GET.getlist("so")
def get_sort_order(self, columns):
sort_order = self.get_sorted_display_list() or self.display_column_sort_order
# Missing columns is a list of every column that isn't included in sort_order.
# These columns will be appended to the end of the display list in alphabetical order.
missing_columns = [col for col in columns if col.field not in sort_order]
missing_columns.sort(key=lambda c: c.label)
sort_order += [col.field for col in missing_columns]
for field, i in self.required_display:
sort_order.insert(i, field)
return sort_order
def get_keywords(self, data_dict):
return data_dict.get('q', '').strip()
def get_facets(self):
return list(self.facets) if self.facets else []
def get_sorts(self):
return self.request.GET.getlist('s', None)
def get_display(self):
"""
Returns a list of display field names. If the user has selected display fields, those are used, otherwise
the default list is returned. If no default list is specified, all fields are displayed.
"""
default = list(self.display) if self.display else list(self.document._doc_type.mapping)
display_fields = self.request.GET.getlist('d') or default
display_fields = [f for f in display_fields if f not in self.required_display_fields]
for field, i in self.required_display:
display_fields.insert(i, field)
return display_fields
def get_saved_search(self):
"""
Returns the "saved_search" GET parameter if it's in the proper format, otherwise returns None.
"""
saved_search_vals = [val for val in self.request.GET.getlist('saved_search') if val]
if len(saved_search_vals) == 1 and saved_search_vals[0].isdigit():
return saved_search_vals[0]
return None
def get_facet_data(self, data_dict, initial=None, exclude=None):
if initial is None:
initial = {}
facets = collections.OrderedDict()
for f in self.get_facets():
if f.field != exclude:
facets[f] = data_dict.getlist(f.field) or initial.get(f.field, [])
return facets
def get_saved_search_model(self):
from .models import SavedSearch
return SavedSearch
def get_search_fields(self, mapping=None, prefix=''):
if self.search:
return self.search
elif mapping is not None:
fields = []
for field_name in mapping:
if mapping[field_name].to_dict().get('analyzer') == self.analyzer:
fields.append(prefix + field_name)
if hasattr(mapping[field_name], 'properties'):
fields.extend(self.get_search_fields(mapping=mapping[field_name].properties, prefix=prefix + field_name + '.'))
return fields
else:
return self.get_search_fields(mapping=self.document._doc_type.mapping)
def get_search_query_type(self, search, keywords, analyzer=None):
if not analyzer:
analyzer = self.analyzer
kwargs = {'query': keywords,
'analyzer': analyzer,
'fields': self.get_search_fields(),
'default_operator': self.operator}
if self.query_type == 'simple_query':
kwargs['auto_generate_phrase_queries'] = True
return search.query(self.query_type, **kwargs)
def get_search(self, keywords=None, facets=None, aggregate=True):
using = self.using or self.document._index._using or 'default'
index = self.index or self.document._index
# TODO: self.document.search(using=using, index=index) once new version is released
s = self.document.search().index(index).using(using).extra(track_scores=True)
if keywords:
s = self.get_search_query_type(s, keywords)
if facets:
for facet, values in facets.items():
if values:
s = facet.filter(s, values)
if aggregate:
facet.apply(s)
return s
def sort_descriptor(self, sort):
if self.missing_sort is None or isinstance(sort, dict):
return sort
desc = sort.startswith('-')
field = sort.lstrip('-')
missing = self.missing_sort
if missing == '_low':
missing = '_last' if desc else '_first'
elif missing == '_high':
missing = '_first' if desc else '_last'
return {
field: {
'order': 'desc' if desc else 'asc',
'missing': missing,
}
}
def apply_highlight(self, search, columns):
highlight_fields = self.highlight if isinstance(self.highlight, (list, tuple)) else [c.highlight for c in columns if c.visible and c.highlight]
# NOTE: If the option to customize the tags (via pre_tags and post_tags) is added then the Column "render" function will need to be updated.
search = search.highlight(*highlight_fields, number_of_fragments=self.number_of_fragments).highlight_options(encoder=self.highlight_encoder)
return search
def render(self):
SavedSearchModel = self.get_saved_search_model()
querystring = self.normalized_querystring(ignore=['p', 'saved_search'])
if self.request.user and self.request.user.is_authenticated and not querystring and not self.request.is_ajax():
default = self.request.user.seeker_searches.filter(url=self.request.path, default=True).first()
if default and default.querystring:
return redirect(default)
# Figure out if this is a saved search, and grab the current user's saved searches.
saved_search = None
if self.request.user and self.request.user.is_authenticated:
saved_search_pk = self.get_saved_search()
if saved_search_pk:
try:
saved_search = self.request.user.seeker_searches.get(pk=saved_search_pk, url=self.request.path)
except SavedSearchModel.DoesNotExist:
pass
saved_searches = self.request.user.seeker_searches.filter(url=self.request.path)
else:
saved_searches = []
keywords = self.get_keywords(self.request.GET)
facets = self.get_facet_data(self.request.GET, initial=self.initial_facets if not self.request.is_ajax() else None)
search = self.get_search(keywords, facets)
columns = self.get_columns()
if self.post_filter_facets:
executed_search = search.execute()
facets_selected_and_results = collections.OrderedDict()
for facet in facets:
if self.request.GET.get(facet.field):
stored_facet_data = facets[facet]
facets[facet] = []
facets_selected_and_results[facet] = (stored_facet_data, self.get_search(keywords, facets).execute())
facets[facet] = stored_facet_data
else:
facets_selected_and_results[facet] = (facets[facet], executed_search)
else:
facets_selected_and_results = None
# Make sure we sanitize the sort fields.
sort_fields = []
column_lookup = {c.field: c for c in columns}
sorts = self.get_sorts()
if not sorts:
if keywords:
sorts = []
else:
sorts = self.sort or []
for s in sorts:
# Get the column based on the field name, and use it's "sort" field, if applicable.
c = column_lookup.get(s.lstrip('-'))
if c and c.sort:
sort_fields.append(self.sort_descriptor('-%s' % c.sort if s.startswith('-') else c.sort))
# Highlight fields.
if self.highlight:
search = self.apply_highlight(search, columns)
# Calculate paging information.
page_size = self.get_page_size()
page = self.request.GET.get('p', '').strip()
page = int(page) if page.isdigit() else 1
offset = (page - 1) * page_size
results_count = search[0:0].execute().hits.total
if results_count <= offset:
page = 1
offset = 0
# Finally, grab the results.
results = search.sort(*sort_fields)[offset:offset + page_size].execute()
context_querystring = self.normalized_querystring(ignore=['p'])
sort = sorts[0] if sorts else None
context = {
'document': self.document,
'keywords': keywords,
'columns': columns,
'optional_columns': [c for c in columns if c.field not in self.required_display_fields],
'display_columns': [c for c in columns if c.visible],
'facets': facets,
'post_filter_facets': self.post_filter_facets,
'facets_selected_and_results': facets_selected_and_results,
'selected_facets': self.request.GET.getlist('f') or self.initial_facets,
'form_action': self.request.path,
'results': results,
'page': page,
'page_size': page_size,
'available_page_sizes': self.available_page_sizes,
'page_spread': self.page_spread,
'sort': sort,
'querystring': context_querystring,
'reset_querystring': self.normalized_querystring(ignore=['p', 's', 'saved_search']),
'show_rank': self.show_rank,
'export_name': self.export_name,
'can_save': self.can_save and self.request.user and self.request.user.is_authenticated,
'header_template': self.header_template,
'search_form_template': self.search_form_template,
'results_template': self.results_template,
'footer_template': self.footer_template,
'saved_search': saved_search,
'saved_searches': list(saved_searches),
'use_save_form': self.use_save_form,
}
if self.use_save_form:
SavedSearchForm = self.get_saved_search_form()
form = SavedSearchForm(saved_searches=saved_searches)
context.update({
'save_form': form,
'save_form_template': self.form_template
})
if self.extra_context:
context.update(self.extra_context)
self.modify_context(context, self.request)
search_complete.send(sender=self, context=context)
if self.request.is_ajax():
ajax_data = {
'querystring': context_querystring,
'page': page,
'sort': sort,
'saved_search_pk': saved_search.pk if saved_search else '',
'table_html': loader.render_to_string(self.results_template, context, request=self.request),
'facet_data': {facet.field: facet.data(results) for facet in self.get_facets()},
}
if self.use_save_form:
ajax_data.update({
'save_form_html': loader.render_to_string(self.form_template, { 'form': form }, request=self.request)
})
if self.post_filter_facets:
ajax_data.update({
'form_html': loader.render_to_string(self.search_form_template, context, request=self.request)
})
return JsonResponse(ajax_data)
else:
return self.render_to_response(context)
def render_to_response(self, context):
return render(self.request, self.template_name, context)
def render_facet_query(self):
keywords = self.get_keywords(self.request.GET)
facet = {f.field: f for f in self.get_facets()}.get(self.request.GET.get('_facet'))
if not facet:
raise Http404()
# We want to apply all the other facet filters besides the one we're querying.
facets = self.get_facet_data(self.request.GET, exclude=facet)
search = self.get_search(keywords, facets, aggregate=False)
fq = '.*' + self.request.GET.get('_query', '').strip() + '.*'
facet.apply(search, include={'pattern': fq, 'flags': 'CASE_INSENSITIVE'})
return JsonResponse(facet.data(search.execute()))
def export(self):
"""
A helper method called when ``_export`` is present in ``request.GET``. Returns a ``StreamingHttpResponse``
that yields CSV data for all matching results.
"""
keywords = self.get_keywords(self.request.GET)
facets = self.get_facet_data(self.request.GET)
search = self.get_search(keywords, facets, aggregate=False)
columns = self.get_columns()
def csv_escape(value):
if isinstance(value, (list, tuple)):
value = '; '.join(force_text(v) for v in value)
return '"%s"' % force_text(value).replace('"', '""')
def csv_generator():
yield ','.join('"%s"' % c.label for c in columns if c.visible and c.export) + '\n'
for result in search.scan():
yield ','.join(csv_escape(c.export_value(result)) for c in columns if c.visible and c.export) + '\n'
export_timestamp = ('_' + timezone.now().strftime('%m-%d-%Y_%H-%M-%S')) if self.export_timestamp else ''
export_name = '%s%s.csv' % (self.export_name, export_timestamp)
resp = StreamingHttpResponse(csv_generator(), content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = 'attachment; filename=%s' % export_name
return resp
def get(self, request, *args, **kwargs):
if '_facet' in request.GET:
return self.render_facet_query()
elif '_export' in request.GET:
return self.export()
else:
return self.render()
def post(self, request, *args, **kwargs):
if not self.can_save:
return redirect(request.get_full_path())
post_qs = request.POST.get('querystring', '')
qs = self.normalized_querystring(post_qs, ignore=['p', 'saved_search'])
saved_search_pk = request.POST.get('saved_search', '').strip()
if not saved_search_pk.isdigit():
saved_search_pk = None
if '_save' in request.POST:
# A "sub" method that handles ajax save submissions (and returns JSON, not a redirect)
if request.is_ajax() and self.use_save_form:
response_data = {} # All data must be able to flatten to JSON
# First we check if the user is attempting to overwrite an existing search
saved_searches = request.user.seeker_searches.filter(url=request.path)
form_kwargs = { 'saved_searches': saved_searches, 'enforce_unique_name': self.enforce_unique_name }
saved_search_pk = request.POST.get('saved_search', '').strip()
if saved_search_pk:
# We really want to do a try/catch on a get but we don't know the model so we check first
saved_search = saved_searches.exists(pk=saved_search_pk)
if saved_search:
# Since we are using "pk" we know there can only be one so .get is safe
form_kwargs['instance'] = saved_searches.get(pk=saved_search_pk)
SavedSearchForm = self.get_saved_search_form()
form = SavedSearchForm(request.POST.copy(), **form_kwargs)
if form.is_valid():
saved_search = form.save(commit=False)
saved_search.user = request.user
saved_search.querystring = qs
saved_search.url = request.path
saved_search.save()
form.save_m2m()
messages.success(request, 'Successfully saved "%s".' % saved_search)
response_data['redirect_url'] = saved_search.get_absolute_url()
else: