0001-search-handle-several-search-engines-23534.patch
combo/apps/search/README | ||
---|---|---|
4 | 4 |
'user': { |
5 | 5 |
'label': 'Search a user', |
6 | 6 |
'url': 'https://.../api/user/?q=%(q)s', |
7 |
# 'cache_duration': 60, # in seconds, default is 0 |
|
8 |
# 'signature': True, # boolean, default is False |
|
7 | 9 |
}, |
8 | 10 |
} |
9 | 11 |
combo/apps/search/management/commands/update_index.py | ||
---|---|---|
30 | 30 |
dest='skip_external_links_collection') |
31 | 31 | |
32 | 32 |
def handle(self, **options): |
33 |
if SearchCell.objects.filter(_search_service='_text').count() == 0:
|
|
33 |
if not any(SearchCell.get_cells_by_search_service('_text')):
|
|
34 | 34 |
# do not index site if there's no matching search cell |
35 | 35 |
return |
36 | 36 |
if not options.get('skip_external_links_collection', False): |
combo/apps/search/migrations/0002_auto_20180720_1511.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
from __future__ import unicode_literals |
|
3 | ||
4 |
from django.db import migrations |
|
5 |
import jsonfield.fields |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('search', '0001_initial'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='searchcell', |
|
17 |
name='_search_services', |
|
18 |
field=jsonfield.fields.JSONField(default=dict, |
|
19 |
verbose_name='Search Services', |
|
20 |
blank=True), |
|
21 |
), |
|
22 |
] |
combo/apps/search/migrations/0003_create_search_services.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
from __future__ import unicode_literals |
|
3 | ||
4 |
from django.db import migrations |
|
5 | ||
6 | ||
7 |
def create_search_services(apps, schema_editor): |
|
8 |
SearchCell = apps.get_model('search', 'SearchCell') |
|
9 |
for searchcell in SearchCell.objects.all(): |
|
10 |
if searchcell._search_service: |
|
11 |
searchcell._search_services = {'data': [searchcell._search_service]} |
|
12 |
else: |
|
13 |
searchcell._search_services = {'data': []} |
|
14 |
searchcell.save() |
|
15 | ||
16 | ||
17 |
def back_to_search_service(apps, schema_editor): |
|
18 |
SearchCell = apps.get_model('search', 'SearchCell') |
|
19 |
for searchcell in SearchCell.objects.all(): |
|
20 |
if searchcell._search_services.get('data'): |
|
21 |
searchcell._search_service = searchcell._search_services.get('data')[0] |
|
22 |
else: |
|
23 |
searchcell._search_service = '' |
|
24 |
searchcell.save() |
|
25 | ||
26 | ||
27 |
class Migration(migrations.Migration): |
|
28 | ||
29 |
dependencies = [ |
|
30 |
('search', '0002_auto_20180720_1511'), |
|
31 |
] |
|
32 | ||
33 |
operations = [ |
|
34 |
migrations.RunPython(create_search_services, back_to_search_service), |
|
35 |
] |
combo/apps/search/migrations/0004_remove_searchcell__search_service.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
from __future__ import unicode_literals |
|
3 | ||
4 |
from django.db import migrations, models |
|
5 | ||
6 | ||
7 |
class Migration(migrations.Migration): |
|
8 | ||
9 |
dependencies = [ |
|
10 |
('search', '0003_create_search_services'), |
|
11 |
] |
|
12 | ||
13 |
operations = [ |
|
14 |
migrations.RemoveField( |
|
15 |
model_name='searchcell', |
|
16 |
name='_search_service', |
|
17 |
), |
|
18 |
] |
combo/apps/search/models.py | ||
---|---|---|
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 | 17 |
from django.conf import settings |
18 |
from django.db import models |
|
19 | 18 |
from django.utils.translation import ugettext_lazy as _ |
20 | 19 |
from django import template |
21 | 20 |
from django.http import HttpResponse |
22 | 21 |
from django.core.exceptions import PermissionDenied |
23 | 22 |
from django.core.urlresolvers import reverse |
24 |
from django.forms import models as model_forms, Select |
|
25 | 23 |
from django.utils.http import quote |
26 | 24 | |
25 |
from jsonfield import JSONField |
|
26 | ||
27 | 27 |
from combo.utils import requests |
28 | 28 |
from combo.data.models import CellBase |
29 | 29 |
from combo.data.library import register_cell_class |
30 |
from combo.utils import NothingInCacheException, get_templated_url |
|
30 |
from combo.utils import get_templated_url |
|
31 | ||
31 | 32 | |
32 | 33 |
@register_cell_class |
33 | 34 |
class SearchCell(CellBase): |
34 | 35 |
template_name = 'combo/search-cell.html' |
35 | 36 | |
36 |
_search_service = models.CharField(verbose_name=_('Search Service'), max_length=64)
|
|
37 |
_search_services = JSONField(_('Search Services'), default=dict, blank=True)
|
|
37 | 38 | |
38 | 39 |
class Meta: |
39 | 40 |
verbose_name = _('Search') |
40 | 41 | |
41 | 42 |
def is_visible(self, user=None): |
42 |
if not self.search_service: |
|
43 |
if not self.search_services:
|
|
43 | 44 |
return False |
44 | 45 |
return super(SearchCell, self).is_visible(user=user) |
45 | 46 | |
46 |
def get_default_form_class(self): |
|
47 |
search_services = [(None, _('Not configured'))] |
|
48 |
search_services.append(('_text', _('Page Contents'))) |
|
49 |
search_services.extend([(code, service['label']) |
|
50 |
for code, service in settings.COMBO_SEARCH_SERVICES.items()]) |
|
51 |
widgets = {'_search_service': Select(choices=search_services)} |
|
52 |
return model_forms.modelform_factory(self.__class__, |
|
53 |
fields=['_search_service'], |
|
54 |
widgets=widgets) |
|
55 | ||
56 | 47 |
@property |
57 | 48 |
def varname(self): |
58 | 49 |
if self.slug: |
... | ... | |
61 | 52 |
return '' |
62 | 53 | |
63 | 54 |
@property |
64 |
def search_service(self): |
|
65 |
if self._search_service == '_text': |
|
66 |
return {'url': reverse('api-search') + '?q=%(q)s', 'label': _('Page Contents')} |
|
67 |
return settings.COMBO_SEARCH_SERVICES.get(self._search_service) or {} |
|
55 |
def search_services(self): |
|
56 |
services = [] |
|
57 |
for service_slug in self._search_services.get('data') or []: |
|
58 |
if service_slug == '_text': |
|
59 |
service = {'url': reverse('api-search') + '?q=%(q)s', 'label': _('Page Contents')} |
|
60 |
else: |
|
61 |
service = settings.COMBO_SEARCH_SERVICES.get(service_slug) |
|
62 |
if service and service.get('url'): |
|
63 |
service['slug'] = service_slug |
|
64 |
services.append(service) |
|
65 |
return services |
|
66 | ||
67 |
@property |
|
68 |
def has_multiple_search_services(self): |
|
69 |
return len(self._search_services.get('data') or []) > 1 |
|
70 | ||
71 |
@classmethod |
|
72 |
def get_cells_by_search_service(cls, search_service): |
|
73 |
for cell in cls.objects.all(): |
|
74 |
if search_service in (cell._search_services.get('data') or []): |
|
75 |
yield cell |
|
68 | 76 | |
69 | 77 |
def modify_global_context(self, context, request): |
70 | 78 |
# if self.varname is in the query string (of the page), |
... | ... | |
94 | 102 |
return extra_context |
95 | 103 | |
96 | 104 |
@classmethod |
97 |
def ajax_results_view(cls, request, cell_pk): |
|
105 |
def ajax_results_view(cls, request, cell_pk, service_slug):
|
|
98 | 106 |
cell = cls.objects.get(pk=cell_pk) |
99 | 107 |
if not cell.is_visible(request.user) or not cell.page.is_visible(request.user): |
100 | 108 |
raise PermissionDenied |
101 | 109 | |
102 |
query = request.GET.get('q') |
|
103 |
if query and cell.search_service.get('url'): |
|
104 |
url = cell.search_service.get('url') |
|
105 |
url = get_templated_url(url) |
|
106 |
url = url % {'q': quote(query.encode('utf-8'))} |
|
107 |
if url.startswith('/'): |
|
108 |
url = request.build_absolute_uri(url) |
|
109 |
results = requests.get(url, cache_duration=0).json() |
|
110 |
def render_response(service={}, results={'err': 0, 'data': []}): |
|
111 |
template_names = ['combo/search-cell-results.html'] |
|
112 |
if cell.slug: |
|
113 |
template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug) |
|
114 |
tmpl = template.loader.select_template(template_names) |
|
115 |
context = {'cell': cell, 'results': results, 'search_service': service} |
|
116 |
return HttpResponse(tmpl.render(context, request), content_type='text/html') |
|
117 | ||
118 |
for service in cell.search_services: |
|
119 |
if service.get('slug') == service_slug: |
|
120 |
break |
|
110 | 121 |
else: |
111 |
results = {'err': 0, 'data': []} |
|
112 | ||
113 |
template_names = ['combo/search-cell-results.html'] |
|
114 |
if cell.slug: |
|
115 |
template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug) |
|
116 |
tmpl = template.loader.select_template(template_names) |
|
117 |
context= {'cell': cell, 'results': results} |
|
118 |
return HttpResponse(tmpl.render(context, request), content_type='text/html') |
|
122 |
return render_response() |
|
123 | ||
124 |
query = request.GET.get('q') |
|
125 |
if not query: |
|
126 |
return render_response(service) |
|
127 | ||
128 |
url = get_templated_url(service['url'], context={'q': query, 'search_service': service}) |
|
129 |
url = url % {'q': quote(query.encode('utf-8'))} # if url contains %(q)s |
|
130 |
if url.startswith('/'): |
|
131 |
url = request.build_absolute_uri(url) |
|
132 | ||
133 |
if not url: |
|
134 |
return render_response(service) |
|
135 | ||
136 |
kwargs = {} |
|
137 |
kwargs['cache_duration'] = service.get('cache_duration', 0) |
|
138 |
kwargs['remote_service'] = 'auto' if service.get('signature') else None |
|
139 |
results = requests.get(url, **kwargs).json() |
|
140 |
return render_response(service, results) |
combo/apps/search/templates/combo/search-cell-results.html | ||
---|---|---|
1 |
{% if results.data %} |
|
2 |
{% if cell.has_multiple_search_services %}<p class="search-service-label">{{ search_service.label }}</p>{% endif %} |
|
1 | 3 |
<div class="links-list"> |
2 | 4 |
<ul> |
3 | 5 |
{% for item in results.data %} |
... | ... | |
7 | 9 |
{% endfor %} |
8 | 10 |
</ul> |
9 | 11 |
</div> |
12 |
{% endif %} |
combo/apps/search/templates/combo/search-cell.html | ||
---|---|---|
2 | 2 |
{% block cell-content %} |
3 | 3 | |
4 | 4 |
<form id="combo-search-form-{{ cell.pk }}" class="combo-search-form"> |
5 |
<input type="text" name="q" autocomplete="off" id="combo-search-input-{{ cell.pk }}" class="combo-search-input" |
|
6 |
data-autocomplete-json="{% url 'combo-search-ajax-results' cell_pk=cell.pk %}{% if initial_query_string %}?{{ initial_query_string }}{% endif %}" />{# initial_query_string pass some context to ajax call #} |
|
5 |
<input type="text" name="q" autocomplete="off" id="combo-search-input-{{ cell.pk }}" class="combo-search-input" /> |
|
7 | 6 |
<button class="submit-button">{% trans "Search" %}</button> |
8 | 7 |
</form> |
9 | 8 | |
10 |
<div id="combo-search-results-{{ cell.pk }}" class="combo-search-results"></div> |
|
9 |
{% for search_service in cell.search_services %} |
|
10 |
<div id="combo-search-results-{{ cell.pk }}-{{ forloop.counter }}" class="combo-search-results combo-search-results-{{ search_service.slug }}"></div> |
|
11 |
{% endfor %} |
|
11 | 12 | |
12 | 13 |
<script> |
13 | 14 |
$(function() { |
14 | 15 |
var combo_search_timeout_{{ cell.pk }}; |
15 | 16 |
var combo_search_form_{{ cell.pk }} = $('#combo-search-form-{{ cell.pk }}'); |
16 | 17 |
var combo_search_input_{{ cell.pk }} = $('#combo-search-input-{{ cell.pk }}'); |
17 |
var combo_search_results_{{ cell.pk }} = $('#combo-search-results-{{ cell.pk }}'); |
|
18 |
var xhr = null; |
|
18 |
{% for search_service in cell.search_services %} |
|
19 |
var combo_search_results_{{ cell.pk }}_{{ forloop.counter }} = $('#combo-search-results-{{ cell.pk }}-{{ forloop.counter }}'); |
|
20 |
var xhr_{{ forloop.counter }} = null; |
|
21 |
var url_{{ forloop.counter }} = '{% url 'combo-search-ajax-results' cell_pk=cell.pk service_slug=search_service.slug %}{% if initial_query_string %}?{{ intial_query_string }}{% endif %}'; |
|
22 |
{% endfor %} |
|
19 | 23 | |
20 | 24 |
function combo_search_update_{{ cell.pk }}() { |
21 |
if (xhr) xhr.abort(); |
|
22 |
xhr = $.get(combo_search_input_{{ cell.pk }}.data('autocomplete-json'), |
|
25 |
{% for search_service in cell.search_services %} |
|
26 |
if (xhr_{{ forloop.counter }}) xhr_{{ forloop.counter }}.abort(); |
|
27 |
xhr_{{ forloop.counter }} = $.get(url_{{ forloop.counter }}, |
|
23 | 28 |
{'q': combo_search_input_{{ cell.pk }}.val()}, |
24 | 29 |
function (response) { |
25 |
xhr = null; |
|
26 |
combo_search_results_{{ cell.pk }}.html(response); |
|
30 |
xhr_{{ forloop.counter }} = null;
|
|
31 |
combo_search_results_{{ cell.pk }}_{{ forloop.counter }}.html(response);
|
|
27 | 32 |
} |
28 | 33 |
); |
34 |
{% endfor %} |
|
29 | 35 |
}; |
30 | 36 | |
31 | 37 |
combo_search_input_{{ cell.pk }}.on('paste keyup', function() { |
combo/apps/search/urls.py | ||
---|---|---|
19 | 19 |
from .models import SearchCell |
20 | 20 | |
21 | 21 |
urlpatterns = [ |
22 |
url(r'^ajax/search/(?P<cell_pk>\w+)/$', SearchCell.ajax_results_view, |
|
22 |
url(r'^ajax/search/(?P<cell_pk>\w+)/(?P<service_slug>[\w-]+)/$', SearchCell.ajax_results_view,
|
|
23 | 23 |
name='combo-search-ajax-results'), |
24 | 24 |
] |
combo/public/views.py | ||
---|---|---|
491 | 491 | |
492 | 492 | |
493 | 493 |
def api_search(request): |
494 |
for cell in SearchCell.objects.filter(_search_service='_text'):
|
|
494 |
for cell in SearchCell.get_cells_by_search_service('_text'):
|
|
495 | 495 |
if not cell.is_visible(request.user): |
496 | 496 |
continue |
497 | 497 |
break |
tests/test_search.py | ||
---|---|---|
48 | 48 |
page.save() |
49 | 49 | |
50 | 50 |
cell = SearchCell(page=page, placeholder='content', order=0) |
51 |
cell._search_service = 'search1'
|
|
51 |
cell._search_services = {'data': ['search1']}
|
|
52 | 52 |
cell.save() |
53 | 53 | |
54 | 54 |
resp = cell.render({}) |
... | ... | |
66 | 66 |
mock_json = mock.Mock() |
67 | 67 |
mock_json.json.return_value = response |
68 | 68 |
requests_get.return_value = mock_json |
69 |
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) |
|
69 |
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
|
|
70 | 70 |
assert requests_get.call_args[0][0] == 'http://www.example.net/search/?q=foo' |
71 | 71 |
assert '<li>' not in resp.text |
72 | 72 | |
73 |
resp = app.get('/ajax/search/%s/?q=foo%%23bar' % cell.pk, status=200) |
|
73 |
resp = app.get('/ajax/search/%s/search1/?q=foo%%23bar' % cell.pk, status=200)
|
|
74 | 74 |
assert requests_get.call_args[0][0] == 'http://www.example.net/search/?q=foo%23bar' |
75 | 75 |
assert '<li>' not in resp.text |
76 | 76 | |
77 | 77 |
response['data'] = [{'url': 'http://test', 'text': 'barbarbar'}] |
78 |
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) |
|
78 |
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
|
|
79 | 79 |
assert resp.text.count('<li>') == 1 |
80 | 80 |
assert '<li><a href="http://test">barbarbar</a>' in resp.text |
81 | 81 | |
82 | 82 |
response['data'] = [{'url': 'http://test', 'text': 'barbarbar', |
83 | 83 |
'description': 'this is <b>html</b>'}] |
84 |
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) |
|
84 |
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
|
|
85 | 85 |
assert resp.text.count('<li>') == 1 |
86 | 86 |
assert '<li><a href="http://test">barbarbar</a>' in resp.text |
87 | 87 |
assert 'this is <b>html</b>' in resp.text |
88 | 88 | |
89 | 89 |
with override_settings(TEMPLATE_VARS=TEMPLATE_VARS): |
90 |
cell._search_service = 'search_tmpl'
|
|
90 |
cell._search_services = {'data': ['search_tmpl']}
|
|
91 | 91 |
cell.save() |
92 | 92 |
with mock.patch('combo.apps.search.models.requests.get') as requests_get: |
93 | 93 |
response = {'err': 0, 'data': []} |
94 | 94 |
mock_json = mock.Mock() |
95 | 95 |
mock_json.json.return_value = response |
96 | 96 |
requests_get.return_value = mock_json |
97 |
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) |
|
97 |
resp = app.get('/ajax/search/%s/search_tmpl/?q=foo' % cell.pk, status=200)
|
|
98 | 98 |
assert requests_get.call_args[0][0] == 'http://search.example.net/?q=foo' |
99 | 99 | |
100 | 100 |
# TEMPLATE_VARS are accessible in template |
... | ... | |
103 | 103 |
templates_settings = [settings.TEMPLATES[0].copy()] |
104 | 104 |
templates_settings[0]['DIRS'] = ['%s/templates-1' % os.path.abspath(os.path.dirname(__file__))] |
105 | 105 |
with override_settings(TEMPLATES=templates_settings): |
106 |
resp = app.get('/ajax/search/%s/?q=bar' % cell.pk, status=200) |
|
106 |
resp = app.get('/ajax/search/%s/search_tmpl/?q=bar' % cell.pk, status=200)
|
|
107 | 107 |
assert requests_get.call_args[0][0] == 'http://search.example.net/?q=bar' |
108 | 108 |
assert 'searchfoo results.data=[]' in resp.text |
109 | 109 |
assert 'search_url=http://search.example.net/' in resp.text |
... | ... | |
113 | 113 |
page = Page(title='Search', slug='search_page', template_name='standard') |
114 | 114 |
page.save() |
115 | 115 |
cell = SearchCell(page=page, placeholder='content', order=0) |
116 |
cell._search_service = 'search1'
|
|
116 |
cell._search_services = {'data': ['search1']}
|
|
117 | 117 |
cell.save() |
118 | 118 |
assert cell.varname == '' |
119 | 119 | |
... | ... | |
143 | 143 |
cell = SearchCell(page=page, order=0) |
144 | 144 |
assert not cell.is_visible() |
145 | 145 | |
146 |
cell._search_service = '_text'
|
|
146 |
cell._search_services = {'data': ['_text']}
|
|
147 | 147 |
assert cell.is_visible() |
148 | 148 | |
149 | 149 |
def test_search_contents(): |
... | ... | |
218 | 218 | |
219 | 219 |
resp = app.get('/api/search/?q=foobar', status=404) |
220 | 220 | |
221 |
cell = SearchCell(page=page, _search_service='_text', order=0)
|
|
221 |
cell = SearchCell(page=page, _search_services={'data': ['_text']}, order=0)
|
|
222 | 222 |
cell.save() |
223 | 223 | |
224 | 224 |
resp = app.get('/api/search/?q=foobar', status=200) |
... | ... | |
242 | 242 |
page = Page(title='example page', slug='example-page') |
243 | 243 |
page.save() |
244 | 244 | |
245 |
cell = SearchCell(page=page, _search_service='_text', order=0)
|
|
245 |
cell = SearchCell(page=page, _search_services={'data': ['_text']}, order=0)
|
|
246 | 246 |
cell.save() |
247 | 247 | |
248 | 248 |
call_command('update_index') |
249 |
- |