Repository: django-haystack/django-haystack

Issue:
allow search views to accept url args and url kwargs
I've heard that there is interest in making haystack search views inherit from django CBV but until that change makes it to the codebase, it would be nice to have url args and kwargs being passed along with `request`

Include py34-django1.7 in .travis.yml 
I see the `py34-django1.7` environment added in `tox.ini`, but I don't see it included in `.travis.yml`.  Any reason tests aren't being run against py34-django1.7?



Discussion:
If using Django’s class-based views would resolve this, let’s see the fate of #826 before moving forward with this feature.



Resolving patch:
diff --git a/haystack/generic_views.py b/haystack/generic_views.py
new file mode 100644
--- /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
--- 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


Test patch:
diff --git a/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py b/test_haystack/elasticsearch_tests/test_elasticsearch_backend.py
--- 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
--- 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
--- /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
--- 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)