diff --git a/.travis.yml b/.travis.yml index da2ea7552..7a6a2f37d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,6 +36,7 @@ env: - TOX_ENV=py33-django1.7 - TOX_ENV=py34-django1.5 - TOX_ENV=py34-django1.6 + - TOX_ENV=py34-django1.7 - TOX_ENV=pypy-django1.5 - TOX_ENV=pypy-django1.6 - TOX_ENV=pypy-django1.7 diff --git a/haystack/generic_views.py b/haystack/generic_views.py new file mode 100644 index 000000000..4ca2903a0 --- /dev/null +++ b/haystack/generic_views.py @@ -0,0 +1,126 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.core.paginator import Paginator +from django.views.generic import FormView +from django.views.generic.edit import FormMixin +from django.views.generic.list import MultipleObjectMixin + +from .forms import FacetedSearchForm +from .forms import ModelSearchForm +from .query import SearchQuerySet + + +RESULTS_PER_PAGE = getattr(settings, 'HAYSTACK_SEARCH_RESULTS_PER_PAGE', 20) + + +class SearchMixin(MultipleObjectMixin, FormMixin): + """ + A mixin that allows adding in Haystacks search functionality into + another view class. + + This mixin exhibits similar end functionality as the base Haystack search + view, but with some important distinctions oriented around greater + compatibility with Django's built-in class based views and mixins. + + Normal flow: + + self.request = request + + self.form = self.build_form() + self.query = self.get_query() + self.results = self.get_results() + + return self.create_response() + + This mixin should: + + 1. Make the form + 2. Get the queryset + 3. Return the paginated queryset + + """ + template_name = 'search/search.html' + load_all = True + form_class = ModelSearchForm + queryset = SearchQuerySet() + context_object_name = None + paginate_by = RESULTS_PER_PAGE + paginate_orphans = 0 + paginator_class = Paginator + page_kwarg = 'page' + form_name = 'form' + search_field = 'q' + object_list = None + + def get_form_kwargs(self): + """ + Returns the keyword arguments for instantiating the form. + """ + kwargs = {'initial': self.get_initial()} + if self.request.method == 'GET': + kwargs.update({ + 'data': self.request.GET, + }) + kwargs.update({'searchqueryset': self.get_queryset()}) + return kwargs + + def form_invalid(self, form): + context = self.get_context_data(**{ + self.form_name: form, + 'object_list': self.get_queryset() + }) + return self.render_to_response(context) + + def form_valid(self, form): + self.queryset = form.search() + context = self.get_context_data(**{ + self.form_name: form, + 'query': form.cleaned_data.get(self.search_field), + 'object_list': self.queryset + }) + return self.render_to_response(context) + + +class FacetedSearchMixin(SearchMixin): + """ + A mixin that allows adding in a Haystack search functionality with search + faceting. + """ + form_class = FacetedSearchForm + + def get_form_kwargs(self): + kwargs = super(SearchMixin, self).get_form_kwargs() + kwargs.update({ + 'selected_facets': self.request.GET.getlist("selected_facets") + }) + return kwargs + + def get_context_data(self, **kwargs): + context = super(FacetedSearchMixin, self).get_context_data(**kwargs) + context.update({'facets': self.results.facet_counts()}) + return context + + +class SearchView(SearchMixin, FormView): + """A view class for searching a Haystack managed search index""" + + def get(self, request, *args, **kwargs): + """ + Handles GET requests and instantiates a blank version of the form. + """ + form_class = self.get_form_class() + form = self.get_form(form_class) + + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + +class FacetedSearchView(FacetedSearchMixin, SearchView): + """ + A view class for searching a Haystack managed search index with + facets + """ + pass diff --git a/haystack/models.py b/haystack/models.py index a250c8f81..5626a3aa8 100644 --- a/haystack/models.py +++ b/haystack/models.py @@ -95,7 +95,13 @@ def _set_object(self, obj): def _get_model(self): if self._model is None: - self._model = models.get_model(self.app_label, self.model_name) + try: + self._model = models.get_model(self.app_label, self.model_name) + except LookupError: + # this changed in change 1.7 to throw an error instead of + # returning None when the model isn't found. So catch the + # lookup error and keep self._model == None. + pass return self._model @@ -127,7 +133,7 @@ def _get_distance(self): if location_field is None: return None - lf_lng, lf_lat = location_field.get_coords() + lf_lng, lf_lat = location_field.get_coords() self._distance = Distance(km=geopy_distance.distance((po_lat, po_lng), (lf_lat, lf_lng)).km) # We've either already calculated it or the backend returned it, so diff --git a/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py b/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py index ff07bdf57..6287c362c 100644 --- a/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py +++ b/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py @@ -201,7 +201,11 @@ def get_model(self): class TestSettings(TestCase): def test_kwargs_are_passed_on(self): from haystack.backends.elasticsearch_backend import ElasticsearchSearchBackend - backend = ElasticsearchSearchBackend('alias', **{'URL': {}, 'INDEX_NAME': 'testing', 'KWARGS': {'max_retries': 42}}) + backend = ElasticsearchSearchBackend('alias', **{ + 'URL': settings.HAYSTACK_CONNECTIONS['elasticsearch']['URL'], + 'INDEX_NAME': 'testing', + 'KWARGS': {'max_retries': 42} + }) self.assertEqual(backend.conn.transport.max_retries, 42) @@ -444,7 +448,7 @@ def test_build_schema(self): (content_field_name, mapping) = self.sb.build_schema(old_ui.all_searchfields()) self.assertEqual(content_field_name, 'text') - self.assertEqual(len(mapping), 4+2) # +2 management fields + self.assertEqual(len(mapping), 4 + 2) # +2 management fields self.assertEqual(mapping, { 'django_id': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, 'django_ct': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, @@ -458,7 +462,7 @@ def test_build_schema(self): ui.build(indexes=[ElasticsearchComplexFacetsMockSearchIndex()]) (content_field_name, mapping) = self.sb.build_schema(ui.all_searchfields()) self.assertEqual(content_field_name, 'text') - self.assertEqual(len(mapping), 15+2) # +2 management fields + self.assertEqual(len(mapping), 15 + 2) # +2 management fields self.assertEqual(mapping, { 'django_id': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, 'django_ct': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False}, diff --git a/test_haystack/solr_tests/test_templatetags.py b/test_haystack/solr_tests/test_templatetags.py index 95a6e4462..5265d1ba4 100644 --- a/test_haystack/solr_tests/test_templatetags.py +++ b/test_haystack/solr_tests/test_templatetags.py @@ -56,6 +56,6 @@ def test_more_like_this_for_model(self, mock_sqs): call().models().more_like_this().__getitem__(slice(None, 5))], any_order=True) - if django.get_version() == '1.7': + if django.VERSION >= (1, 7, 0): # FIXME: https://github.com/toastdriven/django-haystack/issues/1069 test_more_like_this_for_model = unittest.expectedFailure(test_more_like_this_for_model) diff --git a/test_haystack/test_generic_views.py b/test_haystack/test_generic_views.py new file mode 100644 index 000000000..609475134 --- /dev/null +++ b/test_haystack/test_generic_views.py @@ -0,0 +1,71 @@ +from django.test.client import RequestFactory +from django.test.testcases import TestCase +from haystack.forms import ModelSearchForm +from haystack.generic_views import SearchView + + +class GenericSearchViewsTestCase(TestCase): + """Test case for the generic search views.""" + + def setUp(self): + super(GenericSearchViewsTestCase, self).setUp() + self.query = 'haystack' + self.request = self.get_request( + url='/some/random/url?q={0}'.format(self.query) + ) + + def test_get_form_kwargs(self): + """Test getting the search view form kwargs.""" + v = SearchView() + v.request = self.request + + form_kwargs = v.get_form_kwargs() + self.assertEqual(form_kwargs.get('data').get('q'), self.query) + self.assertEqual(form_kwargs.get('initial'), {}) + self.assertTrue('searchqueryset' in form_kwargs) + + def test_search_view_response(self): + """Test the generic SearchView response.""" + response = SearchView.as_view()(request=self.request) + + context = response.context_data + self.assertEqual(context['query'], self.query) + self.assertEqual(context.get('view').__class__, SearchView) + self.assertEqual(context.get('form').__class__, ModelSearchForm) + + def test_search_view_form_valid(self): + """Test the generic SearchView form is valid.""" + v = SearchView() + v.kwargs = {} + v.request = self.request + + form = v.get_form(v.get_form_class()) + response = v.form_valid(form) + context = response.context_data + + self.assertEqual(context['query'], self.query) + + def test_search_view_form_invalid(self): + """Test the generic SearchView form is invalid.""" + v = SearchView() + v.kwargs = {} + v.request = self.request + + form = v.get_form(v.get_form_class()) + response = v.form_invalid(form) + context = response.context_data + + self.assertTrue('query' not in context) + + def get_request(self, url, method='get', data=None, **kwargs): + """Gets the request object for the view. + + :param url: a mock url to use for the request + :param method: the http method to use for the request ('get', 'post', + etc). + """ + factory = RequestFactory() + factory_func = getattr(factory, method) + + request = factory_func(url, data=data or {}, **kwargs) + return request diff --git a/test_haystack/test_models.py b/test_haystack/test_models.py index 2d7f6e7f1..509125527 100644 --- a/test_haystack/test_models.py +++ b/test_haystack/test_models.py @@ -143,10 +143,6 @@ def test_missing_object(self): self.assertEqual(awol2.stored, None) self.assertEqual(len(CaptureHandler.logs_seen), 12) - if django.get_version() == '1.7': - # FIXME: https://github.com/toastdriven/django-haystack/issues/1069 - test_missing_object = unittest.expectedFailure(test_missing_object) - def test_read_queryset(self): # The model is flagged deleted so not returned by the default manager. deleted1 = SearchResult('core', 'afifthmockmodel', 2, 2)