From 420ccd1f4d60cb3cd12c7b8e54a0c673c7fbda78 Mon Sep 17 00:00:00 2001 From: Thomas NOEL Date: Wed, 22 Feb 2017 16:15:37 +0100 Subject: [PATCH] add generic search cell (#15085) --- MANIFEST.in | 1 + combo/apps/search/README | 33 ++++ combo/apps/search/__init__.py | 28 ++++ combo/apps/search/migrations/0001_initial.py | 33 ++++ combo/apps/search/migrations/__init__.py | 0 combo/apps/search/models.py | 122 ++++++++++++++ .../search/templates/combo/search-cell-result.html | 21 +++ .../templates/combo/search-cell-results.html | 13 ++ combo/apps/search/templates/combo/search-cell.html | 31 ++++ combo/apps/search/urls.py | 25 +++ combo/settings.py | 1 + tests/test_search.py | 186 +++++++++++++++++++++ 12 files changed, 494 insertions(+) create mode 100644 combo/apps/search/README create mode 100644 combo/apps/search/__init__.py create mode 100644 combo/apps/search/migrations/0001_initial.py create mode 100644 combo/apps/search/migrations/__init__.py create mode 100644 combo/apps/search/models.py create mode 100644 combo/apps/search/templates/combo/search-cell-result.html create mode 100644 combo/apps/search/templates/combo/search-cell-results.html create mode 100644 combo/apps/search/templates/combo/search-cell.html create mode 100644 combo/apps/search/urls.py create mode 100644 tests/test_search.py diff --git a/MANIFEST.in b/MANIFEST.in index 84dbb59..269d0f2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 diff --git a/combo/apps/search/README b/combo/apps/search/README new file mode 100644 index 0000000..879b335 --- /dev/null +++ b/combo/apps/search/README @@ -0,0 +1,33 @@ +Configure Search Services in settings.COMBO_SEARCH_SERVICES: + +COMBO_SEARCH_SERVICES = { + 'user': { + 'label': "Search a user", + 'search_url': 'https://.../api/user/?q=%(q)s', + 'get_url': 'https://.../api/user/%(id)s/', + }, +} + +search_url must return a JSON: + + { + "err": 0, + "data": [ + { ... result 1 ... }, + { ... result 2 ... } + ... + ] + ... + } + +which is sent to template combo/search-cell-results.html + +get_url must return a JSON: + + { + "err": 0, + "data": { ... }, + ... + } + +which is sent to template combo/search-cell-result.html diff --git a/combo/apps/search/__init__.py b/combo/apps/search/__init__.py new file mode 100644 index 0000000..e551722 --- /dev/null +++ b/combo/apps/search/__init__.py @@ -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 . + +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' diff --git a/combo/apps/search/migrations/0001_initial.py b/combo/apps/search/migrations/0001_initial.py new file mode 100644 index 0000000..23f780a --- /dev/null +++ b/combo/apps/search/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/combo/apps/search/migrations/__init__.py b/combo/apps/search/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/search/models.py b/combo/apps/search/models.py new file mode 100644 index 0000000..804cb51 --- /dev/null +++ b/combo/apps/search/models.py @@ -0,0 +1,122 @@ +# 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 . + +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 len(getattr(settings, 'COMBO_SEARCH_SERVICES', {})) > 0 + + 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: + return self.slug.replace('-', '_') + # no hyphen in varname, could be used in templates + return 'search_item_%s' % self.pk + + @property + def search_service(self): + return settings.COMBO_SEARCH_SERVICES.get(self._search_service) or {} + + def modify_global_context(self, context, request): + # if there are several search cells, they know the results of all + if request.GET.get(self.varname): + if 'searches' not in context: + context['searches'] = {} + context['searches'][self.varname] = request.GET.get(self.varname) + + def render_item(self, context): + if not context.get('synchronous'): + raise NothingInCacheException() + + item_id = context.request.GET.get(self.varname) + url = self.search_service.get('get_url') % {'id': item_id} + item = requests.get(url, cache_duration=0).json() + + template_names = ['combo/search-cell-result.html'] + if self.slug: + template_names.insert(0, 'combo/cells/%s/search-cell-result.html' % self.slug) + tmpl = template.loader.select_template(template_names) + + context.update({ + 'request': context.request, + 'cell': self, + 'item': item, + }) + return tmpl.render(context) + + def render(self, context): + # if an item has been is selected, render it + if context.request.GET.get(self.varname) and self.search_service.get('get_url'): + return self.render_item(context) + # if not, render input and results + return super(SearchCell, self).render(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('search_url'): + url = cell.search_service.get('search_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') diff --git a/combo/apps/search/templates/combo/search-cell-result.html b/combo/apps/search/templates/combo/search-cell-result.html new file mode 100644 index 0000000..1708611 --- /dev/null +++ b/combo/apps/search/templates/combo/search-cell-result.html @@ -0,0 +1,21 @@ +{% load i18n %} + +{% trans 'Item:' %} +
    + {% for key, value in item.data.items %} +
  • {{ key }}: {{ value }}
  • + {% endfor %} +
+ +{% trans 'Context:' %} +
    +{% for kind, id in searches.items %} +
  • {{ kind }}: {{ id }}
  • +{% endfor %} +
+ +

+ + {% trans 'Perform a new search' %} + +

diff --git a/combo/apps/search/templates/combo/search-cell-results.html b/combo/apps/search/templates/combo/search-cell-results.html new file mode 100644 index 0000000..b36f654 --- /dev/null +++ b/combo/apps/search/templates/combo/search-cell-results.html @@ -0,0 +1,13 @@ + diff --git a/combo/apps/search/templates/combo/search-cell.html b/combo/apps/search/templates/combo/search-cell.html new file mode 100644 index 0000000..2264540 --- /dev/null +++ b/combo/apps/search/templates/combo/search-cell.html @@ -0,0 +1,31 @@ +{% load i18n %} + + + + diff --git a/combo/apps/search/urls.py b/combo/apps/search/urls.py new file mode 100644 index 0000000..0954a87 --- /dev/null +++ b/combo/apps/search/urls.py @@ -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 . + +from django.conf.urls import patterns, url + +from .models import SearchCell + +urlpatterns = patterns( + '', + url(r'^ajax/search/(?P\w+)/$', SearchCell.ajax_results_view, + name='combo-search-ajax-results'), +) diff --git a/combo/settings.py b/combo/settings.py index 5944d4d..93cd60b 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -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', ) diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..bc5093a --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,186 @@ +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 combo.apps.search.models import SearchCell +from combo.data.models import Page + +pytestmark = pytest.mark.django_db + +client = Client() + +SEARCH_SERVICES_1 = { + 'search1': { + 'label': 'Search 1', + 'search_url': 'http://www.example.net/search/?q=%(q)s', + 'get_url': 'http://www.example.net/get/%(id)s/', + } +} + +SEARCH_SERVICES_2 = { + 'search1': { + 'label': 'Search 1', + 'search_url': 'http://www.example.net/search/?q=%(q)s', + 'get_url': 'http://www.example.net/get/%(id)s/', + }, + 'search2': { + 'label': 'Search 2', + 'search_url': 'http://www.example.net/search2/?q=%(q)s', + 'get_url': 'http://www.example.net/get2/%(id)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_1): + assert SearchCell.is_enabled() == True + with SearchServices(SEARCH_SERVICES_2): + assert SearchCell.is_enabled() == True + with SearchServices({}): + assert SearchCell.is_enabled() == False + +def test_search_cell(app): + with SearchServices(SEARCH_SERVICES_1): + 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() + + request = RequestFactory().get('/search_page/') + context = Context({'request': request}) + context.request = request + context['synchronous'] = True # to get fresh content, disable ajax + + resp = cell.render(context) + assert 'input' in resp + assert 'id="combo-search-input-%s"' % 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 '
  • ' not in resp.content + + response['data'] = [{'id': 1, 'text': 'barbarbar'}] + resp = client.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) + assert resp.content.count('
  • ') == 1 + assert '' % cell.pk in resp.content + assert 'barbarbar (1)' in resp.content + + response['data'] = {'id': 'foobar', 'text': 'barfoo'} + request = RequestFactory().get('/search_page/?search_item_%s=foobar' % cell.pk) + context = Context({'request': request}) + context['synchronous'] = True # disable ajax + context.request = request + cell.modify_global_context(context, request) + resp = cell.render(context) + assert requests_get.call_args[0][0] == 'http://www.example.net/get/foobar/' + assert 'Item:' in resp + assert 'id: foobar' in resp + assert 'text: barfoo' in resp + assert 'Context:' in resp + assert 'search_item_1: foobar' in resp + + cell.slug = 'slug1' + cell.save() + + response['data'] = [{'id': 'foo', 'text': 'bar'}] + resp = client.get('/ajax/search/%s/?q=foo' % cell.pk, status=200) + assert resp.content.count('
  • ') == 1 + assert 'slug1=foo' in resp.content + assert '>bar (foo)' in resp.content + + response['data'] = {'id': 'foobar', 'text': 'barfoo'} + request = RequestFactory().get('/search_page/?slug1=foobar') + context = Context({'request': request}) + context['synchronous'] = True # disable ajax + context.request = request + cell.modify_global_context(context, request) + resp = cell.render(context) + assert 'Item:' in resp + assert 'id: foobar' in resp + assert 'text: barfoo' in resp + assert 'Context:' in resp + assert 'slug1: foobar' in resp + +def test_search_2_cells(app): + with SearchServices(SEARCH_SERVICES_2): + page = Page(title='Search', slug='search_page', template_name='standard') + page.save() + cell1 = SearchCell(page=page, placeholder='content', order=0) + cell1._search_service = 'search1' + cell1.slug = 'slug1' + cell1.save() + cell2 = SearchCell(page=page, placeholder='content', order=0) + cell2._search_service = 'search2' + cell2.slug = 'slug2' + cell2.save() + + resp = client.get('/search_page/', status=200) + assert 'id: foo') == 2 + assert resp.count('Context:') == 2 + assert resp.count('slug1: 1') == 2 + assert resp.count('slug2: 2') == 2 + + # only one cell "found" + request = RequestFactory().get('/search_page/?slug1=1') + context = Context({'request': request}) + context['synchronous'] = True # disable ajax + context.request = request + cell1.modify_global_context(context, request) + cell2.modify_global_context(context, request) + resp = cell1.render(context) + resp += cell2.render(context) + assert requests_get.call_count == 3 + assert requests_get.call_args_list[2][0] == ('http://www.example.net/get/1/',) + + assert resp.count('id: foo') == 1 + assert resp.count('Context:') == 1 + assert resp.count('slug1: 1') == 1 + assert resp.count('slug2') == 0 -- 2.11.0