add generic search cell (#15085)

This commit is contained in:
Thomas NOËL 2017-02-22 16:15:37 +01:00
parent ff7576fb7b
commit 4c3cf5e198
12 changed files with 438 additions and 0 deletions

View File

@ -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

29
combo/apps/search/README Normal file
View File

@ -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.

View File

@ -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'

View File

@ -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',
},
),
]

View File

115
combo/apps/search/models.py Normal file
View File

@ -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')

View File

@ -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>

View File

@ -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>

25
combo/apps/search/urls.py Normal file
View File

@ -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'),
)

View File

@ -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',
)

View File

@ -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')

109
tests/test_search.py Normal file
View File

@ -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/'