add generic search cell (#15085)
This commit is contained in:
parent
ff7576fb7b
commit
4c3cf5e198
|
@ -9,6 +9,7 @@ recursive-include combo/public/static *.css *.js *.ico *.gif *.png *.jpg
|
|||
recursive-include data/themes *.css *.js *.gif *.png *.jpg *.jpeg *.html
|
||||
|
||||
# templates
|
||||
recursive-include combo/apps/search/templates *.html
|
||||
recursive-include combo/apps/usersearch/templates *.html
|
||||
recursive-include combo/apps/dataviz/templates *.html
|
||||
recursive-include combo/apps/family/templates *.html
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
Configure search services in settings.COMBO_SEARCH_SERVICES:
|
||||
|
||||
COMBO_SEARCH_SERVICES = {
|
||||
'user': {
|
||||
'label': 'Search a user',
|
||||
'url': 'https://.../api/user/?q=%(q)s',
|
||||
},
|
||||
}
|
||||
|
||||
'url' must return a JSON:
|
||||
|
||||
{
|
||||
"err": 0,
|
||||
"data": [
|
||||
{
|
||||
'url': 'https://example.net/page3/',
|
||||
'text': 'example, page3',
|
||||
'description': 'this can be <b>html</b> text'
|
||||
},
|
||||
{ ... }, ...
|
||||
]
|
||||
...
|
||||
}
|
||||
|
||||
which is sent to template 'combo/search-cell-results.html'
|
||||
|
||||
If the cell slug is "foo" and a variable named "foo" exists in the
|
||||
query_string, it is added to the global page context, so it can be
|
||||
used, for example, by a JsonCell.
|
|
@ -0,0 +1,28 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2014-2017 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import django.apps
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.search'
|
||||
verbose_name = _('Search')
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
return urls.urlpatterns
|
||||
|
||||
default_app_config = 'combo.apps.search.AppConfig'
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('data', '0022_auto_20170214_2006'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SearchCell',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('placeholder', models.CharField(max_length=20)),
|
||||
('order', models.PositiveIntegerField()),
|
||||
('slug', models.SlugField(verbose_name='Slug', blank=True)),
|
||||
('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)),
|
||||
('public', models.BooleanField(default=True, verbose_name='Public')),
|
||||
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
|
||||
('last_update_timestamp', models.DateTimeField(auto_now=True)),
|
||||
('_search_service', models.CharField(max_length=64, verbose_name='Search Service')),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Search',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,115 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2014-2017 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django import template
|
||||
from django.http import HttpResponse
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms import models as model_forms, Select
|
||||
|
||||
from combo.utils import requests
|
||||
from combo.data.models import CellBase
|
||||
from combo.data.library import register_cell_class
|
||||
from combo.utils import NothingInCacheException
|
||||
|
||||
@register_cell_class
|
||||
class SearchCell(CellBase):
|
||||
template_name = 'combo/search-cell.html'
|
||||
|
||||
_search_service = models.CharField(verbose_name=_('Search Service'), max_length=64)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Search')
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return bool(getattr(settings, 'COMBO_SEARCH_SERVICES', {}))
|
||||
|
||||
def is_visible(self, user=None):
|
||||
return super(SearchCell, self).is_visible(user=user)
|
||||
|
||||
def get_default_form_class(self):
|
||||
search_services = [(None, _('Not configured'))]
|
||||
search_services.extend([(code, service['label'])
|
||||
for code, service in getattr(settings, 'COMBO_SEARCH_SERVICES', {}).items()])
|
||||
widgets = {'_search_service': Select(choices=search_services)}
|
||||
return model_forms.modelform_factory(self.__class__,
|
||||
fields=['_search_service'],
|
||||
widgets=widgets)
|
||||
|
||||
@property
|
||||
def varname(self):
|
||||
if self.slug:
|
||||
# no hyphen in varname, could be used in context and templates
|
||||
return self.slug.replace('-', '_')
|
||||
return ''
|
||||
|
||||
@property
|
||||
def search_service(self):
|
||||
return settings.COMBO_SEARCH_SERVICES.get(self._search_service) or {}
|
||||
|
||||
def modify_global_context(self, context, request):
|
||||
# if self.varname is in the query string (of the page),
|
||||
# add it to the global context; so it can be used by others cells
|
||||
# for example by a JsonCell with ...[self.varname]... in its URL
|
||||
if self.varname and self.varname in request.GET:
|
||||
context[self.varname] = request.GET.get(self.varname)
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
extra_context = super(SearchCell, self).get_cell_extra_context(context)
|
||||
# if there is a q_<slug> in query_string, send it to the template (to be
|
||||
# used as an initial query) and remove it from query_string
|
||||
initial_q = None
|
||||
initial_query_string = None
|
||||
if context.get('request'):
|
||||
request_get = context['request'].GET.copy()
|
||||
if self.varname and context.get('request'):
|
||||
q_varname = 'q_%s' % self.varname
|
||||
if q_varname in request_get:
|
||||
initial_q = request_get[q_varname]
|
||||
del request_get[q_varname]
|
||||
initial_query_string = request_get.urlencode()
|
||||
extra_context.update({
|
||||
'initial_q': initial_q,
|
||||
'initial_query_string': initial_query_string
|
||||
})
|
||||
return extra_context
|
||||
|
||||
@classmethod
|
||||
def ajax_results_view(cls, request, cell_pk):
|
||||
cell = cls.objects.get(pk=cell_pk)
|
||||
if not cell.is_visible(request.user) or not cell.page.is_visible(request.user):
|
||||
raise PermissionDenied
|
||||
|
||||
query = request.GET.get('q')
|
||||
if query and cell.search_service.get('url'):
|
||||
url = cell.search_service.get('url') % {'q': query}
|
||||
results = requests.get(url, cache_duration=0).json()
|
||||
else:
|
||||
results = {'err': 0, 'data': []}
|
||||
|
||||
template_names = ['combo/search-cell-results.html']
|
||||
if cell.slug:
|
||||
template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug)
|
||||
tmpl = template.loader.select_template(template_names)
|
||||
context = {
|
||||
'request': request,
|
||||
'cell': cell,
|
||||
'results': results
|
||||
}
|
||||
return HttpResponse(tmpl.render(context), content_type='text/html')
|
|
@ -0,0 +1,9 @@
|
|||
<div class="links-list">
|
||||
<ul>
|
||||
{% for item in results.data %}
|
||||
<li><a href="{{ item.url }}">{{ item.text }}</a>{% if item.description %}
|
||||
{{ item.description|safe }}
|
||||
{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,46 @@
|
|||
{% load i18n %}
|
||||
|
||||
<form id="combo-search-form-{{ cell.pk }}" class="combo-search-form">
|
||||
<input type="text" name="q" autocomplete="off" id="combo-search-input-{{ cell.pk }}" class="combo-search-input"
|
||||
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 #}
|
||||
<button class="submit-button">{% trans "Search" %}</button>
|
||||
</form>
|
||||
|
||||
<div id="combo-search-results-{{ cell.pk }}" class="combo-search-results"></div>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
var combo_search_timeout_{{ cell.pk }};
|
||||
var combo_search_form_{{ cell.pk }} = $('#combo-search-form-{{ cell.pk }}');
|
||||
var combo_search_input_{{ cell.pk }} = $('#combo-search-input-{{ cell.pk }}');
|
||||
var combo_search_results_{{ cell.pk }} = $('#combo-search-results-{{ cell.pk }}');
|
||||
|
||||
function combo_search_update_{{ cell.pk }}() {
|
||||
$.get(combo_search_input_{{ cell.pk }}.data('autocomplete-json'),
|
||||
{'q': combo_search_input_{{ cell.pk }}.val()},
|
||||
function (response) {
|
||||
combo_search_results_{{ cell.pk }}.html(response);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
combo_search_input_{{ cell.pk }}.on('paste keyup', function() {
|
||||
clearTimeout(combo_search_timeout_{{ cell.pk }});
|
||||
combo_search_timeout_{{ cell.pk }} = setTimeout(combo_search_update_{{ cell.pk }}, 300);
|
||||
});
|
||||
combo_search_input_{{ cell.pk }}.on('change', function() {
|
||||
clearTimeout(combo_search_timeout_{{ cell.pk }});
|
||||
combo_search_update_{{ cell.pk }}(this);
|
||||
});
|
||||
combo_search_form_{{ cell.pk }}.on('submit', function() {
|
||||
clearTimeout(combo_search_timeout_{{ cell.pk }});
|
||||
combo_search_update_{{ cell.pk }}();
|
||||
return false;
|
||||
});
|
||||
|
||||
{% if initial_q %}
|
||||
combo_search_input_{{ cell.pk }}.val('{{ initial_q }}');
|
||||
combo_search_update_{{ cell.pk }}();
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,25 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2014-2017 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
from .models import SearchCell
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^ajax/search/(?P<cell_pk>\w+)/$', SearchCell.ajax_results_view,
|
||||
name='combo-search-ajax-results'),
|
||||
)
|
|
@ -71,6 +71,7 @@ INSTALLED_APPS = (
|
|||
'combo.apps.newsletters',
|
||||
'combo.apps.fargo',
|
||||
'combo.apps.notifications',
|
||||
'combo.apps.search',
|
||||
'combo.apps.usersearch',
|
||||
'xstatic.pkg.chartnew_js',
|
||||
)
|
||||
|
|
|
@ -13,6 +13,7 @@ from webtest import Upload
|
|||
|
||||
from combo.wsgi import application
|
||||
from combo.data.models import Page, CellBase, TextCell, LinkCell
|
||||
from combo.apps.search.models import SearchCell
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -290,6 +291,47 @@ def test_add_all_basic_cell_types(app, admin_user):
|
|||
resp = app.get(option.get('data-add-url'))
|
||||
resp = resp.follow()
|
||||
|
||||
SEARCH_SERVICES = {
|
||||
'search1': {
|
||||
'label': 'Search 1',
|
||||
'url': 'http://www.example.net/search/?q=%(q)s',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SearchServices(object):
|
||||
def __init__(self, search_services):
|
||||
self.search_services = search_services
|
||||
def __enter__(self):
|
||||
settings.COMBO_SEARCH_SERVICES = self.search_services
|
||||
def __exit__(self, *args, **kwargs):
|
||||
delattr(settings, 'COMBO_SEARCH_SERVICES')
|
||||
|
||||
def test_search_cell(app, admin_user):
|
||||
Page.objects.all().delete()
|
||||
page = Page(title='One', slug='one', template_name='standard')
|
||||
page.save()
|
||||
|
||||
# add
|
||||
app = login(app)
|
||||
assert SearchCell.objects.count() == 0
|
||||
with SearchServices(SEARCH_SERVICES):
|
||||
resp = app.get('/manage/pages/%s/' % page.id)
|
||||
for option in resp.html.find_all('option'):
|
||||
if 'content/search_' in option.get('data-add-url'):
|
||||
resp = app.get(option.get('data-add-url'))
|
||||
resp = resp.follow()
|
||||
assert SearchCell.objects.count() == 1
|
||||
cell = SearchCell.objects.first()
|
||||
|
||||
# delete
|
||||
app = login(app)
|
||||
resp = app.get('/manage/pages/%s/' % page.id)
|
||||
resp = resp.click(href='/search_searchcell-%s/delete' % cell.id)
|
||||
resp = resp.form.submit()
|
||||
assert SearchCell.objects.count() == 0
|
||||
|
||||
|
||||
def test_delete_cell(app, admin_user):
|
||||
Page.objects.all().delete()
|
||||
page = Page(title='One', slug='one', template_name='standard')
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
import json
|
||||
import pytest
|
||||
import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import Client
|
||||
from django.test.client import RequestFactory
|
||||
from django.template import Context
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from combo.apps.search.models import SearchCell
|
||||
from combo.data.models import Page, JsonCell
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
client = Client()
|
||||
|
||||
SEARCH_SERVICES = {
|
||||
'search1': {
|
||||
'label': 'Search 1',
|
||||
'url': 'http://www.example.net/search/?q=%(q)s',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SearchServices(object):
|
||||
def __init__(self, search_services):
|
||||
self.search_services = search_services
|
||||
|
||||
def __enter__(self):
|
||||
settings.COMBO_SEARCH_SERVICES = self.search_services
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
delattr(settings, 'COMBO_SEARCH_SERVICES')
|
||||
|
||||
def test_enabled(app):
|
||||
assert SearchCell.is_enabled() == False
|
||||
with SearchServices(SEARCH_SERVICES):
|
||||
assert SearchCell.is_enabled() == True
|
||||
with SearchServices({}):
|
||||
assert SearchCell.is_enabled() == False
|
||||
|
||||
def test_search_cell(app):
|
||||
with SearchServices(SEARCH_SERVICES):
|
||||
page = Page(title='Search', slug='search_page', template_name='standard')
|
||||
page.save()
|
||||
|
||||
cell = SearchCell(page=page, placeholder='content', order=0)
|
||||
cell._search_service = 'search1'
|
||||
cell.save()
|
||||
|
||||
context = Context({})
|
||||
resp = cell.render(context)
|
||||
assert 'input' in resp
|
||||
assert 'id="combo-search-input-%s"' % cell.pk in resp
|
||||
|
||||
cell.slug = 'var-name'
|
||||
context = Context({'request': RequestFactory().get('/?q_var_name=searchme')})
|
||||
resp = cell.render(context)
|
||||
assert "combo_search_input_%s.val('searchme');" % cell.pk in resp
|
||||
|
||||
with mock.patch('combo.apps.search.models.requests.get') as requests_get:
|
||||
|
||||
response = {'err': 0, 'data': []}
|
||||
mock_json = mock.Mock()
|
||||
mock_json.json.return_value = response
|
||||
requests_get.return_value = mock_json
|
||||
resp = client.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
|
||||
assert requests_get.call_args[0][0] == 'http://www.example.net/search/?q=foo'
|
||||
assert '<li>' not in resp.content
|
||||
|
||||
response['data'] = [{'url': 'http://test', 'text': 'barbarbar'}]
|
||||
resp = client.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
|
||||
assert resp.content.count('<li>') == 1
|
||||
assert '<li><a href="http://test">barbarbar</a></li>' in resp.content
|
||||
|
||||
response['data'] = [{'url': 'http://test', 'text': 'barbarbar',
|
||||
'description': 'this is <b>html</b>'}]
|
||||
resp = client.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
|
||||
assert resp.content.count('<li>') == 1
|
||||
assert '<li><a href="http://test">barbarbar</a>' in resp.content
|
||||
assert 'this is <b>html</b>' in resp.content
|
||||
|
||||
|
||||
def test_search_global_context(app):
|
||||
with SearchServices(SEARCH_SERVICES):
|
||||
page = Page(title='Search', slug='search_page', template_name='standard')
|
||||
page.save()
|
||||
cell = SearchCell(page=page, placeholder='content', order=0)
|
||||
cell._search_service = 'search1'
|
||||
cell.save()
|
||||
assert cell.varname == ''
|
||||
|
||||
cell.slug = 'search-item'
|
||||
cell.save()
|
||||
assert cell.varname == 'search_item'
|
||||
|
||||
jsoncell = JsonCell(page=page, placeholder='content', order=0)
|
||||
jsoncell.url = 'http://www.example.net/search/[search_item]/'
|
||||
jsoncell.save()
|
||||
url = reverse('combo-public-ajax-page-cell',
|
||||
kwargs={'page_pk': page.id, 'cell_reference': jsoncell.get_reference()}) + \
|
||||
'?search_item=foo'
|
||||
|
||||
with mock.patch('combo.utils.requests.get') as requests_get:
|
||||
data = {'data': []}
|
||||
requests_get.return_value = mock.Mock(content=json.dumps(data), status_code=200)
|
||||
resp = app.get(url)
|
||||
assert requests_get.call_args[0][0] == 'http://www.example.net/search/foo/'
|
Loading…
Reference in New Issue