├── docs
├── about.rst
├── required_modules
│ └── search_sites.py
├── index.rst
├── api.rst
├── changelog.rst
├── Makefile
└── conf.py
├── haystackbrowser
├── templatetags
│ ├── __init__.py
│ ├── haystackbrowser_compat.py
│ └── haystackbrowser_data.py
├── __init__.py
├── templates
│ └── admin
│ │ └── haystackbrowser
│ │ ├── change_form_with_data.html
│ │ ├── change_form_with_link.html
│ │ ├── result_list_headers.html
│ │ ├── result.html
│ │ ├── view.html
│ │ ├── result_list.html
│ │ └── view_data.html
├── test_app.py
├── tests_compat.py
├── test_config.py
├── test_forms.py
├── test_admin.py
├── utils.py
├── forms.py
├── models.py
└── admin.py
├── tests_search_sites.py
├── .bumpversion.cfg
├── .gitignore
├── MANIFEST.in
├── conftest.py
├── demo_project.py
├── tests_urls.py
├── setup.cfg
├── tox.ini
├── Makefile
├── LICENSE
├── .travis.yml
├── tests_settings.py
├── setup.py
└── README.rst
/docs/about.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../README.rst
2 |
--------------------------------------------------------------------------------
/haystackbrowser/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
--------------------------------------------------------------------------------
/haystackbrowser/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | __version_info__ = '0.6.3'
3 | __version__ = '0.6.3'
4 | version = '0.6.3'
5 |
--------------------------------------------------------------------------------
/docs/required_modules/search_sites.py:
--------------------------------------------------------------------------------
1 | # This file exists only to allow `sphinx.ext.autodoc` to work
2 | #import haystack
3 | #haystack.autodiscover()
4 |
--------------------------------------------------------------------------------
/tests_search_sites.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | from __future__ import unicode_literals
4 | import haystack
5 | haystack.autodiscover()
6 |
--------------------------------------------------------------------------------
/.bumpversion.cfg:
--------------------------------------------------------------------------------
1 | [bumpversion]
2 | current_version = 0.6.3
3 | commit = True
4 | tag = True
5 | tag_name = {new_version}
6 | files = setup.py docs/conf.py haystackbrowser/__init__.py README.rst
7 |
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | docs/_build
3 | haystackbrowser/panels.py
4 | dist
5 | django_haystackbrowser.egg-info
6 | *.pickle
7 | .eggs
8 | db.sqlite3
9 | .tox
10 | htmlcov/
11 | django-haystackbrowser-0.*.*
12 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 | include tox.ini
4 | include Makefile
5 | global-include *.rst *.py *.html
6 | recursive-include docs Makefile
7 | recursive-exclude htmlcov *.html
8 | prune docs/_build
9 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. django-haystackbrowser documentation master file, created by
2 | sphinx-quickstart on Sun Sep 23 17:15:39 2012.
3 |
4 | Documentation index
5 | ===================
6 |
7 | .. toctree::
8 | :maxdepth: 10
9 |
10 | about
11 | api
12 | changelog
13 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/change_form_with_data.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 | {% load i18n %}
3 | {% load haystackbrowser_data %}
4 |
5 | {% block sidebar %}
6 | {% if change and original %}
7 | {% haystackbrowser_for_object original %}
8 | {% endif %}
9 | {{ block.super }}
10 | {% endblock %}
11 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | import django
4 | from django.conf import settings
5 | import os
6 |
7 |
8 | HERE = os.path.realpath(os.path.dirname(__file__))
9 |
10 |
11 | def pytest_configure():
12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests_settings")
13 | if settings.configured and hasattr(django, 'setup'):
14 | django.setup()
15 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/change_form_with_link.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 | {% load i18n %}
3 |
4 | {% block object-tools-items %}
5 | {% if change and original %}
6 |
{% trans "View stored data" %}
7 | {% endif %}
8 | {{ block.super }}
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/demo_project.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | from __future__ import absolute_import
4 | import os
5 | import sys
6 | sys.dont_write_bytecode = True
7 |
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests_settings")
9 |
10 | from django.core.wsgi import get_wsgi_application
11 | application = get_wsgi_application()
12 |
13 | if __name__ == "__main__":
14 | from django.core.management import execute_from_command_line
15 | execute_from_command_line(sys.argv)
16 |
--------------------------------------------------------------------------------
/tests_urls.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | try:
3 | from django.conf.urls import url, include
4 | except ImportError:
5 | from django.conf.urls.defaults import url, include
6 | from django.contrib import admin
7 | from django.core.exceptions import ImproperlyConfigured
8 |
9 | try:
10 | urlpatterns = [
11 | url(r'^admin/', include(admin.site.urls)),
12 | ]
13 | except ImproperlyConfigured: # >= Django 2.0
14 | urlpatterns = [
15 | url(r'^admin/', admin.site.urls),
16 | ]
17 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/result_list_headers.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 | {% trans "Name" %} |
3 | {% trans "App" %} |
4 | {% trans "Model" %} |
5 | {% trans "Primary key" %} |
6 | {% if filtered %}{% trans "Score" %} | {% endif %}
7 | {% trans "Content field" %} |
8 | {% trans "Content" %} |
9 | |
10 |
11 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [pytest]
2 | norecursedirs=.* *.egg .svn _build src bin lib local include
3 | python_files=test_*.py
4 | addopts = --cov haystackbrowser --cov-report term --cov-report html -vvv
5 |
6 | [metadata]
7 | license-file = LICENSE
8 |
9 | [wheel]
10 | universal = 1
11 |
12 | [flake8]
13 | max-line-length = 80
14 |
15 | [check-manifest]
16 | ignore-default-rules = true
17 | ignore =
18 | .travis.yml
19 | .bumpversion.cfg
20 | PKG-INFO
21 | .eggs
22 | .idea
23 | .tox
24 | __pycache__
25 | bin
26 | include
27 | lib
28 | local
29 | share
30 | .Python
31 | *.egg-info
32 | *.egg-info/*
33 | setup.cfg
34 | .hgtags
35 | .hgignore
36 | .gitignore
37 | .bzrignore
38 | *.mo
39 | htmlcov
40 |
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | API documentation
2 | =================
3 |
4 | :mod:`admin` classes
5 | --------------------
6 |
7 | .. automodule:: haystackbrowser.admin
8 | :members:
9 | :show-inheritance:
10 |
11 | :mod:`forms`
12 | ------------
13 |
14 | .. automodule:: haystackbrowser.forms
15 | :members:
16 | :show-inheritance:
17 |
18 | :mod:`models` available
19 | -----------------------
20 |
21 | .. automodule:: haystackbrowser.models
22 | :members:
23 | :show-inheritance:
24 |
25 | :mod:`utils` helpers
26 | --------------------
27 |
28 | .. automodule:: haystackbrowser.utils
29 | :members:
30 | :show-inheritance:
31 |
32 | Template tags
33 | -------------
34 |
35 | .. automodule:: haystackbrowser.templatetags.haystackbrowser_data
36 | :members:
37 | :show-inheritance:
38 |
--------------------------------------------------------------------------------
/haystackbrowser/templatetags/haystackbrowser_compat.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import logging
3 | from django import template
4 | register = template.Library()
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 | try:
9 | from django.template.defaultfilters import truncatechars
10 | except ImportError: # We're on Django < 1.4, fake a simple one ...
11 | logger.info("truncatechars template filter not found, backfilling a "
12 | "vague approximation for Django < 1.4")
13 | from django.utils.encoding import force_unicode
14 | def truncatechars(value, arg):
15 | try:
16 | length = int(arg)
17 | except ValueError: # Invalid literal for int().
18 | return value # Fail silently.
19 | return force_unicode(value)[:length]
20 | register.filter('truncatechars', truncatechars)
21 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | minversion=2.2
3 | envlist = py27-dj{13,14,15,16,17,18,19,110},
4 | py33-dj{15,16,17,18},
5 | py34-dj{16,17,18,19,110},
6 | py35-dj{18,19,110},
7 | py36-dj{110,111,20},
8 |
9 | [testenv]
10 | commands =
11 | python -B -tt -W ignore setup.py test
12 | deps =
13 | dj13: Django>=1.3.1,<1.4
14 | dj14: Django>=1.4,<1.5
15 | dj15: Django>=1.5,<1.6
16 | dj16: Django>=1.6,<1.7
17 | dj17: Django>=1.7,<1.8
18 | dj18: Django>=1.8,<1.9
19 | dj19: Django>=1.9,<1.10
20 | dj110: Django>=1.10,<1.11
21 | dj111: Django>=1.11,<2.0
22 | dj20: Django>=2.0,<2.1
23 | dj13,dj14: django-haystack<2.0
24 | dj13,dj14: Whoosh<2.5
25 | dj15,dj16,dj17: django-haystack==2.4.1
26 | dj18,dj19,dj110: django-haystack==2.6.0
27 | dj111,dj20: django-haystack>=2.8
28 | dj15,dj16,dj17,dj18,dj19,dj110,dj111,dj20: Whoosh>2.5
29 | setenv:
30 | OLD_HAYSTACK=0
31 | LANG='ascii'
32 | ignore_outcome =
33 | py33-dj13: True
34 | py33-dj14: True
35 | py33-dj19: True
36 | py34-dj13: True
37 | py34-dj14: True
38 | py35-dj13: True
39 | py35-dj14: True
40 | py35-dj15: True
41 | py35-dj16: True
42 | py35-dj17: True
43 |
44 | [testenv:py27-dj13]
45 | setenv:
46 | OLD_HAYSTACK=1
47 |
48 | [testenv:py27-dj14]
49 | setenv:
50 | OLD_HAYSTACK=1
51 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/result.html:
--------------------------------------------------------------------------------
1 | {% load i18n highlight haystackbrowser_compat %}
2 | {{ result.verbose_name }} |
3 |
4 | {% if result.get_app_url %}
5 | {{ result.app_label }}
6 | {% else %}
7 | {{ result.app_label }}
8 | {% endif %}
9 | |
10 |
11 | {% if result.get_model_url %}
12 | {{ result.model_name }}
13 | {% else %}
14 | {{ result.model_name }}
15 | {% endif %}
16 | |
17 |
18 | {% if result.get_pk_url %}
19 | {{ result.pk }}
20 | {% else %}
21 | {{ result.pk }}
22 | {% endif %}
23 | |
24 | {% if filtered %}{{ result.score|floatformat:"-3" }} | {% endif %}
25 | {{ result.get_content_field }} |
26 |
27 |
28 | {% if request.GET.q %}
29 | {% highlight result.get_content with request.GET.q html_tag "strong" max_length 500 %}
30 | {% else %}
31 | {{ result.get_content|striptags|safe|truncatechars:500 }}
32 | {% endif %}
33 | |
34 |
35 | {% if result.get_detail_url %}
36 |
37 | {% trans 'View stored data' %}
38 |
39 | {% else %}
40 |
41 | {% endif %}
42 | |
43 |
44 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean-pyc clean-build docs clean
2 |
3 | help:
4 | @echo "clean - remove all build, test, coverage and Python artifacts"
5 | @echo "clean-build - remove build artifacts"
6 | @echo "clean-pyc - remove Python file artifacts"
7 | @echo "clean-test - remove test and coverage artifacts"
8 | @echo "test - run tests quickly with the default Python"
9 | @echo "release - package and upload a release"
10 | @echo "dist - package"
11 | @echo "check - package & run metadata sanity checks"
12 | @echo "run - runserver"
13 | @echo "install - install the package to the active Python's site-packages"
14 |
15 | clean: clean-build clean-pyc clean-test
16 |
17 | clean-build:
18 | rm -fr build/
19 | rm -fr dist/
20 | rm -fr .eggs/
21 | find . -name '*.egg-info' -exec rm -fr {} +
22 | find . -name '*.egg' -exec rm -f {} +
23 |
24 | clean-pyc:
25 | find . -name '*.pyc' -exec rm -f {} +
26 | find . -name '*.pyo' -exec rm -f {} +
27 | find . -name '*~' -exec rm -f {} +
28 | find . -name '__pycache__' -exec rm -fr {} +
29 |
30 | clean-test:
31 | rm -fr .tox/
32 | rm -f .coverage
33 | rm -fr htmlcov/
34 |
35 | test: clean-pyc clean-test
36 | python -B -R -tt -W ignore setup.py test
37 |
38 | release: dist
39 | twine upload dist/*
40 |
41 | dist: test clean
42 | python setup.py sdist bdist_wheel
43 | ls -l dist
44 |
45 | check: dist
46 | check-manifest
47 | pyroma .
48 | restview --long-description
49 |
50 | install: clean
51 | python setup.py install
52 |
53 | run: clean-pyc
54 | python demo_project.py runserver 0.0.0.0:8080
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2012, Keryn Knight
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 | 2. Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23 |
24 | The views and conclusions contained in the software and documentation are those
25 | of the authors and should not be interpreted as representing official policies,
26 | either expressed or implied, of the FreeBSD Project.
27 |
28 |
--------------------------------------------------------------------------------
/haystackbrowser/templatetags/haystackbrowser_data.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from classytags.arguments import Argument
3 | from classytags.core import Options
4 | from classytags.helpers import InclusionTag
5 | from django import template
6 | from haystack.query import SearchQuerySet
7 | from haystackbrowser.models import SearchResultWrapper
8 | from haystackbrowser.utils import get_haystack_settings
9 |
10 | try:
11 | from haystack.constants import DJANGO_CT, DJANGO_ID
12 | except ImportError:
13 | DJANGO_CT = 'django_ct'
14 | DJANGO_ID = 'django_id'
15 |
16 |
17 | register = template.Library()
18 |
19 | class HaystackBrowserForObject(InclusionTag):
20 | """
21 | Render a template which shows the given model object's data in the haystack
22 | search index.
23 | """
24 | template = 'admin/haystackbrowser/view_data.html'
25 | options = Options(
26 | Argument('obj', required=True, resolve=True),
27 | )
28 |
29 | def get_context(self, context, obj):
30 | object_id = obj.pk
31 | content_type_id = '%s.%s' % (obj._meta.app_label, obj._meta.module_name)
32 | query = {DJANGO_ID: object_id, DJANGO_CT: content_type_id}
33 | output_context = {
34 | 'haystack_settings': get_haystack_settings(),
35 | }
36 | try:
37 | result = SearchQuerySet().filter(**query)[:1][0]
38 | result = SearchResultWrapper(obj=result)
39 | output_context.update(original=result)
40 | except IndexError:
41 | pass
42 | return output_context
43 |
44 | register.tag('haystackbrowser_for_object', HaystackBrowserForObject)
45 |
--------------------------------------------------------------------------------
/haystackbrowser/test_app.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | from __future__ import unicode_literals
4 | import pytest
5 |
6 | from django.conf import settings
7 | try:
8 | from django.core.urlresolvers import reverse, resolve
9 | except ImportError: # >= Django 2.0
10 | from django.urls import reverse, resolve
11 | from .admin import Search404
12 |
13 |
14 | skip_old_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is True,
15 | reason="Doesn't apply to Haystack 1.2.x")
16 |
17 | skip_new_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is False,
18 | reason="Doesn't apply to Haystack 2.x")
19 |
20 | @skip_new_haystack
21 | def test_env_setting_old_haystack():
22 | assert settings.OLD_HAYSTACK is True
23 |
24 |
25 | @skip_old_haystack
26 | def test_env_setting_new_haystack():
27 | assert settings.OLD_HAYSTACK is False
28 |
29 |
30 | def test_app_is_mounted_accessing_changelist_but_no_models_loaded(admin_user, rf):
31 | url = reverse('admin:haystackbrowser_haystackresults_changelist')
32 | request = rf.get(url)
33 | request.user = admin_user
34 | match = resolve(url)
35 | with pytest.raises(Search404):
36 | match.func(request, *match.args, **match.kwargs)
37 |
38 |
39 | def test_app_is_mounted_viewing_details_but_no_models_loaded(admin_user, rf):
40 | url = reverse('admin:haystackbrowser_haystackresults_change',
41 | kwargs={'content_type': 1, 'pk': 1})
42 | request = rf.get(url)
43 | request.user = admin_user
44 | match = resolve(url)
45 | with pytest.raises(Search404):
46 | match.func(request, *match.args, **match.kwargs)
47 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: python
3 |
4 | matrix:
5 | include:
6 | - env: TOX_ENV=py27-dj13
7 | python: "2.7"
8 | - env: TOX_ENV=py27-dj14
9 | python: "2.7"
10 | - env: TOX_ENV=py27-dj15
11 | python: "2.7"
12 | - env: TOX_ENV=py27-dj16
13 | python: "2.7"
14 | - env: TOX_ENV=py27-dj17
15 | python: "2.7"
16 | - env: TOX_ENV=py27-dj18
17 | python: "2.7"
18 | - env: TOX_ENV=py27-dj19
19 | python: "2.7"
20 | - env: TOX_ENV=py27-dj110
21 | python: "2.7"
22 | - env: TOX_ENV=py33-dj15
23 | python: "3.3"
24 | - env: TOX_ENV=py33-dj16
25 | python: "3.3"
26 | - env: TOX_ENV=py33-dj17
27 | python: "3.3"
28 | - env: TOX_ENV=py33-dj18
29 | python: "3.3"
30 | - env: TOX_ENV=py34-dj15
31 | python: "3.4"
32 | - env: TOX_ENV=py34-dj16
33 | python: "3.4"
34 | - env: TOX_ENV=py34-dj17
35 | python: "3.4"
36 | - env: TOX_ENV=py34-dj18
37 | python: "3.4"
38 | - env: TOX_ENV=py34-dj19
39 | python: "3.4"
40 | - env: TOX_ENV=py34-dj110
41 | python: "3.4"
42 | - env: TOX_ENV=py35-dj18
43 | python: "3.5"
44 | - env: TOX_ENV=py35-dj19
45 | python: "3.5"
46 | - env: TOX_ENV=py35-dj110
47 | python: "3.5"
48 | - env: TOX_ENV=py36-dj110
49 | python: "3.6"
50 | - env: TOX_ENV=py36-dj111
51 | python: "3.6"
52 | - env: TOX_ENV=py36-dj20
53 | python: "3.6"
54 | allow_failures:
55 | - env: TOX_ENV=py33-dj15
56 | - env: TOX_ENV=py33-dj16
57 | - env: TOX_ENV=py33-dj17
58 | - env: TOX_ENV=py33-dj18
59 |
60 | notifications:
61 | email: false
62 |
63 | install:
64 | - pip install --upgrade pip setuptools tox
65 |
66 | cache:
67 | directories:
68 | - $HOME/.cache/pip
69 |
70 | script:
71 | - tox -e $TOX_ENV
72 |
--------------------------------------------------------------------------------
/haystackbrowser/tests_compat.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import with_statement
3 | from django.conf import settings, UserSettingsHolder
4 | from django.utils.functional import wraps
5 |
6 | class override_settings(object):
7 | """
8 | Acts as either a decorator, or a context manager. If it's a decorator it
9 | takes a function and returns a wrapped function. If it's a contextmanager
10 | it's used with the ``with`` statement. In either event entering/exiting
11 | are called before and after, respectively, the function/block is executed.
12 | """
13 | def __init__(self, **kwargs):
14 | self.options = kwargs
15 | self.wrapped = settings._wrapped
16 |
17 | def __enter__(self):
18 | self.enable()
19 |
20 | def __exit__(self, exc_type, exc_value, traceback):
21 | self.disable()
22 |
23 | def __call__(self, test_func):
24 | from django.test import TransactionTestCase
25 | if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase):
26 | original_pre_setup = test_func._pre_setup
27 | original_post_teardown = test_func._post_teardown
28 | def _pre_setup(innerself):
29 | self.enable()
30 | original_pre_setup(innerself)
31 | def _post_teardown(innerself):
32 | original_post_teardown(innerself)
33 | self.disable()
34 | test_func._pre_setup = _pre_setup
35 | test_func._post_teardown = _post_teardown
36 | return test_func
37 | else:
38 | @wraps(test_func)
39 | def inner(*args, **kwargs):
40 | with self:
41 | return test_func(*args, **kwargs)
42 | return inner
43 |
44 | def enable(self):
45 | override = UserSettingsHolder(settings._wrapped)
46 | for key, new_value in self.options.items():
47 | setattr(override, key, new_value)
48 | settings._wrapped = override
49 |
50 | def disable(self):
51 | settings._wrapped = self.wrapped
52 |
53 |
--------------------------------------------------------------------------------
/haystackbrowser/test_config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 |
6 | import pytest
7 | from django.conf import settings
8 |
9 | try:
10 | from django.utils.encoding import force_text
11 | except ImportError: # Django < 1.4 didn't have force_text because it predates 1.4-1.5 py3k support
12 | from django.utils.encoding import force_unicode as force_text
13 | from haystackbrowser.utils import HaystackConfig
14 |
15 | try:
16 | from django.test import override_settings
17 | except ImportError:
18 | try:
19 | from django.test.utils import override_settings
20 | except ImportError: # Django 1.3.x
21 | from .tests_compat import override_settings
22 |
23 | skip_old_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is True,
24 | reason="Doesn't apply to Haystack 1.2.x")
25 |
26 | skip_new_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is False,
27 | reason="Doesn't apply to Haystack 2.x")
28 |
29 |
30 | @skip_old_haystack
31 | def test_get_valid_filters_version2():
32 | conf = HaystackConfig()
33 | filters = tuple((x, force_text(y)) for x, y in conf.get_valid_filters())
34 | assert filters == (('contains', 'contains'),
35 | ('exact', 'exact'),
36 | ('fuzzy', 'similar to (fuzzy)'),
37 | ('gt', 'greater than'),
38 | ('gte', 'greater than or equal to'),
39 | ('in', 'in'),
40 | ('lt', 'less than'),
41 | ('lte', 'less than or equal to'),
42 | ('range', 'range (inclusive)'),
43 | ('startswith', 'starts with'))
44 |
45 | @skip_new_haystack
46 | def test_get_valid_filters_version2():
47 | conf = HaystackConfig()
48 | filters = tuple((x, force_text(y)) for x, y in conf.get_valid_filters())
49 | assert filters == (('exact', 'exact'),
50 | ('gt', 'greater than'),
51 | ('gte', 'greater than or equal to'),
52 | ('in', 'in'),
53 | ('lt', 'less than'),
54 | ('lte', 'less than or equal to'),
55 | ('range', 'range (inclusive)'),
56 | ('startswith', 'starts with'))
57 |
--------------------------------------------------------------------------------
/tests_settings.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 |
4 |
5 | DEBUG = os.environ.get('DEBUG', 'on') == 'on'
6 | SECRET_KEY = os.environ.get('SECRET_KEY', 'TESTTESTTESTTESTTESTTESTTESTTEST')
7 | ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,testserver').split(',')
8 | BASE_DIR = os.path.abspath(os.path.dirname(os.path.abspath(__file__)))
9 |
10 |
11 | DATABASES = {
12 | 'default': {
13 | 'ENGINE': 'django.db.backends.sqlite3',
14 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
15 | }
16 | }
17 |
18 | INSTALLED_APPS = [
19 | 'django.contrib.sites',
20 | 'django.contrib.contenttypes',
21 | 'django.contrib.staticfiles',
22 | 'django.contrib.auth',
23 | # need sessions for Client.login() to work
24 | 'django.contrib.sessions',
25 | 'django.contrib.admin',
26 | 'haystack',
27 | 'haystackbrowser',
28 | ]
29 |
30 | SKIP_SOUTH_TESTS = True
31 | SOUTH_TESTS_MIGRATE = False
32 |
33 | STATIC_URL = '/__static__/'
34 | MEDIA_URL = '/__media__/'
35 | MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage'
36 | SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
37 | SESSION_COOKIE_HTTPONLY = True
38 |
39 |
40 | ROOT_URLCONF = 'tests_urls'
41 |
42 | # Use a fast hasher to speed up tests.
43 | PASSWORD_HASHERS = (
44 | 'django.contrib.auth.hashers.MD5PasswordHasher',
45 | )
46 |
47 | SITE_ID = 1
48 |
49 | TEMPLATE_CONTEXT_PROCESSORS = (
50 | 'django.contrib.auth.context_processors.auth',
51 | )
52 |
53 | MIDDLEWARE_CLASSES = (
54 | 'django.contrib.sessions.middleware.SessionMiddleware',
55 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
56 | 'django.contrib.messages.middleware.MessageMiddleware',
57 | )
58 |
59 | STATIC_ROOT = os.path.join(BASE_DIR, 'tests_collectstatic')
60 | MEDIA_ROOT = os.path.join(BASE_DIR, 'tests_media')
61 |
62 | TEMPLATE_DIRS = ()
63 | USE_TZ = True
64 |
65 |
66 | OLD_HAYSTACK = bool(int(os.getenv('OLD_HAYSTACK', '0')))
67 |
68 | if OLD_HAYSTACK is True:
69 | HAYSTACK_SITECONF = 'tests_search_sites'
70 | HAYSTACK_SEARCH_ENGINE = 'whoosh'
71 | HAYSTACK_WHOOSH_PATH = os.path.join(BASE_DIR, 'whoosh_lt25_index')
72 | else:
73 | HAYSTACK_CONNECTIONS = {
74 | 'default': {
75 | 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
76 | 'PATH': os.path.join(BASE_DIR, 'whoosh_gt25_index'),
77 | },
78 | 'other': {
79 | 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
80 | 'PATH': os.path.join(BASE_DIR, 'whoosh_gt25_index'),
81 | },
82 | }
83 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/view.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/change_form.html' %}
2 | {% load i18n %}
3 |
4 | {% block extrastyle %}
5 | {{ block.super }}
6 |
30 | {% endblock %}
31 |
32 | {% if not is_popup %}
33 | {% block breadcrumbs %}
34 |
67 | {% endblock %}
68 | {% endif %}
69 |
70 |
71 | {% block content %}
72 | {% block object-tools %}
73 | {% if original %}
74 |
85 | {% endif %}
86 | {% endblock %}
87 | {% include "admin/haystackbrowser/view_data.html" %}
88 | {% endblock %}
89 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | Change history
2 | --------------
3 |
4 | A list of changes which affect the API and related code follows. Documentation
5 | and other miscellaneous changes are not listed. See the git history for a
6 | complete history.
7 |
8 | May 2013
9 | ^^^^^^^^
10 |
11 | * |feature| Supports the **Haystack 2.0 beta** changes, while maintaining
12 | 1.x support.
13 | * |feature| support for faceting (**experimental**)
14 |
15 | * Requires a faceting backend (see `backend capabilities`_) - currently
16 | only Solr and Xapian are whitelisted, and only Solr tested.
17 | * Provides a list of possible fields on which to facet.
18 | * Faceting is done based on selected fields.
19 |
20 | * |feature| the *Stored data view* now makes use of `more like this`_
21 | to display other objects in the index which are similar.
22 | * |feature| If a query is present in the changelist view, discovered
23 | results are fed through the ``highlight`` template tag to display
24 | the appropriate snippet.
25 | * |feature| Stored data view now includes a (translatable) count of the
26 | stored/additional fields on the index.
27 | * |feature| The changelist title now better reflects the view by including
28 | the query, if given.
29 | * |bugfix| *Content field*, *Score* and *Content* headers on the changelist
30 | were previously not available for translation.
31 | * |bugfix| the *Clear filters* action on the changelist view is now only
32 | displayed if the model count in the querystring does not match the
33 | available models. Previously it was always displayed.
34 | * |bugfix| *clear filters* is now a translatable string *clear all filters*
35 |
36 | April 2013
37 | ^^^^^^^^^^
38 |
39 | * |bugfix| Lack of media prevents the page from working under Grappelli.
40 | Thanks to `David Novakovic`_ (`dpnova`_ on `GitHub`_) for the fix.
41 | * |bugfix| Templates weren't getting included when using the setup.py,
42 | probably because I've always been using `setup.py develop`.
43 | Thanks to `David Novakovic`_ (`dpnova`_ on `GitHub`_) for the fix.
44 |
45 | January 2013
46 | ^^^^^^^^^^^^
47 |
48 | * |feature| Added template tag for rendering the data found in the haystack
49 | index for any given object;
50 | * |feature| Added two possible admin templates:
51 |
52 | * ``admin/haystackbrowser/change_form_with_link.html`` which adds a link
53 | (alongside the *history* and *view on site* links) to the corresponding
54 | stored data view for the current object.
55 | * ``admin/haystackbrowser/change_form_with_data.html`` which displays all
56 | the stored data for the current object, on the same screen, beneath the standard
57 | ``ModelAdmin`` submit row.
58 |
59 | * |feature| Exposed the discovered settings via the new function
60 | ``get_haystack_settings`` in the ``utils`` module.
61 | * |bugfix| Removed the template syntax which was previously causing the app
62 | to crash under 1.3.0, but not under 1.3.1, because of `this ticket`_ against
63 | Django.
64 | * |bugfix| Removed the ``{% blocktrans with x=y %}`` syntax, in favour of the
65 | ``{% blocktrans with y as x %}`` style, which allows the app to work under
66 | **Django 1.2**
67 |
68 | November 2012
69 | ^^^^^^^^^^^^^
70 |
71 | * |bugfix| issue which caused the app not to work under Django 1.4+ because an
72 | attribute was removed quietly from the standard AdminSite.
73 | * |bugfix| No more ridiculous pagination in the list view.
74 |
75 | September 2012
76 | ^^^^^^^^^^^^^^
77 |
78 | * Initial hackery to get things into a working state. Considered the first release,
79 | for lack of a better term.
80 |
81 |
82 | .. |bugfix| replace:: **Bug fix:**
83 | .. |feature| replace:: **New/changed:**
84 | .. _this ticket: https://code.djangoproject.com/ticket/15721
85 | .. _David Novakovic: http://blog.dpn.name/
86 | .. _dpnova: https://github.com/dpnova/
87 | .. _GitHub: https://github.com/
88 | .. _backend capabilities: http://django-haystack.readthedocs.org/en/latest/backend_support.html#backend-capabilities
89 | .. _more like this: http://django-haystack.readthedocs.org/en/latest/searchqueryset_api.html#more-like-this
90 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 | import sys
4 | from setuptools import setup, find_packages
5 | from setuptools.command.test import test as TestCommand
6 | if sys.version_info[0] == 2:
7 | # get the Py3K compatible `encoding=` for opening files.
8 | from io import open
9 |
10 |
11 | class PyTest(TestCommand):
12 | def initialize_options(self):
13 | TestCommand.initialize_options(self)
14 | self.pytest_args = []
15 |
16 | def finalize_options(self):
17 | TestCommand.finalize_options(self)
18 | self.test_args = []
19 | self.test_suite = True
20 |
21 | def run_tests(self):
22 | # import here, cause outside the eggs aren't loaded
23 | import pytest
24 | errno = pytest.main(self.pytest_args)
25 | sys.exit(errno)
26 |
27 |
28 | class Tox(TestCommand):
29 | user_options = [('tox-args=', 'a', "Arguments to pass to tox")]
30 | def initialize_options(self):
31 | TestCommand.initialize_options(self)
32 | self.tox_args = None
33 | def finalize_options(self):
34 | TestCommand.finalize_options(self)
35 | self.test_args = []
36 | self.test_suite = True
37 | def run_tests(self):
38 | #import here, cause outside the eggs aren't loaded
39 | import tox
40 | import shlex
41 | args = self.tox_args
42 | if args:
43 | args = shlex.split(self.tox_args)
44 | errno = tox.cmdline(args=args)
45 | sys.exit(errno)
46 |
47 |
48 | def make_readme(root_path):
49 | consider_files = ('README.rst', 'LICENSE', 'CHANGELOG', 'CONTRIBUTORS')
50 | for filename in consider_files:
51 | filepath = os.path.realpath(os.path.join(root_path, filename))
52 | if os.path.isfile(filepath):
53 | with open(filepath, mode='r', encoding="utf-8") as f:
54 | yield f.read()
55 |
56 |
57 | HERE = os.path.abspath(os.path.dirname(__file__))
58 | SHORT_DESC = """A reusable Django application for viewing and debugging all the data that has been pushed into Haystack"""
59 | LONG_DESCRIPTION = "\r\n\r\n----\r\n\r\n".join(make_readme(HERE))
60 |
61 |
62 | TROVE_CLASSIFIERS = [
63 | 'Development Status :: 4 - Beta',
64 | 'Environment :: Web Environment',
65 | 'Framework :: Django',
66 | 'Intended Audience :: Developers',
67 | 'Operating System :: OS Independent',
68 | 'Programming Language :: Python :: 2.6',
69 | 'Programming Language :: Python :: 2.7',
70 | 'Programming Language :: Python :: 3.3',
71 | 'Programming Language :: Python :: 3.4',
72 | 'Programming Language :: Python :: 3.5',
73 | 'Natural Language :: English',
74 | 'Topic :: Internet :: WWW/HTTP :: Site Management',
75 | 'Topic :: Database :: Front-Ends',
76 | 'License :: OSI Approved :: BSD License',
77 | 'Framework :: Django',
78 | 'Framework :: Django :: 1.4',
79 | 'Framework :: Django :: 1.5',
80 | 'Framework :: Django :: 1.6',
81 | 'Framework :: Django :: 1.7',
82 | 'Framework :: Django :: 1.8',
83 | ]
84 |
85 | PACKAGES = find_packages()
86 |
87 | setup(
88 | name='django-haystackbrowser',
89 | version='0.6.3',
90 | description=SHORT_DESC,
91 | author='Keryn Knight',
92 | author_email='python-package@kerynknight.com',
93 | license="BSD License",
94 | keywords="django",
95 | zip_safe=False,
96 | long_description=LONG_DESCRIPTION,
97 | url='https://github.com/kezabelle/django-haystackbrowser/tree/master',
98 | packages=PACKAGES,
99 | install_requires=[
100 | 'django-classy-tags>=0.3.4.1',
101 | # as of now, django-haystack's latest version is 2.5.0, and explicitly
102 | # doesn't support Django 1.10+
103 | # So, we put this last, to ensure that it pegs the maximum version
104 | # where packages with looser requirements may say otherwise.
105 | 'django-haystack>=1.2.0',
106 | ],
107 | tests_require=[
108 | 'pytest==2.9.2',
109 | 'pytest-cov==2.2.1',
110 | 'pytest-django==2.9.1',
111 | 'pytest-mock==1.1',
112 | 'pytest-remove-stale-bytecode==2.1',
113 | 'Whoosh',
114 | ],
115 | cmdclass={'test': PyTest, 'tox': Tox},
116 | classifiers=TROVE_CLASSIFIERS,
117 | platforms=['OS Independent'],
118 | package_data={'': [
119 | 'templates/admin/haystackbrowser/*.html',
120 | ]},
121 | )
122 |
--------------------------------------------------------------------------------
/haystackbrowser/test_forms.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 | import pytest
6 | from django.conf import settings
7 | try:
8 | from django.test import override_settings
9 | except ImportError:
10 | try:
11 | from django.test.utils import override_settings
12 | except ImportError: # Django 1.3.x
13 | from .tests_compat import override_settings
14 | from haystackbrowser.forms import PreSelectedModelSearchForm
15 | try:
16 | from unittest.mock import patch, Mock
17 | except ImportError: # < python 3.3
18 | from mock import patch, Mock
19 |
20 | skip_old_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is True,
21 | reason="Doesn't apply to Haystack 1.2.x")
22 |
23 | skip_new_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is False,
24 | reason="Doesn't apply to Haystack 2.x")
25 |
26 |
27 | Model = Mock(return_value='testmodel')
28 | Meta = Mock(app_label='test', model_name='testing', module_name='anothertest')
29 | Model.attach_mock(Meta, '_meta')
30 |
31 |
32 | @skip_new_haystack
33 | def test_guess_haystack_version1():
34 | form = PreSelectedModelSearchForm(data={})
35 | assert form.version == 1
36 |
37 |
38 | @skip_old_haystack
39 | def test_guess_haystack_version2():
40 | form = PreSelectedModelSearchForm(data={})
41 | assert form.version == 2
42 |
43 |
44 | @skip_new_haystack
45 | def test_should_allow_faceting_version1_ok_backend():
46 | form = PreSelectedModelSearchForm(data={})
47 | with override_settings(HAYSTACK_SEARCH_ENGINE='solr'):
48 | assert form.should_allow_faceting() is True
49 |
50 |
51 | @skip_new_haystack
52 | def test_should_allow_faceting_version1_ok_backend():
53 | form = PreSelectedModelSearchForm(data={})
54 | assert form.should_allow_faceting() is False
55 |
56 |
57 | @skip_old_haystack
58 | def test_should_allow_faceting_version2_ok_backend():
59 | form = PreSelectedModelSearchForm(data={})
60 | NEW_CONFIG = {
61 | 'default': {
62 | 'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine',
63 | 'PATH': 'test',
64 | }
65 | }
66 | with override_settings(HAYSTACK_CONNECTIONS=NEW_CONFIG):
67 | assert form.should_allow_faceting() is True
68 |
69 |
70 | @skip_old_haystack
71 | def test_should_allow_faceting_version2_bad_backend():
72 | form = PreSelectedModelSearchForm(data={})
73 | # whoosh isn't supported for faceting
74 | assert form.should_allow_faceting() is False
75 |
76 |
77 | @skip_old_haystack
78 | @patch('haystack.backends.BaseEngine.get_unified_index')
79 | def test_configure_faceting_version2_has_data(unified_index):
80 | # mock out enough of the backend to get data
81 | indexed_models = Mock(return_value=[Model, Model])
82 | facet_fieldnames = Mock(_facet_fieldnames={'a': 1, 'b':2})
83 | facet_fieldnames.attach_mock(indexed_models, 'get_indexed_models')
84 | unified_index.return_value = facet_fieldnames
85 | form = PreSelectedModelSearchForm(data={})
86 | assert form.configure_faceting() == [('a', 'A'), ('b', 'B')]
87 |
88 |
89 | @skip_old_haystack
90 | def test_configure_faceting_version2_without_data():
91 | form = PreSelectedModelSearchForm(data={})
92 | assert form.configure_faceting() == []
93 |
94 |
95 | @skip_new_haystack
96 | @patch('haystack.sites.SearchSite._field_mapping')
97 | def test_configure_faceting_version1_has_data(field_mapping):
98 | field_mapping.return_value = {'a': {'facet_fieldname': 'A'},
99 | 'b': {'facet_fieldname': 'B'}}
100 | form = PreSelectedModelSearchForm(data={})
101 | assert form.configure_faceting() == [('A', 'A'), ('B', 'B')]
102 |
103 |
104 | @skip_new_haystack
105 | @patch('haystack.sites.SearchSite._field_mapping')
106 | def test_configure_faceting_version1_without_data(field_mapping):
107 | field_mapping.return_value = {}
108 | form = PreSelectedModelSearchForm(data={})
109 | assert form.configure_faceting() == []
110 |
111 |
112 | @skip_new_haystack
113 | def test_has_multiple_connections_version1():
114 | form = PreSelectedModelSearchForm(data={})
115 | assert form.has_multiple_connections() is False
116 |
117 |
118 | @skip_old_haystack
119 | def test_has_multiple_connections_version2():
120 | form = PreSelectedModelSearchForm(data={})
121 | with override_settings(HAYSTACK_CONNECTIONS={'default': 1, 'other': 2}):
122 | assert form.has_multiple_connections() is True
123 |
124 |
125 | @skip_old_haystack
126 | def test_has_multiple_connections_version2_nope():
127 | form = PreSelectedModelSearchForm(data={})
128 | with override_settings(HAYSTACK_CONNECTIONS={'default': 1}):
129 | assert form.has_multiple_connections() is False
130 |
131 |
132 | @skip_old_haystack
133 | def test_get_possible_connections_version2():
134 | form = PreSelectedModelSearchForm(data={})
135 | setting = {
136 | 'default': {
137 | 'TITLE': 'lol',
138 | },
139 | 'other': {},
140 | }
141 | with override_settings(HAYSTACK_CONNECTIONS=setting):
142 | assert sorted(form.get_possible_connections()) == [
143 | ('default', 'lol'),
144 | ('other', 'other'),
145 | ]
146 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/result_list.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_list.html" %}
2 | {% load i18n admin_list %}
3 | {% if not is_popup %}
4 | {% block breadcrumbs %}
5 |
24 | {% endblock %}
25 | {% endif %}
26 |
27 | {% block content %}
28 |
29 | {% if form.errors %}
30 |
31 | {% for error in form.non_field_errors %}
32 | - {{ error}}
33 | {% endfor %}
34 | {% for field in form %}
35 | {% if field.errors %}
36 | {% for error in field.errors %}
37 | - {{ field.label_tag }} - {{ error }}
38 | {% endfor %}
39 | {% endif %}
40 | {% endfor %}
41 |
42 | {% endif %}
43 |
44 | {% block search %}{% endblock search %}
45 | {% if applied_facets %}
46 |
54 | {% endif %}
55 |
56 | {% block filters %}
57 |
58 |
{% trans "Filter" %}
59 |
87 | {% if search_model_count != form.models.field.choices|length or request.GET.q or search_facet_count > 0 %}
88 |
97 | {% endif %}
98 |
99 | {% if facets and form.possible_facets.field.choices|length > 0 %}
100 |
{% trans "Facets & counts" %}
101 | {% for facet_type in facets.get_field_facets %}
102 |
{{ facet_type.grouper.get_display }}
103 |
104 | {% for item in facet_type.list %}
105 | -
106 |
107 | {{ item.value }} ({{ item.count }})
108 |
109 | {% endfor %}
110 |
111 | {% endfor %}
112 | {% endif %}
113 |
114 |
115 |
116 | {% endblock filters %}
117 |
118 | {% block result_list %}
119 | {% if results %}
120 |
121 |
122 |
123 |
124 | {% include "admin/haystackbrowser/result_list_headers.html" with filtered=filtered only %}
125 |
126 |
127 |
128 | {% for result in results %}
129 |
130 | {% include "admin/haystackbrowser/result.html" with result=result request=request filtered=filtered search_form=form only %}
131 |
132 | {% endfor %}
133 |
134 |
135 |
136 | {% endif %}
137 | {% endblock result_list %}
138 | {% block pagination %}{% pagination cl %}{% endblock %}
139 |
140 |
141 | {% endblock content %}
142 |
--------------------------------------------------------------------------------
/haystackbrowser/templates/admin/haystackbrowser/view_data.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
109 |
--------------------------------------------------------------------------------
/haystackbrowser/test_admin.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import absolute_import
3 | from __future__ import print_function
4 | from __future__ import unicode_literals
5 | from __future__ import division
6 | import django
7 | import pytest
8 | from functools import partial
9 | from django.conf import settings
10 | try:
11 | from django.core.urlresolvers import reverse, resolve
12 | except ImportError: # >= Django 2.0
13 | from django.urls import reverse, resolve
14 | from haystack.exceptions import SearchBackendError
15 | from haystackbrowser.admin import Search404
16 | from haystackbrowser.forms import PreSelectedModelSearchForm
17 | from haystackbrowser.models import SearchResultWrapper
18 |
19 | skip_old_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is True,
20 | reason="Doesn't apply to Haystack 1.2.x")
21 |
22 | skip_new_haystack = pytest.mark.skipif(settings.OLD_HAYSTACK is False,
23 | reason="Doesn't apply to Haystack 2.x")
24 |
25 |
26 | @pytest.yield_fixture
27 | def detailview(admin_user, rf):
28 | url = reverse('admin:haystackbrowser_haystackresults_change',
29 | kwargs={'content_type': 1, 'pk': 1})
30 | request = rf.get(url)
31 | request.user = admin_user
32 | match = resolve(url)
33 | yield partial(match.func, request, *match.args, **match.kwargs)
34 |
35 |
36 | def test_views_resolve_correctly():
37 | list_url = reverse('admin:haystackbrowser_haystackresults_changelist')
38 | detail_url = reverse('admin:haystackbrowser_haystackresults_change',
39 | kwargs={'content_type': 1, 'pk': 1})
40 | assert list_url == '/admin/haystackbrowser/haystackresults/'
41 | assert detail_url == '/admin/haystackbrowser/haystackresults/1/1/'
42 | list_view = resolve(list_url)
43 | detail_view = resolve(detail_url)
44 |
45 |
46 |
47 | def test_detailview_has_view_result_but_fails_because_mlt(mocker, detailview):
48 | """
49 | Gets as far as:
50 | raw_mlt = SearchQuerySet().more_like_this(model_instance)[:5]
51 | before raising.
52 | """
53 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = [mocker.Mock()]
54 | with pytest.raises(SearchBackendError):
55 | detailview()
56 |
57 |
58 | def test_detailview_has_view_result_templateresponse(mocker, detailview):
59 | original = mocker.Mock()
60 | mlt = [mocker.Mock(), mocker.Mock()]
61 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = [original]
62 | mocker.patch('haystack.query.SearchQuerySet.more_like_this').return_value = mlt
63 | response = detailview()
64 | from django.template.response import TemplateResponse
65 | assert isinstance(response, TemplateResponse) is True
66 | assert response.status_code == 200
67 | context_keys = set(response.context_data.keys())
68 | assert context_keys.issuperset({
69 | 'app_label',
70 | 'form',
71 | 'form_valid',
72 | 'has_change_permission',
73 | 'haystack_settings',
74 | 'haystack_version',
75 | 'module_name',
76 | 'original',
77 | 'similar_objects',
78 | 'title'
79 | })
80 | if django.VERSION[0:2] >= (1, 7):
81 | assert 'site_header' in context_keys
82 | assert 'site_title' in context_keys
83 | else:
84 | assert 'site_header' not in context_keys
85 | assert 'site_title' not in context_keys
86 | assert response.context_data['form_valid'] is False
87 | assert response.context_data['has_change_permission'] is True
88 | assert len(response.context_data['similar_objects']) == 2
89 | assert isinstance(response.context_data['original'], SearchResultWrapper)
90 | assert isinstance(response.context_data['form'], PreSelectedModelSearchForm)
91 | assert response.context_data['original'].object == original
92 |
93 |
94 |
95 | @skip_new_haystack
96 | def test_detailview_has_view_result_templateresponse_settings_version1(mocker, detailview):
97 | mlt = [mocker.Mock(), mocker.Mock()]
98 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = [mocker.Mock()]
99 | mocker.patch('haystack.query.SearchQuerySet.more_like_this').return_value = mlt
100 | response = detailview()
101 | assert len(response.context_data['haystack_settings']) == 3
102 | values = {x[0] for x in response.context_data['haystack_settings']}
103 | assert values == {'SEARCH ENGINE', 'SITECONF', 'WHOOSH PATH'}
104 |
105 |
106 | @skip_old_haystack
107 | def test_detailview_has_view_result_templateresponse_settings_version2(mocker, detailview):
108 | mlt = [mocker.Mock(), mocker.Mock()]
109 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = [mocker.Mock()]
110 | mocker.patch('haystack.query.SearchQuerySet.more_like_this').return_value = mlt
111 | response = detailview()
112 | assert len(response.context_data['haystack_settings']) == 4
113 | values = {x[0] for x in response.context_data['haystack_settings']}
114 | assert values == {'ENGINE', 'PATH'}
115 |
116 |
117 |
118 | def test_detailview_no_result(mocker, detailview):
119 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = []
120 | with pytest.raises(Search404):
121 | detailview()
122 |
123 |
124 | def test_GH15_detailview_mlt_attributeerror_is_handled(mocker, detailview):
125 | mocker.patch('haystack.query.SearchQuerySet.filter').return_value = [mocker.Mock()]
126 | msg = "MLT failed because the haystack ES1 backend is using the 2.x " \
127 | "version of elasticsearch-py which does not have a .mlt method"
128 | mocker.patch('haystack.query.SearchQuerySet.more_like_this').side_effect = AttributeError(msg)
129 | # Refs #GH-15 - calling .more_like_this(...) should raise an AttributeError
130 | # to emulate the ES1-haystack-backend with ES2.x library situation, but
131 | # it should not be promoted to a userland exception and should instead
132 | # be silenced...
133 | detailview()
134 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
18 |
19 | help:
20 | @echo "Please use \`make ' where is one of"
21 | @echo " html to make standalone HTML files"
22 | @echo " dirhtml to make HTML files named index.html in directories"
23 | @echo " singlehtml to make a single large HTML file"
24 | @echo " pickle to make pickle files"
25 | @echo " json to make JSON files"
26 | @echo " htmlhelp to make HTML files and a HTML help project"
27 | @echo " qthelp to make HTML files and a qthelp project"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
31 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
32 | @echo " text to make text files"
33 | @echo " man to make manual pages"
34 | @echo " texinfo to make Texinfo files"
35 | @echo " info to make Texinfo files and run them through makeinfo"
36 | @echo " gettext to make PO message catalogs"
37 | @echo " changes to make an overview of all changed/added/deprecated items"
38 | @echo " linkcheck to check all external links for integrity"
39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
40 |
41 | clean:
42 | -rm -rf $(BUILDDIR)/*
43 |
44 | html:
45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
48 |
49 | dirhtml:
50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
51 | @echo
52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
53 |
54 | singlehtml:
55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
56 | @echo
57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
58 |
59 | pickle:
60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
61 | @echo
62 | @echo "Build finished; now you can process the pickle files."
63 |
64 | json:
65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
66 | @echo
67 | @echo "Build finished; now you can process the JSON files."
68 |
69 | htmlhelp:
70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
71 | @echo
72 | @echo "Build finished; now you can run HTML Help Workshop with the" \
73 | ".hhp project file in $(BUILDDIR)/htmlhelp."
74 |
75 | qthelp:
76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
77 | @echo
78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-haystackbrowser.qhcp"
81 | @echo "To view the help file:"
82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-haystackbrowser.qhc"
83 |
84 | devhelp:
85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
86 | @echo
87 | @echo "Build finished."
88 | @echo "To view the help file:"
89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-haystackbrowser"
90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-haystackbrowser"
91 | @echo "# devhelp"
92 |
93 | epub:
94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
95 | @echo
96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
97 |
98 | latex:
99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
100 | @echo
101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
103 | "(use \`make latexpdf' here to do that automatically)."
104 |
105 | latexpdf:
106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
107 | @echo "Running LaTeX files through pdflatex..."
108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
110 |
111 | text:
112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
113 | @echo
114 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
115 |
116 | man:
117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
118 | @echo
119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
120 |
121 | texinfo:
122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
123 | @echo
124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
125 | @echo "Run \`make' in that directory to run these through makeinfo" \
126 | "(use \`make info' here to do that automatically)."
127 |
128 | info:
129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
130 | @echo "Running Texinfo files through makeinfo..."
131 | make -C $(BUILDDIR)/texinfo info
132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
133 |
134 | gettext:
135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
136 | @echo
137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
138 |
139 | changes:
140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
141 | @echo
142 | @echo "The overview file is in $(BUILDDIR)/changes."
143 |
144 | linkcheck:
145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
146 | @echo
147 | @echo "Link check complete; look for any errors in the above output " \
148 | "or in $(BUILDDIR)/linkcheck/output.txt."
149 |
150 | doctest:
151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
152 | @echo "Testing of doctests in the sources finished, look at the " \
153 | "results in $(BUILDDIR)/doctest/output.txt."
154 |
--------------------------------------------------------------------------------
/haystackbrowser/utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import re
3 | import sys
4 | PY3 = sys.version_info[0] == 3
5 | if PY3:
6 | string_types = str,
7 | else:
8 | string_types = basestring,
9 | from django.conf import settings
10 | from django.core.management.commands.diffsettings import module_to_dict
11 | import logging
12 | from django.core.exceptions import ImproperlyConfigured
13 | from django.template.defaultfilters import yesno
14 | from haystack.constants import VALID_FILTERS
15 | from django.utils.translation import ugettext_lazy as _
16 | try:
17 | from django.utils.encoding import force_text
18 | except ImportError: # Django < 1.4 didn't have force_text because it predates 1.4-1.5 py3k support
19 | from django.utils.encoding import force_unicode as force_text
20 |
21 |
22 | logger = logging.getLogger(__name__)
23 |
24 |
25 | class HaystackConfig(object):
26 | __slots__ = (
27 | 'version',
28 | )
29 |
30 | def __init__(self):
31 | if self.is_version_2x():
32 | logger.debug("Guessed Haystack 2.x")
33 | self.version = 2
34 | elif self.is_version_1x():
35 | logger.debug("Guessed Haystack 1.2.x")
36 | self.version = 1
37 | else:
38 | self.version = None
39 |
40 |
41 | def __repr__(self):
42 | return '<%(module)s.%(cls)s version=%(version)d, ' \
43 | 'multiple_connections=%(conns)s ' \
44 | 'supports_faceting=%(facets)s>' % {
45 | 'module': self.__class__.__module__,
46 | 'cls': self.__class__.__name__,
47 | 'conns': yesno(self.has_multiple_connections()),
48 | 'facets': yesno(self.supports_faceting()),
49 | 'version': self.version,
50 | }
51 |
52 | def is_version_1x(self):
53 | return getattr(settings, 'HAYSTACK_SEARCH_ENGINE', None) is not None
54 |
55 | def is_version_2x(self):
56 | return getattr(settings, 'HAYSTACK_CONNECTIONS', None) is not None
57 |
58 | def supports_faceting(self):
59 | if self.version == 1:
60 | engine_1x = getattr(settings, 'HAYSTACK_SEARCH_ENGINE', None)
61 | return engine_1x in ('solr', 'xapian')
62 | elif self.version == 2:
63 | engine_2x = getattr(settings, 'HAYSTACK_CONNECTIONS', {})
64 | try:
65 | engine_2xdefault = engine_2x['default']['ENGINE']
66 | ok_engines = (
67 | 'solr' in engine_2xdefault,
68 | 'xapian' in engine_2xdefault,
69 | 'elasticsearch' in engine_2xdefault,
70 | )
71 | return any(ok_engines)
72 | except KeyError as e:
73 | raise ImproperlyConfigured("I think you're on Haystack 2.x without "
74 | "a `HAYSTACK_CONNECTIONS` dictionary")
75 | # I think this is unreachable, but for safety's sake we're going to
76 | # assume that if it got here, we can't know faceting is OK and working
77 | # so we'll disable the feature.
78 | return False
79 |
80 | def get_facets(self, sqs=None):
81 | if self.version == 2:
82 | from haystack import connections
83 | facet_fields = connections['default'].get_unified_index()._facet_fieldnames
84 | return tuple(sorted(facet_fields.keys()))
85 | elif self.version == 1:
86 | assert sqs is not None, "Must provide a SearchQuerySet " \
87 | "to get the site from"
88 | possible_facets = []
89 | for k, v in sqs.site._field_mapping().items():
90 | if v['facet_fieldname'] is not None:
91 | possible_facets.append(v['facet_fieldname'])
92 | return tuple(sorted(possible_facets))
93 | return ()
94 |
95 | def supports_multiple_connections(self):
96 | if self.version == 1:
97 | return False
98 | elif self.version == 2:
99 | return True
100 | return False
101 |
102 | def has_multiple_connections(self):
103 | if self.supports_multiple_connections():
104 | engine_2x = getattr(settings, 'HAYSTACK_CONNECTIONS', {})
105 | return len(engine_2x) > 1
106 | return False
107 |
108 | def get_connections(self):
109 | def consumer():
110 | engine_2x = getattr(settings, 'HAYSTACK_CONNECTIONS', {})
111 | for engine, values in engine_2x.items():
112 | engine_name = force_text(engine)
113 | if 'TITLE' in values:
114 | title = force_text(values['TITLE'])
115 | else:
116 | title = engine_name
117 | yield (engine_name, title)
118 | return tuple(consumer())
119 |
120 | def get_valid_filters(self):
121 | filters = sorted(VALID_FILTERS)
122 | names = {
123 | 'contains': _('contains'),
124 | 'exact': _('exact'),
125 | 'gt': _('greater than'),
126 | 'gte': _('greater than or equal to'),
127 | 'lt': _('less than'),
128 | 'lte': _('less than or equal to'),
129 | 'in': _('in'),
130 | 'startswith': _('starts with'),
131 | 'range': _('range (inclusive)'),
132 | 'fuzzy': _('similar to (fuzzy)')
133 | }
134 | return tuple((filter, names[filter])
135 | for filter in filters
136 | if filter in names)
137 |
138 |
139 | def cleanse_setting_value(setting_value):
140 | """ do not show user:pass in https://user:pass@domain.com settings values """
141 | if not isinstance(setting_value, string_types):
142 | return setting_value
143 | return re.sub(r'//(.*:.*)@', '//********:********@', setting_value)
144 |
145 |
146 | def get_haystack_settings():
147 | """
148 | Find all settings which are prefixed with `HAYSTACK_`
149 | """
150 | filtered_settings = []
151 | connections = getattr(settings, 'HAYSTACK_CONNECTIONS', {})
152 | try:
153 | # 2.x style (one giant dictionary)
154 | connections['default'] #: may be a KeyError, in which case, 1.x style.
155 | for named_backend, values in connections.items():
156 | for setting_name, setting_value in values.items():
157 | setting_name = setting_name.replace('_', ' ')
158 | filtered_settings.append((setting_name, cleanse_setting_value(setting_value), named_backend))
159 | except KeyError as e:
160 | # 1.x style, where everything is a separate setting.
161 | searching_for = u'HAYSTACK_'
162 | all_settings = module_to_dict(settings._wrapped)
163 | for setting_name, setting_value in all_settings.items():
164 | if setting_name.startswith(searching_for):
165 | setting_name = setting_name.replace(searching_for, '').replace('_', ' ')
166 | filtered_settings.append((setting_name, cleanse_setting_value(setting_value)))
167 | return filtered_settings
168 |
--------------------------------------------------------------------------------
/haystackbrowser/forms.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from django.core.exceptions import ValidationError
3 | from django.http import QueryDict
4 | from django.template.defaultfilters import yesno
5 | from django.forms import (MultipleChoiceField, CheckboxSelectMultiple,
6 | ChoiceField, HiddenInput, IntegerField)
7 | from django.utils.translation import ugettext_lazy as _
8 | try:
9 | from django.forms.utils import ErrorDict
10 | except ImportError: # < Django 1.8
11 | from django.forms.util import ErrorDict
12 | from haystack.forms import ModelSearchForm, model_choices
13 | from haystackbrowser.models import AppliedFacets, Facet
14 | from haystackbrowser.utils import HaystackConfig
15 |
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | class SelectedFacetsField(MultipleChoiceField):
21 | def __init__(self, *args, **kwargs):
22 | # takes the fieldname out of an iterable of Facet instances
23 | if 'possible_facets' in kwargs:
24 | self.possible_facets = [x[0] for x in kwargs.pop('possible_facets')]
25 | else:
26 | self.possible_facets = []
27 | super(SelectedFacetsField, self).__init__(*args, **kwargs)
28 |
29 | def valid_value(self, value):
30 | # doesn't contain `a:b` as a minimum
31 | if len(value) < 3:
32 | return False
33 | if ':' not in value:
34 | return False
35 | # shouldn't be `:aa` or `aa:`
36 | if value.startswith(':') or value.endswith(':'):
37 | return False
38 | facet_name, sep, facet_value = value.partition(':')
39 |
40 | return facet_name in self.possible_facets
41 |
42 |
43 | class PreSelectedModelSearchForm(ModelSearchForm):
44 | possible_facets = MultipleChoiceField(widget=CheckboxSelectMultiple,
45 | choices=(), required=False,
46 | label=_("Finding facets on"))
47 | connection = ChoiceField(choices=(), required=False)
48 | p = IntegerField(required=False, label=_("Page"), min_value=0,
49 | max_value=99999999, initial=1)
50 |
51 | def __init__(self, *args, **kwargs):
52 | """
53 | If we're in a recognised faceting engine, display and allow faceting.
54 | """
55 | super(PreSelectedModelSearchForm, self).__init__(*args, **kwargs)
56 | if 'models' in self.fields:
57 | self.fields['models'].initial = [x[0] for x in model_choices()]
58 | self.fields['models'].label = _("Only models")
59 | self.haystack_config = HaystackConfig()
60 |
61 | self.version = self.haystack_config.version
62 | if self.should_allow_faceting():
63 | possible_facets = self.configure_faceting()
64 | self.fields['possible_facets'].choices = possible_facets
65 | self.fields['selected_facets'] = SelectedFacetsField(
66 | choices=(), required=False, possible_facets=possible_facets)
67 |
68 |
69 | if self.has_multiple_connections():
70 | wtf = self.get_possible_connections()
71 | self.fields['connection'].choices = tuple(wtf) # noqa
72 | self.fields['connection'].initial = 'default'
73 | else:
74 | self.fields['connection'].widget = HiddenInput()
75 |
76 | def is_haystack1(self):
77 | return self.haystack_config.is_version_1x()
78 |
79 | def is_haystack2(self):
80 | return self.haystack_config.is_version_2x()
81 |
82 | def guess_haystack_version(self):
83 | return self.haystack_config.version
84 |
85 | def has_multiple_connections(self):
86 | return self.haystack_config.has_multiple_connections()
87 |
88 | def get_possible_connections(self):
89 | return self.haystack_config.get_connections()
90 |
91 | def configure_faceting(self):
92 | possible_facets = self.haystack_config.get_facets(sqs=self.searchqueryset)
93 | return [Facet(x).choices() for x in sorted(possible_facets)]
94 |
95 | def should_allow_faceting(self):
96 | return self.haystack_config.supports_faceting()
97 |
98 | def __repr__(self):
99 | is_valid = self.is_bound and not bool(self._errors)
100 | return '<%(module)s.%(cls)s bound=%(is_bound)s valid=%(valid)s ' \
101 | 'version=%(version)d multiple_connections=%(conns)s ' \
102 | 'supports_faceting=%(facets)s>' % {
103 | 'module': self.__class__.__module__,
104 | 'cls': self.__class__.__name__,
105 | 'is_bound': yesno(self.is_bound),
106 | 'conns': yesno(self.has_multiple_connections()),
107 | 'facets': yesno(self.should_allow_faceting()),
108 | 'valid': yesno(is_valid),
109 | 'version': self.haystack_config.version,
110 | }
111 |
112 | def no_query_found(self):
113 | """
114 | When nothing is entered, show everything, because it's a better
115 | useful default for our usage.
116 | """
117 | return self.searchqueryset.all()
118 |
119 | def search(self):
120 | sqs = self.searchqueryset.all()
121 |
122 | if not self.is_valid():
123 | # When nothing is entered, show everything, because it's a better
124 | # useful default for our usage.
125 | return sqs
126 |
127 | cleaned_data = getattr(self, 'cleaned_data', {})
128 |
129 | connection = cleaned_data.get('connection', ())
130 | if self.has_multiple_connections() and len(connection) == 1:
131 | sqs = sqs.using(*connection)
132 |
133 | if self.should_allow_faceting():
134 | for applied_facet in self.applied_facets():
135 | narrow_query = applied_facet.narrow.format(
136 | cleaned_value=sqs.query.clean(applied_facet.value))
137 | sqs = sqs.narrow(narrow_query)
138 |
139 | to_facet_on = sorted(cleaned_data.get('possible_facets', ()))
140 | if len(to_facet_on) > 0:
141 | for field in to_facet_on:
142 | sqs = sqs.facet(field)
143 |
144 | only_models = self.get_models()
145 | if len(only_models) > 0:
146 | sqs = sqs.models(*only_models)
147 |
148 | query = cleaned_data.get('q', [''])
149 | if query:
150 | sqs = sqs.auto_query(*query)
151 |
152 | if self.load_all:
153 | sqs = sqs.load_all()
154 |
155 | return sqs
156 |
157 | def clean_connection(self):
158 | return [self.cleaned_data.get('connection', 'default').strip()]
159 |
160 | def clean_possible_facets(self):
161 | return list(frozenset(self.cleaned_data.get('possible_facets', ())))
162 |
163 | def clean_selected_facets(self):
164 | return list(frozenset(self.cleaned_data.get('selected_facets', ())))
165 |
166 | def clean_q(self):
167 | return [self.cleaned_data.get('q', '')]
168 |
169 | def clean_p(self):
170 | page = self.cleaned_data.get('p', None)
171 | if page is None:
172 | page = self.fields['p'].min_value
173 | return [page]
174 |
175 | def full_clean(self):
176 | """
177 | Taken from Django master as of 5e06fa1469180909c51c07151692412269e51ea3
178 | but is mostly a copy-paste all the way back to 1.3.1
179 | Basically we want to keep cleaned_data around, not remove it
180 | if errors occured.
181 | """
182 | self._errors = ErrorDict()
183 | if not self.is_bound: # Stop further processing.
184 | return
185 | self.cleaned_data = {}
186 | # If the form is permitted to be empty, and none of the form data has
187 | # changed from the initial data, short circuit any validation.
188 | if self.empty_permitted and not self.has_changed():
189 | return
190 | self._clean_fields()
191 | self._clean_form()
192 | self._post_clean()
193 |
194 | def clean(self):
195 | cd = self.cleaned_data
196 | selected = 'selected_facets'
197 | possible = 'possible_facets'
198 | if selected in cd and len(cd[selected]) > 0:
199 | if possible not in cd or len(cd[possible]) == 0:
200 | raise ValidationError('Unable to provide facet counts without selecting a field to facet on')
201 | return cd
202 |
203 | def applied_facets(self):
204 | cleaned_querydict = self.cleaned_data_querydict
205 | return AppliedFacets(querydict=cleaned_querydict)
206 |
207 | @property
208 | def cleaned_data_querydict(self):
209 | """
210 | Creates an immutable QueryDict instance from the form's cleaned_data
211 | """
212 | query = QueryDict('', mutable=True)
213 | # make sure cleaned_data is available, if possible ...
214 | self.is_valid()
215 | cleaned_data = getattr(self, 'cleaned_data', {})
216 | for key, values in cleaned_data.items():
217 | query.setlist(key=key, list_=values)
218 | query._mutable = False
219 | return query
220 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # django-haystackbrowser documentation build configuration file, created by
4 | # sphinx-quickstart on Sun Sep 23 17:15:39 2012.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 | from django.core.management import setup_environ
16 | from django.conf import global_settings as django_conf
17 | django_conf.HAYSTACK_SITECONF = 'search_sites'
18 | django_conf.HAYSTACK_SEARCH_ENGINE = 'simple'
19 | setup_environ(django_conf)
20 |
21 | # If extensions (or modules to document with autodoc) are in another directory,
22 | # add these directories to sys.path here. If the directory is relative to the
23 | # documentation root, use os.path.abspath to make it absolute, like shown here.
24 | packages = [
25 | 'required_modules',
26 | '..'
27 | ]
28 | here = os.path.dirname(__file__)
29 | for pkg in packages:
30 | new_pkg = os.path.abspath(os.path.join(here, pkg))
31 | sys.path.insert(0, new_pkg)
32 | # from haystackbrowser import version as bundled_version
33 |
34 | # -- General configuration -----------------------------------------------------
35 |
36 | # If your documentation needs a minimal Sphinx version, state it here.
37 | #needs_sphinx = '1.0'
38 |
39 | # Add any Sphinx extension module names here, as strings. They can be extensions
40 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
41 | extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.viewcode']
42 |
43 | # Add any paths that contain templates here, relative to this directory.
44 | templates_path = ['_templates']
45 |
46 | # The suffix of source filenames.
47 | source_suffix = '.rst'
48 |
49 | # The encoding of source files.
50 | #source_encoding = 'utf-8-sig'
51 |
52 | # The master toctree document.
53 | master_doc = 'index'
54 |
55 | # General information about the project.
56 | project = u'django-haystackbrowser'
57 | copyright = u'2013, Keryn Knight'
58 |
59 | # The version info for the project you're documenting, acts as replacement for
60 | # |version| and |release|, also used in various other places throughout the
61 | # built documents.
62 | #
63 | # The short X.Y version.
64 | version = '0.6.3'
65 | # The full version, including alpha/beta/rc tags.
66 | release = '0.6.3'
67 |
68 | # The language for content autogenerated by Sphinx. Refer to documentation
69 | # for a list of supported languages.
70 | #language = None
71 |
72 | # There are two options for replacing |today|: either, you set today to some
73 | # non-false value, then it is used:
74 | #today = ''
75 | # Else, today_fmt is used as the format for a strftime call.
76 | #today_fmt = '%B %d, %Y'
77 |
78 | # List of patterns, relative to source directory, that match files and
79 | # directories to ignore when looking for source files.
80 | exclude_patterns = ['_build']
81 |
82 | # The reST default role (used for this markup: `text`) to use for all documents.
83 | #default_role = None
84 |
85 | # If true, '()' will be appended to :func: etc. cross-reference text.
86 | add_function_parentheses = True
87 |
88 | # If true, the current module name will be prepended to all description
89 | # unit titles (such as .. function::).
90 | add_module_names = True
91 |
92 | # If true, sectionauthor and moduleauthor directives will be shown in the
93 | # output. They are ignored by default.
94 | show_authors = True
95 |
96 | # The name of the Pygments (syntax highlighting) style to use.
97 | pygments_style = 'sphinx'
98 |
99 | # A list of ignored prefixes for module index sorting.
100 | #modindex_common_prefix = []
101 |
102 |
103 | # -- Options for HTML output ---------------------------------------------------
104 |
105 | # The theme to use for HTML and HTML Help pages. See the documentation for
106 | # a list of builtin themes.
107 | html_theme = 'sphinxdoc'
108 |
109 | # Theme options are theme-specific and customize the look and feel of a theme
110 | # further. For a list of options available for each theme, see the
111 | # documentation.
112 | #html_theme_options = {}
113 |
114 | # Add any paths that contain custom themes here, relative to this directory.
115 | #html_theme_path = []
116 |
117 | # The name for this set of Sphinx documents. If None, it defaults to
118 | # " v documentation".
119 | html_title = '%s %s' % (project, release)
120 |
121 | # A shorter title for the navigation bar. Default is the same as html_title.
122 | html_short_title = '%s (version %s)' % (project, release)
123 |
124 | # The name of an image file (relative to this directory) to place at the top
125 | # of the sidebar.
126 | #html_logo = None
127 |
128 | # The name of an image file (within the static path) to use as favicon of the
129 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
130 | # pixels large.
131 | #html_favicon = None
132 |
133 | # Add any paths that contain custom static files (such as style sheets) here,
134 | # relative to this directory. They are copied after the builtin static files,
135 | # so a file named "default.css" will overwrite the builtin "default.css".
136 | html_static_path = ['_static']
137 |
138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
139 | # using the given strftime format.
140 | html_last_updated_fmt = '%b %d, %Y'
141 |
142 | # If true, SmartyPants will be used to convert quotes and dashes to
143 | # typographically correct entities.
144 | #html_use_smartypants = True
145 |
146 | # Custom sidebar templates, maps document names to template names.
147 | #html_sidebars = {}
148 |
149 | # Additional templates that should be rendered to pages, maps page names to
150 | # template names.
151 | #html_additional_pages = {}
152 |
153 | # If false, no module index is generated.
154 | html_domain_indices = False
155 |
156 | # If false, no index is generated.
157 | html_use_index = False
158 |
159 | # If true, the index is split into individual pages for each letter.
160 | #html_split_index = False
161 |
162 | # If true, links to the reST sources are added to the pages.
163 | html_show_sourcelink = True
164 |
165 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
166 | html_show_sphinx = False
167 |
168 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
169 | html_show_copyright = False
170 |
171 | # If true, an OpenSearch description file will be output, and all pages will
172 | # contain a tag referring to it. The value of this option must be the
173 | # base URL from which the finished HTML is served.
174 | #html_use_opensearch = ''
175 |
176 | # This is the file name suffix for HTML files (e.g. ".xhtml").
177 | #html_file_suffix = None
178 |
179 | # Output file base name for HTML help builder.
180 | htmlhelp_basename = 'django-haystackbrowserdoc'
181 |
182 |
183 | # -- Options for LaTeX output --------------------------------------------------
184 |
185 | latex_elements = {
186 | # The paper size ('letterpaper' or 'a4paper').
187 | #'papersize': 'letterpaper',
188 |
189 | # The font size ('10pt', '11pt' or '12pt').
190 | #'pointsize': '10pt',
191 |
192 | # Additional stuff for the LaTeX preamble.
193 | #'preamble': '',
194 | }
195 |
196 | # Grouping the document tree into LaTeX files. List of tuples
197 | # (source start file, target name, title, author, documentclass [howto/manual]).
198 | latex_documents = [
199 | ('index', 'django-haystackbrowser.tex', u'django-haystackbrowser Documentation',
200 | u'Keryn Knight', 'manual'),
201 | ]
202 |
203 | # The name of an image file (relative to this directory) to place at the top of
204 | # the title page.
205 | #latex_logo = None
206 |
207 | # For "manual" documents, if this is true, then toplevel headings are parts,
208 | # not chapters.
209 | #latex_use_parts = False
210 |
211 | # If true, show page references after internal links.
212 | #latex_show_pagerefs = False
213 |
214 | # If true, show URL addresses after external links.
215 | #latex_show_urls = False
216 |
217 | # Documents to append as an appendix to all manuals.
218 | #latex_appendices = []
219 |
220 | # If false, no module index is generated.
221 | #latex_domain_indices = True
222 |
223 |
224 | # -- Options for manual page output --------------------------------------------
225 |
226 | # One entry per manual page. List of tuples
227 | # (source start file, name, description, authors, manual section).
228 | man_pages = [
229 | ('index', 'django-haystackbrowser', u'django-haystackbrowser Documentation',
230 | [u'Keryn Knight'], 1)
231 | ]
232 |
233 | # If true, show URL addresses after external links.
234 | #man_show_urls = False
235 |
236 |
237 | # -- Options for Texinfo output ------------------------------------------------
238 |
239 | # Grouping the document tree into Texinfo files. List of tuples
240 | # (source start file, target name, title, author,
241 | # dir menu entry, description, category)
242 | texinfo_documents = [
243 | ('index', 'django-haystackbrowser', u'django-haystackbrowser Documentation',
244 | u'Keryn Knight', 'django-haystackbrowser', 'One line description of project.',
245 | 'Miscellaneous'),
246 | ]
247 |
248 | # Documents to append as an appendix to all manuals.
249 | #texinfo_appendices = []
250 |
251 | # If false, no module index is generated.
252 | #texinfo_domain_indices = True
253 |
254 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
255 | #texinfo_show_urls = 'footnote'
256 |
257 |
258 | # Example configuration for intersphinx: refer to the Python standard library.
259 | intersphinx_mapping = {
260 | 'python': ('http://docs.python.org/', None),
261 | 'django': ('http://django.readthedocs.org/en/latest/', None),
262 | 'haystack': ('http://django-haystack.readthedocs.org/en/latest/', None),
263 | }
264 |
265 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | .. _Django: https://www.djangoproject.com/
2 | .. _Haystack: http://www.haystacksearch.org/
3 | .. _Django administration: https://docs.djangoproject.com/en/dev/ref/contrib/admin/
4 | .. _GitHub: https://github.com/
5 | .. _PyPI: http://pypi.python.org/pypi
6 | .. _kezabelle/django-haystackbrowser: https://github.com/kezabelle/django-haystackbrowser/
7 | .. _master: https://github.com/kezabelle/django-haystackbrowser/tree/master
8 | .. _issue tracker: https://github.com/kezabelle/django-haystackbrowser/issues/
9 | .. _my Twitter account: https://twitter.com/kezabelle/
10 | .. _FreeBSD: http://en.wikipedia.org/wiki/BSD_licenses#2-clause_license_.28.22Simplified_BSD_License.22_or_.22FreeBSD_License.22.29
11 | .. _Ben Hastings: https://twitter.com/benjhastings/
12 | .. _David Novakovic: http://blog.dpn.name/
13 | .. _Francois Lebel: http://flebel.com/
14 | .. _Jussi Räsänen: http://skyred.fi/
15 | .. _Michaël Krens: https://github.com/michi88/
16 | .. _REPL to inspect the SearchQuerySet: http://django-haystack.readthedocs.org/en/latest/debugging.html#no-results-found-on-the-web-page
17 | .. _ticket 21056: https://code.djangoproject.com/ticket/21056
18 | .. _tagged on GitHub: https://github.com/kezabelle/django-haystackbrowser/tags
19 | .. _my laziness: https://github.com/kezabelle/django-haystackbrowser/issues/6
20 | .. _Anton Shurashov: https://github.com/Sinkler/
21 |
22 | .. title:: About
23 |
24 | django-haystackbrowser
25 | ======================
26 |
27 | :author: Keryn Knight
28 |
29 | .. |travis_stable| image:: https://travis-ci.org/kezabelle/django-haystackbrowser.svg?branch=0.6.3
30 | :target: https://travis-ci.org/kezabelle/django-haystackbrowser/branches
31 |
32 | .. |travis_master| image:: https://travis-ci.org/kezabelle/django-haystackbrowser.svg?branch=master
33 | :target: https://travis-ci.org/kezabelle/django-haystackbrowser/branches
34 |
35 | ============== ======
36 | Release Status
37 | ============== ======
38 | stable (0.6.3) |travis_stable|
39 | master |travis_master|
40 | ============== ======
41 |
42 | .. contents:: Sections
43 | :depth: 2
44 |
45 | In brief
46 | --------
47 |
48 | A plug-and-play `Django`_ application for viewing, browsing and debugging data
49 | discovered in your `Haystack`_ Search Indexes.
50 |
51 |
52 | Why I wrote it
53 | --------------
54 |
55 | I love `Haystack`_ but I'm sometimes not sure what data I have in it. When a
56 | query isn't producing the result I'd expect, debugging it traditionally involves
57 | using the Python `REPL to inspect the SearchQuerySet`_, and while I'm not allergic
58 | to doing that, it can be inconvenient, and doesn't scale well when you need to
59 | make multiple changes.
60 |
61 | This application, a minor abuse of the `Django administration`_, aims to solve that
62 | by providing a familiar interface in which to query and browse the data, in a
63 | developer-friendly way.
64 |
65 | .. _requirements:
66 |
67 | Requirements and dependencies
68 | -----------------------------
69 |
70 | django-haystackbrowser should hopefully run on:
71 |
72 | * **Django 1.3.1** or higher;
73 | * **Haystack 1.2** or higher (including **2.x**)
74 |
75 | It additionally depends on ``django-classy-tags``, though only to use the provided
76 | template tags, which are entirely optional.
77 |
78 | Supported versions
79 | ^^^^^^^^^^^^^^^^^^
80 |
81 | In theory, the below should work, based on a few minimal sanity-checking
82 | tests; if any of them don't, please open a ticket on the `issue tracker`_.
83 |
84 | +--------+-------------------------------------+
85 | | Django | Python |
86 | +--------+-------+-----+-------+-------+-------+
87 | | | 2.7 | 3.3 | 3.4 | 3.5 | 3.6 |
88 | +--------+-------+-----+-------+-------+-------+
89 | | 1.3.x | Yup | | | | |
90 | +--------+-------+-----+-------+-------+-------+
91 | | 1.4.x | Yup | | | | |
92 | +--------+-------+-----+-------+-------+-------+
93 | | 1.5.x | Yup | Yup | | | |
94 | +--------+-------+-----+-------+-------+-------+
95 | | 1.6.x | Yup | Yup | Yup | | |
96 | +--------+-------+-----+-------+-------+-------+
97 | | 1.7.x | Yup | Yup | Yup | | |
98 | +--------+-------+-----+-------+-------+-------+
99 | | 1.8.x | Yup | Yup | Yup | Yup | |
100 | +--------+-------+-----+-------+-------+-------+
101 | | 1.9.x | Yup | | Yup | Yup | |
102 | +--------+-------+-----+-------+-------+-------+
103 | | 1.10.x | Maybe | | Maybe | Yup | Maybe |
104 | +--------+-------+-----+-------+-------+-------+
105 | | 1.11.x | Maybe | | Maybe | Yup | Maybe |
106 | +--------+-------+-----+-------+-------+-------+
107 | | 2.0.x | | | Maybe | Maybe | Yup |
108 | +--------+-------+-----+-------+-------+-------+
109 |
110 | Any instances of **Maybe** are because I haven't personally used it on that,
111 | version, nor have I had anyone report problems with it which would indicate a
112 | lack of support.
113 |
114 | What it does
115 | ------------
116 |
117 | Any staff user with the correct permission (currently, ``request.user.is_superuser``
118 | must be ``True``) has a new application available in the standard admin index.
119 |
120 | There are two views, an overview for browsing and searching, and another for
121 | inspecting the data found for an individual object.
122 |
123 | List view
124 | ^^^^^^^^^
125 |
126 | The default landing page, the list view, shows the following fields:
127 |
128 | * model verbose name;
129 | * the `Django`_ app name, with a link to that admin page;
130 | * the `Django`_ model name, linking to the admin changelist for that model, if
131 | it has been registered via ``admin.site.register``;
132 | * the database primary key for that object, linking to the admin change view for
133 | that specific object, if the app and model are both registered via
134 | ``admin.site.register``;
135 | * The *score* for the current query, as returned by `Haystack`_ - when no
136 | query is given, the default score of **1.0** is used;
137 | * The primary content field for each result;
138 | * The first few words of that primary content field, or a relevant snippet
139 | with highlights, if searching by keywords.
140 |
141 | It also allows you to perform searches against the index, optionally filtering
142 | by specific models or faceted fields. That's functionality `Haystack`_ provides
143 | out of the box, so should be familiar.
144 |
145 | If your `Haystack`_ configuration includes multiple connections, you can pick
146 | and choose which one to use on a per-query basis.
147 |
148 | Stored data view
149 | ^^^^^^^^^^^^^^^^
150 |
151 | From the list view, clicking on ``View stored data`` for any result will bring
152 | up the stored data view, which is the most useful part of it.
153 |
154 | * Shows all ``stored`` fields defined in the SearchIndex, and their values;
155 | * Highlights which of the stored fields is the primary content field
156 | (usually, ``text``);
157 | * Shows all additional fields;
158 | * Strips any HTML tags present in the raw data when displaying, with an
159 | option to display raw data on hover.
160 | * Shows any `Haystack`_ specific settings in the settings module.
161 | * Shows up to **5** similar objects, if the backend supports it.
162 |
163 | The stored data view, like the list view, provides links to the relevant admin
164 | pages for the app/model/instance if appropriate.
165 |
166 | Installation
167 | ------------
168 |
169 | It's taken many years of `my laziness`_ to get around to it, but it is now
170 | possible to get the package from `PyPI`_.
171 |
172 | Using pip
173 | ^^^^^^^^^
174 |
175 | The best way to grab the package is using ``pip`` to grab latest release from
176 | `PyPI`_::
177 |
178 | pip install django-haystackbrowser==0.6.3
179 |
180 | The alternative is to use ``pip`` to install the master branch in ``git``::
181 |
182 | pip install git+https://github.com/kezabelle/django-haystackbrowser.git#egg=django-haystackbrowser
183 |
184 | Any missing dependencies will be resolved by ``pip`` automatically.
185 |
186 | If you want the last release (0.6.3), such as it is, you can do::
187 |
188 | pip install git+https://github.com/kezabelle/django-haystackbrowser.git@0.6.3#egg=django-haystackbrowser
189 |
190 | You can find all previous releases `tagged on GitHub`_
191 |
192 | Using git directly
193 | ^^^^^^^^^^^^^^^^^^
194 |
195 | If you're not using ``pip``, you can get the latest version::
196 |
197 | git clone https://github.com/kezabelle/django-haystackbrowser.git
198 |
199 | and then make sure the ``haystackbrowser`` package is on your python path.
200 |
201 | Usage
202 | -----
203 |
204 | Once it's on your Python path, add it to your settings module::
205 |
206 | INSTALLED_APPS += (
207 | 'haystackbrowser',
208 | )
209 |
210 | It's assumed that both `Haystack`_ and the `Django administration`_ are already in your
211 | ``INSTALLED_APPS``, but if they're not, they need to be, so go ahead and add
212 | them::
213 |
214 | INSTALLED_APPS += (
215 | 'django.contrib.admin',
216 | 'haystack',
217 | 'haystackbrowser',
218 | )
219 |
220 | With the `requirements`_ met and the `installation`_ complete, the only thing that's
221 | left to do is sign in to the AdminSite, and verify the new *Search results* app
222 | works.
223 |
224 | Extending admin changeforms
225 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
226 |
227 | Assuming it works, you can augment your existing ModelAdmins by using
228 | (or copy-pasting from) the templates available:
229 |
230 | * ``admin/haystackbrowser/change_form_with_link.html`` adds a link
231 | (alongside the **history** and **view on site** links) to the corresponding
232 | stored data view for the current object.
233 | * ``admin/haystackbrowser/change_form_with_data.html`` displays all
234 | the stored data for the current object, on the same screen, beneath the standard
235 | ``ModelAdmin`` submit row.
236 |
237 | Both templates play nicely with the standard admin pages, and both ensure
238 | they call their ``{% block %}``'s super context.
239 |
240 | Their simplest usage would be::
241 |
242 | class MyModelAdmin(admin.ModelAdmin):
243 | change_form_template = 'admin/haystackbrowser/change_form_with_data.html'
244 |
245 | Though if you've already changed your template, either via the aforementioned attribute or
246 | via admin template discovery, you can easily take the minor changes from these listed
247 | templates and adapt them for your own needs.
248 |
249 | .. note::
250 | Both the provided templates check that the given context has ``change=True``
251 | and access to the ``original`` object being edited, so nothing will appear on
252 | the add screens.
253 |
254 | Contributing
255 | ------------
256 |
257 | Please do!
258 |
259 | The project is hosted on `GitHub`_ in the `kezabelle/django-haystackbrowser`_
260 | repository. The main/stable branch is `master`_.
261 |
262 | Bug reports and feature requests can be filed on the repository's `issue tracker`_.
263 |
264 | If something can be discussed in 140 character chunks, there's also `my Twitter account`_.
265 |
266 | Contributors
267 | ^^^^^^^^^^^^
268 |
269 | The following people have been of help, in some capacity.
270 |
271 | * `Ben Hastings`_, for testing it under **Django 1.4** and subsequently forcing
272 | me to stop it blowing up uncontrollably.
273 | * `David Novakovic`_, for getting it to at least work under **Grappelli**, and
274 | fixing an omission in the setup script.
275 | * `Francois Lebel`_, for various fixes.
276 | * `Jussi Räsänen`_, for various fixes.
277 | * Vadim Markovtsev, for minor fix related to Django 1.8+.
278 | * `Michaël Krens`_, for various fixes.
279 | * `Anton Shurashov`_, for fixes related to Django 2.0.
280 |
281 | TODO
282 | ----
283 |
284 | * Ensure the new faceting features work as intended (the test database I
285 | have doesn't *really* cover enough, yet)
286 |
287 | Known issues
288 | ------------
289 |
290 | * Prior to `Django`_ 1.7, the links to the app admin may not actually work,
291 | because the linked app may not be mounted onto the AdminSite, but passing
292 | pretty much anything to the AdminSite app_list urlpattern will result in
293 | a valid URL. The other URLs should only ever work if they're mounted, though.
294 | See `ticket 21056`_ for the change.
295 |
296 | The license
297 | -----------
298 |
299 | It's `FreeBSD`_. There's a ``LICENSE`` file in the root of the repository, and
300 | any downloads.
301 |
--------------------------------------------------------------------------------
/haystackbrowser/models.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import logging
3 |
4 | try:
5 | from urllib import quote_plus
6 | except ImportError: # > Python 3
7 | from django.utils.six.moves.urllib import parse
8 | quote_plus = parse.quote_plus
9 | from operator import itemgetter
10 | from itertools import groupby
11 | from collections import namedtuple
12 | from django.db import models
13 | try:
14 | from django.utils.encoding import force_text
15 | except ImportError: # < Django 1.5
16 | from django.utils.encoding import force_unicode as force_text
17 | from django.utils.safestring import mark_safe
18 | from django.utils.html import strip_tags
19 | try:
20 | from django.core.urlresolvers import NoReverseMatch, reverse
21 | except ImportError: # >= Django 2.0
22 | from django.urls import reverse, NoReverseMatch
23 | from django.utils.translation import ugettext_lazy as _
24 |
25 |
26 | logger = logging.getLogger(__name__)
27 |
28 |
29 | class HaystackResults(models.Model):
30 | """ Our fake model, used for mounting :py:class:`~haystackbrowser.admin.HaystackResultsAdmin`
31 | onto the appropriate AdminSite.
32 |
33 | .. note::
34 |
35 | the model is marked as unmanaged, so will never get created via ``syncdb``.
36 | """
37 | class Meta:
38 | managed = False
39 | verbose_name = _('Search result')
40 | verbose_name_plural = _('Search results')
41 |
42 |
43 | class SearchResultWrapper(object):
44 | """Value object which consumes a standard Haystack SearchResult, and the current
45 | admin site, and exposes additional methods and attributes for displaying the data
46 | appropriately.
47 |
48 | :param obj: the item to be wrapped.
49 | :type obj: object
50 | :param admin_site: the parent site instance.
51 | :type admin_site: AdminSite object
52 |
53 | """
54 | def __init__(self, obj, admin_site=None):
55 | self.admin = admin_site
56 | self.object = obj
57 | if getattr(self.object, 'searchindex', None) is None:
58 | # < Haystack 1.2
59 | from haystack import site
60 | self.object.searchindex = site.get_index(self.object.model)
61 |
62 |
63 | def __repr__(self):
64 | return '<%(module)s.%(cls)s [%(app)s.%(model)s pk=%(pk)r]>' % {
65 | 'module': self.__class__.__module__,
66 | 'cls': self.__class__.__name__,
67 | 'obj': self.object,
68 | 'app': self.object.app_label,
69 | 'model': self.object.model_name,
70 | 'pk': self.object.pk,
71 | }
72 |
73 | def get_app_url(self):
74 | """Resolves a given object's app into a link to the app administration.
75 |
76 | .. warning::
77 | This link may return a 404, as pretty much anything may
78 | be reversed and fit into the ``app_list`` urlconf.
79 |
80 | :return: string or None
81 | """
82 | try:
83 | return reverse('%s:app_list' % self.admin, kwargs={
84 | 'app_label': self.object.app_label,
85 | })
86 | except NoReverseMatch:
87 | return None
88 |
89 | def get_model_url(self):
90 | """Generates a link to the changelist for a specific Model in the administration.
91 |
92 | :return: string or None
93 | """
94 | try:
95 | parts = (self.admin, self.object.app_label, self.object.model_name)
96 | return reverse('%s:%s_%s_changelist' % parts)
97 | except NoReverseMatch:
98 | return None
99 |
100 | def get_pk_url(self):
101 | """Generates a link to the edit page for a specific object in the administration.
102 |
103 | :return: string or None
104 | """
105 | try:
106 | parts = (self.admin, self.object.app_label, self.object.model_name)
107 | return reverse('%s:%s_%s_change' % parts, args=(self.object.pk,))
108 | except NoReverseMatch:
109 | return None
110 |
111 | def get_detail_url(self):
112 | try:
113 | urlname = '%s:haystackbrowser_haystackresults_change' % self.admin
114 | return reverse(urlname, kwargs={
115 | 'content_type': '.'.join([self.object.app_label,
116 | self.object.model_name]),
117 | 'pk': self.object.pk})
118 | except NoReverseMatch:
119 | return None
120 |
121 | def get_model_attrs(self):
122 | outfields = {}
123 | try:
124 | fields = self.object.searchindex.fields
125 | except:
126 | fields = {}
127 | else:
128 | for key, field in fields.items():
129 | has_model_attr = getattr(field, 'model_attr', None)
130 | if has_model_attr is not None:
131 | outfields[key] = force_text(has_model_attr)
132 | return outfields
133 |
134 | def get_stored_fields(self):
135 | stored_fields = {}
136 | model_attrs = self.get_model_attrs()
137 | for key, value in self.object.get_stored_fields().items():
138 | safe_value = force_text(value).strip()
139 | stored_fields[key] = {
140 | 'raw': safe_value,
141 | 'safe': mark_safe(strip_tags(safe_value))
142 | }
143 | if key in model_attrs:
144 | stored_fields[key].update(model_attr=model_attrs.get(key))
145 | return stored_fields
146 |
147 | def get_additional_fields(self):
148 | """Find all fields in the Haystack SearchResult which have not already
149 | appeared in the stored fields.
150 |
151 | :return: dictionary of field names and values.
152 | """
153 | additional_fields = {}
154 | stored_fields = self.get_stored_fields().keys()
155 | model_attrs = self.get_model_attrs()
156 | for key, value in self.object.get_additional_fields().items():
157 | if key not in stored_fields:
158 | safe_value = force_text(value).strip()
159 | additional_fields[key] = {
160 | 'raw': safe_value,
161 | 'safe': mark_safe(strip_tags(safe_value))
162 | }
163 | if key in model_attrs:
164 | additional_fields[key].update(model_attr=model_attrs.get(key))
165 | return additional_fields
166 |
167 | def get_content_field(self):
168 | """Find the name of the main content field in the Haystack SearchIndex
169 | for this object.
170 |
171 | :return: string representing the attribute name.
172 | """
173 | return self.object.searchindex.get_content_field()
174 |
175 | def get_content(self):
176 | """Given the name of the main content field in the Haystack Search Index
177 | for this object, get the named attribute on this object.
178 |
179 | :return: whatever is in ``self.object.``
180 | """
181 | return getattr(self.object, self.get_content_field())
182 |
183 | def get_stored_field_count(self):
184 | """
185 | Provides mechanism for finding the number of stored fields stored on
186 | this Search Result.
187 |
188 | :return: the count of all stored fields.
189 | :rtype: integer
190 | """
191 | return len(self.object.get_stored_fields().keys())
192 |
193 | def get_additional_field_count(self):
194 | """
195 | Provides mechanism for finding the number of stored fields stored on
196 | this Search Result.
197 |
198 | :return: the count of all stored fields.
199 | :rtype: integer
200 | """
201 | return len(self.get_additional_fields().keys())
202 |
203 | def __getattr__(self, attr):
204 | return getattr(self.object, attr)
205 |
206 | def app_label(self):
207 | try:
208 | return self.object.model._meta.app_config.verbose_name
209 | except AttributeError as e:
210 | return self.object.app_label
211 |
212 |
213 | class FacetWrapper(object):
214 | """
215 | A simple wrapper around `sqs.facet_counts()` to filter out things with
216 | 0, and re-arrange the data in such a way that the template can handle it.
217 | """
218 | __slots__ = ('dates', 'fields', 'queries', '_total_count', '_querydict')
219 |
220 | def __init__(self, facet_counts, querydict):
221 | self.dates = facet_counts.get('dates', {})
222 | self.fields = facet_counts.get('fields', {})
223 | self.queries = facet_counts.get('queries', {})
224 |
225 | self._total_count = len(self.dates) + len(self.fields) + len(self.queries)
226 | # querydict comes from the cleaned form data ...
227 | page_key = 'p'
228 | if querydict is not None and page_key in querydict:
229 | querydict.pop(page_key)
230 | self._querydict = querydict
231 |
232 | def __repr__(self):
233 | return '<%(module)s.%(cls)s fields=%(fields)r dates=%(dates)r ' \
234 | 'queries=%(queries)r>' % {
235 | 'module': self.__class__.__module__,
236 | 'cls': self.__class__.__name__,
237 | 'fields': self.fields,
238 | 'dates': self.dates,
239 | 'queries': self.queries,
240 | }
241 |
242 | def get_facets_from(self, x):
243 | if x not in ('dates', 'queries', 'fields'):
244 | raise AttributeError('Wrong field, silly.')
245 |
246 | for field, items in getattr(self, x).items():
247 | for content, count in items:
248 | content = content.strip()
249 | if count > 0 and content:
250 | yield {'field': field, 'value': content, 'count': count,
251 | 'fieldvalue': quote_plus('%s:%s' % (field, content)),
252 | 'facet': Facet(field, querydict=self._querydict)}
253 |
254 | def get_grouped_facets_from(self, x):
255 | data = sorted(self.get_facets_from(x), key=itemgetter('field'))
256 | #return data
257 | results = ({'grouper': Facet(key), 'list': list(val)}
258 | for key, val in groupby(data, key=itemgetter('field')))
259 | return results
260 |
261 | def get_field_facets(self):
262 | return self.get_grouped_facets_from('fields')
263 |
264 | def get_date_facets(self):
265 | return self.get_grouped_facets_from('dates')
266 |
267 | def get_query_facets(self):
268 | return self.get_grouped_facets_from('queries')
269 |
270 | def __bool__(self):
271 | """
272 | Used for doing `if facets: print(facets)` - this is the Python 2 magic
273 | method; __nonzero__ is the equivalent thing in Python 3
274 | """
275 | return self._total_count > 0
276 | __nonzero__ = __bool__
277 |
278 | def __len__(self):
279 | """
280 | For checking things via `if len(facets) > 0: print(facets)`
281 | """
282 | return self._total_count
283 |
284 |
285 | class AppliedFacet(namedtuple('AppliedFacet', 'field value querydict')):
286 | __slots__ = ()
287 | def title(self):
288 | return self.value
289 |
290 | @property
291 | def facet(self):
292 | """ a richer object """
293 | return Facet(self.raw)
294 |
295 | @property
296 | def raw(self):
297 | """ the original data, rejoined """
298 | return '%s:%s' % (self.field, self.value)
299 |
300 | @property
301 | def narrow(self):
302 | """ returns a string format value """
303 | return '{0}:"{{cleaned_value}}"'.format(self.field)
304 |
305 | def link(self):
306 | """ link to just this facet """
307 | new_qd = self.querydict.copy()
308 | page_key = 'p'
309 | if page_key in new_qd:
310 | new_qd.pop(page_key)
311 | new_qd['selected_facets'] = self.raw
312 | new_qd['possible_facets'] = self.field
313 | return '?%s' % new_qd.urlencode()
314 |
315 | def remove_link(self):
316 | new_qd = self.querydict.copy()
317 | # remove page forcibly ...
318 | page_key = 'p'
319 | if page_key in new_qd:
320 | new_qd.pop(page_key)
321 | # remove self from the existing querydict/querystring ...
322 | key = 'selected_facets'
323 | if key in new_qd and self.raw in new_qd.getlist(key):
324 | new_qd.getlist(key).remove(self.raw)
325 | return '?%s' % new_qd.urlencode()
326 |
327 |
328 | class AppliedFacets(object):
329 | __slots__ = ('_applied',)
330 |
331 | def __init__(self, querydict):
332 | self._applied = {}
333 | selected = ()
334 | if 'selected_facets' in querydict:
335 | selected = querydict.getlist('selected_facets')
336 | for raw_facet in selected:
337 | if ":" not in raw_facet:
338 | continue
339 | field, value = raw_facet.split(":", 1)
340 | to_add = AppliedFacet(field=field, value=value,
341 | querydict=querydict)
342 | self._applied[raw_facet] = to_add
343 |
344 | def __iter__(self):
345 | return iter(self._applied.values())
346 |
347 | def __len__(self):
348 | return len(self._applied)
349 |
350 | def __contains__(self, item):
351 | return item in self._applied
352 |
353 | def __repr__(self):
354 | raw = tuple(v.raw for k, v in self._applied.items())
355 | return '<{cls!s}.{name!s} selected_facets={raw}>'.format(
356 | cls=self.__class__.__module__, name=self.__class__.__name__,
357 | raw=raw)
358 |
359 | def __str__(self):
360 | raw = [v.facet.get_display() for k, v in self._applied.items()]
361 | return '{name!s} {raw!s}'.format(name=self.__class__.__name__, raw=raw)
362 |
363 |
364 | class Facet(object):
365 | """
366 | Takes a facet field name, like `thing_exact`
367 | """
368 |
369 | __slots__ = ('fieldname', '_querydict')
370 | def __init__(self, fieldname, querydict=None):
371 | self.fieldname = fieldname
372 | self._querydict = querydict
373 |
374 | def __repr__(self):
375 | return '<%(module)s.%(cls)s - %(field)s>' % {
376 | 'module': self.__class__.__module__,
377 | 'cls': self.__class__.__name__,
378 | 'field': self.fieldname,
379 | }
380 |
381 | def link(self):
382 | qd = self._querydict
383 | if qd is not None:
384 | return '?%s' % qd.urlencode()
385 | return '?'
386 |
387 | def get_display(self):
388 | return self.fieldname.replace('_', ' ').title()
389 |
390 | def choices(self):
391 | return (self.fieldname, self.get_display())
392 |
--------------------------------------------------------------------------------
/haystackbrowser/admin.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from inspect import getargspec
3 | from django.core.exceptions import PermissionDenied
4 | from django.core.paginator import Paginator, InvalidPage
5 | from haystack.exceptions import SearchBackendError
6 | try:
7 | from django.utils.encoding import force_text
8 | except ImportError: # < Django 1.5
9 | from django.utils.encoding import force_unicode as force_text
10 | from django.utils.translation import ugettext_lazy as _
11 | from django.http import Http404, HttpResponseRedirect
12 | try:
13 | from functools import update_wrapper
14 | except ImportError: # < Django 1.6
15 | from django.utils.functional import update_wrapper
16 | try:
17 | from django.template.response import TemplateResponse
18 | UPGRADED_RENDER = True
19 | except ImportError: # Some old Django, which gets worse renderers
20 | from django.shortcuts import render_to_response
21 | UPGRADED_RENDER = False
22 | from django.template import RequestContext
23 | from django.contrib import admin
24 | from django.contrib.admin.views.main import PAGE_VAR, SEARCH_VAR
25 | from django.contrib.admin.options import ModelAdmin
26 | from django.conf import settings
27 | from haystack import __version__
28 | from haystack.query import SearchQuerySet
29 | from haystack.forms import model_choices
30 | from haystackbrowser.models import HaystackResults, SearchResultWrapper, FacetWrapper
31 | from haystackbrowser.forms import PreSelectedModelSearchForm
32 | from haystackbrowser.utils import get_haystack_settings
33 | from django.forms import Media
34 | try:
35 | from haystack.constants import DJANGO_CT, DJANGO_ID
36 | except ImportError: # really old haystack, early in 1.2 series?
37 | DJANGO_CT = 'django_ct'
38 | DJANGO_ID = 'django_id'
39 |
40 | _haystack_version = '.'.join([str(x) for x in __version__])
41 | logger = logging.getLogger(__name__)
42 |
43 | def get_query_string(query_params, new_params=None, remove=None):
44 | # TODO: make this bettererer. Use propery dicty stuff on the Querydict?
45 | if new_params is None:
46 | new_params = {}
47 | if remove is None:
48 | remove = []
49 | params = query_params.copy()
50 | for r in remove:
51 | for k in list(params):
52 | if k == r:
53 | del params[k]
54 | for k, v in new_params.items():
55 | if v is None:
56 | if k in params:
57 | del params[k]
58 | else:
59 | params[k] = v
60 | return '?%s' % params.urlencode()
61 |
62 |
63 | class FakeChangeListForPaginator(object):
64 | """A value object to contain attributes required for Django's pagination template tag."""
65 | def __init__(self, request, page, per_page, model_opts):
66 | self.paginator = page.paginator
67 | self.page_num = page.number - 1
68 | self.can_show_all = False
69 | self.show_all = False
70 | self.result_count = self.paginator.count
71 | self.multi_page = self.result_count > per_page
72 | self.request = request
73 | self.opts = model_opts
74 |
75 | def get_query_string(self, a_dict):
76 | """ Method to return a querystring appropriate for pagination."""
77 | return get_query_string(self.request.GET, a_dict)
78 |
79 | def __repr__(self):
80 | return '<%(module)s.%(cls)s page=%(page)d total=%(count)d>' % {
81 | 'module': self.__class__.__module__,
82 | 'cls': self.__class__.__name__,
83 | 'page': self.page_num,
84 | 'count': self.result_count,
85 | }
86 |
87 |
88 | class Search404(Http404):
89 | pass
90 |
91 |
92 | class HaystackResultsAdmin(object):
93 | """Object which emulates enough of the standard Django ModelAdmin that it may
94 | be mounted into an AdminSite instance and pass validation.
95 | Used to work around the fact that we don't actually have a concrete Django Model.
96 |
97 | :param model: the model being mounted for this object.
98 | :type model: class
99 | :param admin_site: the parent site instance.
100 | :type admin_site: AdminSite object
101 | """
102 | fields = None
103 | fieldsets = None
104 | exclude = None
105 | date_hierarchy = None
106 | ordering = None
107 | list_select_related = False
108 | save_as = False
109 | save_on_top = False
110 |
111 | def __init__(self, model, admin_site):
112 | self.model = model
113 | self.opts = model._meta
114 | self.admin_site = admin_site
115 |
116 | @classmethod
117 | def validate(cls, *args, **kwargs):
118 | return
119 |
120 | @staticmethod
121 | def check(*args, **kwargs):
122 | """ it's not a real modeladmin, so we need this attribute in DEBUG. """
123 | return ()
124 |
125 | def get_model_perms(self, request):
126 | return {
127 | 'add': self.has_add_permission(request),
128 | 'change': self.has_change_permission(request),
129 | 'delete': self.has_delete_permission(request)
130 | }
131 |
132 | def has_module_permission(self, request):
133 | return any(self.get_model_perms(request=request).values())
134 |
135 | def has_add_permission(self, request):
136 | """Emulates the equivalent Django ModelAdmin method.
137 | :param request: the current request.
138 | :type request: WSGIRequest
139 |
140 | :return: `False`
141 | """
142 | return False
143 |
144 | def has_change_permission(self, request, obj=None):
145 | """Emulates the equivalent Django ModelAdmin method.
146 |
147 | :param request: the current request.
148 | :param obj: the object is being viewed.
149 | :type request: WSGIRequest
150 | :type obj: None
151 |
152 | :return: The value of `request.user.is_superuser`
153 | """
154 | return request.user.is_superuser
155 |
156 | def has_delete_permission(self, request, obj=None):
157 | """Emulates the equivalent Django ModelAdmin method.
158 |
159 | :param request: the current request.
160 | :param obj: the object is being viewed.
161 | :type request: WSGIRequest
162 | :type obj: None
163 |
164 | :return: `False`
165 | """
166 | return False
167 |
168 | def urls(self):
169 | """Sets up the required urlconf for the admin views."""
170 | try:
171 | # > 1.5
172 | from django.conf.urls import url
173 | def patterns(prefix, *args):
174 | return list(args) # must be a list, not a tuple, because Django.
175 | except ImportError as e:
176 | # < 1.5
177 | from django.conf.urls.defaults import patterns, url
178 |
179 | def wrap(view):
180 | def wrapper(*args, **kwargs):
181 | return self.admin_site.admin_view(view)(*args, **kwargs)
182 | return update_wrapper(wrapper, view)
183 |
184 | if hasattr(self.model._meta, 'model_name'):
185 | model_key = self.model._meta.model_name
186 | else:
187 | model_key = self.model._meta.module_name
188 |
189 | return patterns('',
190 | url(regex=r'^(?P.+)/(?P.+)/$',
191 | view=wrap(self.view),
192 | name='%s_%s_change' % (self.model._meta.app_label,
193 | model_key)
194 | ),
195 | url(regex=r'^$',
196 | view=wrap(self.index),
197 | name='%s_%s_changelist' % (self.model._meta.app_label,
198 | model_key)
199 | ),
200 | )
201 | urls = property(urls)
202 |
203 | def get_results_per_page(self, request):
204 | """Allows for overriding the number of results shown.
205 | This differs from the usual way a ModelAdmin may declare pagination
206 | via ``list_per_page`` and instead looks in Django's ``LazySettings`` object
207 | for the item ``HAYSTACK_SEARCH_RESULTS_PER_PAGE``. If it's not found,
208 | falls back to **20**.
209 |
210 | :param request: the current request.
211 | :type request: WSGIRequest
212 |
213 | :return: The number of results to show, per page.
214 | """
215 | return getattr(settings, 'HAYSTACK_SEARCH_RESULTS_PER_PAGE',
216 | ModelAdmin.list_per_page)
217 |
218 | def get_paginator_var(self, request):
219 | """Provides the name of the variable used in query strings to discover
220 | what page is being requested. Uses the same ``PAGE_VAR`` as the standard
221 | :py:class:`django.contrib.admin.views.main.ChangeList `
222 |
223 | :param request: the current request.
224 | :type request: WSGIRequest
225 |
226 | :return: the name of the variable used in query strings for pagination.
227 | """
228 | return PAGE_VAR
229 |
230 | def get_search_var(self, request):
231 | """Provides the name of the variable used in query strings to discover
232 | what text search has been requested. Uses the same ``SEARCH_VAR`` as the standard
233 | :py:class:`django.contrib.admin.views.main.ChangeList `
234 |
235 | :param request: the current request.
236 | :type request: WSGIRequest
237 |
238 | :return: the name of the variable used in query strings for text searching.
239 | """
240 | return SEARCH_VAR
241 |
242 | def get_searchresult_wrapper(self):
243 | """This method serves as a hook for potentially overriding which class
244 | is used for wrapping each result into a value object for display.
245 |
246 | :return: class for wrapping search results. Defaults to :py:class:`~haystackbrowser.models.SearchResultWrapper`
247 | """
248 | return SearchResultWrapper
249 |
250 | def get_wrapped_search_results(self, object_list):
251 | """Wraps each :py:class:`~haystack.models.SearchResult` from the
252 | :py:class:`~haystack.query.SearchQuerySet` in our own value object, whose
253 | responsibility is providing additional attributes required for display.
254 |
255 | :param object_list: :py:class:`~haystack.models.SearchResult` objects.
256 |
257 | :return: list of items wrapped with whatever :py:meth:`~haystackbrowser.admin.HaystackResultsAdmin.get_searchresult_wrapper` provides.
258 | """
259 | klass = self.get_searchresult_wrapper()
260 | return tuple(klass(x, self.admin_site.name) for x in object_list)
261 |
262 | def get_current_query_string(self, request, add=None, remove=None):
263 | """ Method to return a querystring with modified parameters.
264 |
265 | :param request: the current request.
266 | :type request: WSGIRequest
267 | :param add: items to be added.
268 | :type add: dictionary
269 | :param remove: items to be removed.
270 | :type remove: dictionary
271 |
272 | :return: the new querystring.
273 | """
274 | return get_query_string(request.GET, new_params=add, remove=remove)
275 |
276 | def get_settings(self):
277 | """Find all Django settings prefixed with ``HAYSTACK_``
278 |
279 | :return: dictionary whose keys are setting names (tidied up).
280 | """
281 | return get_haystack_settings()
282 |
283 |
284 |
285 | def do_render(self, request, template_name, context):
286 | if UPGRADED_RENDER:
287 | return TemplateResponse(request=request, template=template_name,
288 | context=context)
289 | else:
290 | return render_to_response(template_name=template_name, context=context,
291 | context_instance=RequestContext(request))
292 |
293 | def each_context_compat(self, request):
294 | # Django didn't always have an AdminSite.each_context method.
295 | if not hasattr(self.admin_site, 'each_context'):
296 | return {}
297 | method_sig = getargspec(self.admin_site.each_context)
298 | # Django didn't always pass along request.
299 | if 'request' in method_sig.args:
300 | return self.admin_site.each_context(request)
301 | return self.admin_site.each_context()
302 |
303 | def index(self, request):
304 | """The view for showing all the results in the Haystack index. Emulates
305 | the standard Django ChangeList mostly.
306 |
307 | :param request: the current request.
308 | :type request: WSGIRequest
309 |
310 | :return: A template rendered into an HttpReponse
311 | """
312 | if not self.has_change_permission(request, None):
313 | raise PermissionDenied("Not a superuser")
314 |
315 | page_var = self.get_paginator_var(request)
316 | form = PreSelectedModelSearchForm(request.GET or None, load_all=False)
317 | minimum_page = form.fields[page_var].min_value
318 | # Make sure there are some models indexed
319 | available_models = model_choices()
320 | if len(available_models) <= 0:
321 | raise Search404('No search indexes bound via Haystack')
322 |
323 | # We've not selected any models, so we're going to redirect and select
324 | # all of them. This will bite me in the ass if someone searches for a string
325 | # but no models, but I don't know WTF they'd expect to return, anyway.
326 | # Note that I'm only doing this to sidestep this issue:
327 | # https://gist.github.com/3766607
328 | if 'models' not in request.GET.keys():
329 | # TODO: make this betterererer.
330 | new_qs = ['&models=%s' % x[0] for x in available_models]
331 | # if we're in haystack2, we probably want to provide the 'default'
332 | # connection so that it behaves as if "initial" were in place.
333 | if form.has_multiple_connections():
334 | new_qs.append('&connection=' + form.fields['connection'].initial)
335 | new_qs = ''.join(new_qs)
336 | existing_query = request.GET.copy()
337 | if page_var in existing_query:
338 | existing_query.pop(page_var)
339 | existing_query[page_var] = minimum_page
340 | location = '%(path)s?%(existing_qs)s%(new_qs)s' % {
341 | 'existing_qs': existing_query.urlencode(),
342 | 'new_qs': new_qs,
343 | 'path': request.path_info,
344 | }
345 | return HttpResponseRedirect(location)
346 |
347 | sqs = form.search()
348 | cleaned_GET = form.cleaned_data_querydict
349 | try:
350 | page_no = int(cleaned_GET.get(PAGE_VAR, minimum_page))
351 | except ValueError:
352 | page_no = minimum_page
353 | results_per_page = self.get_results_per_page(request)
354 | paginator = Paginator(sqs, results_per_page)
355 | try:
356 | page = paginator.page(page_no+1)
357 | except (InvalidPage, ValueError):
358 | # paginator.page may raise InvalidPage if we've gone too far
359 | # meanwhile, casting the querystring parameter may raise ValueError
360 | # if it's None, or '', or other silly input.
361 | raise Search404("Invalid page")
362 |
363 | query = request.GET.get(self.get_search_var(request), None)
364 | connection = request.GET.get('connection', None)
365 | title = self.model._meta.verbose_name_plural
366 |
367 | wrapped_facets = FacetWrapper(
368 | sqs.facet_counts(), querydict=form.cleaned_data_querydict.copy())
369 |
370 | context = {
371 | 'results': self.get_wrapped_search_results(page.object_list),
372 | 'pagination_required': page.has_other_pages(),
373 | # this may be expanded into xrange(*page_range) to copy what
374 | # the paginator would yield. This prevents 50000+ pages making
375 | # the page slow to render because of django-debug-toolbar.
376 | 'page_range': (1, paginator.num_pages + 1),
377 | 'page_num': page.number,
378 | 'result_count': paginator.count,
379 | 'opts': self.model._meta,
380 | 'title': force_text(title),
381 | 'root_path': getattr(self.admin_site, 'root_path', None),
382 | 'app_label': self.model._meta.app_label,
383 | 'filtered': True,
384 | 'form': form,
385 | 'form_valid': form.is_valid(),
386 | 'query_string': self.get_current_query_string(request, remove=[page_var]),
387 | 'search_model_count': len(cleaned_GET.getlist('models')),
388 | 'search_facet_count': len(cleaned_GET.getlist('possible_facets')),
389 | 'search_var': self.get_search_var(request),
390 | 'page_var': page_var,
391 | 'facets': wrapped_facets,
392 | 'applied_facets': form.applied_facets(),
393 | 'module_name': force_text(self.model._meta.verbose_name_plural),
394 | 'cl': FakeChangeListForPaginator(request, page, results_per_page, self.model._meta),
395 | 'haystack_version': _haystack_version,
396 | # Note: the empty Media object isn't specficially required for the
397 | # standard Django admin, but is apparently a pre-requisite for
398 | # things like Grappelli.
399 | # See #1 (https://github.com/kezabelle/django-haystackbrowser/pull/1)
400 | 'media': Media()
401 | }
402 | # Update the context with variables that should be available to every page
403 | context.update(self.each_context_compat(request))
404 | return self.do_render(request=request,
405 | template_name='admin/haystackbrowser/result_list.html',
406 | context=context)
407 |
408 | def view(self, request, content_type, pk):
409 | """The view for showing the results of a single item in the Haystack index.
410 |
411 | :param request: the current request.
412 | :type request: WSGIRequest
413 | :param content_type: ``app_label`` and ``model_name`` as stored in Haystack, separated by "."
414 | :type content_type: string.
415 | :param pk: the object identifier stored in Haystack
416 | :type pk: string.
417 |
418 | :return: A template rendered into an HttpReponse
419 | """
420 | if not self.has_change_permission(request, None):
421 | raise PermissionDenied("Not a superuser")
422 |
423 | query = {DJANGO_ID: pk, DJANGO_CT: content_type}
424 | try:
425 | raw_sqs = SearchQuerySet().filter(**query)[:1]
426 | wrapped_sqs = self.get_wrapped_search_results(raw_sqs)
427 | sqs = wrapped_sqs[0]
428 | except IndexError:
429 | raise Search404("Search result using query {q!r} does not exist".format(
430 | q=query))
431 | except SearchBackendError as e:
432 | raise Search404("{exc!r} while trying query {q!r}".format(
433 | q=query, exc=e))
434 |
435 | more_like_this = ()
436 | # the model may no longer be in the database, instead being only backed
437 | # by the search backend.
438 | model_instance = sqs.object.object
439 | if model_instance is not None:
440 | # Refs #GH-15 - elasticsearch-py 2.x does not implement a .mlt
441 | # method, but currently there's nothing in haystack-proper which
442 | # prevents using the 2.x series with the haystack-es1 backend.
443 | # At some point haystack will have a separate es backend ...
444 | # and I have no idea if/how I'm going to support that.
445 | try:
446 | raw_mlt = SearchQuerySet().more_like_this(model_instance)[:5]
447 | except AttributeError as e:
448 | logger.debug("Support for 'more like this' functionality was "
449 | "not found, possibly because you're using "
450 | "the elasticsearch-py 2.x series with haystack's "
451 | "ES1.x backend", exc_info=1, extra={'request': request})
452 | raw_mlt = ()
453 | more_like_this = self.get_wrapped_search_results(raw_mlt)
454 |
455 | form = PreSelectedModelSearchForm(request.GET or None, load_all=False)
456 | form_valid = form.is_valid()
457 |
458 | context = {
459 | 'original': sqs,
460 | 'title': _('View stored data for this %s') % force_text(sqs.verbose_name),
461 | 'app_label': self.model._meta.app_label,
462 | 'module_name': force_text(self.model._meta.verbose_name_plural),
463 | 'haystack_settings': self.get_settings(),
464 | 'has_change_permission': self.has_change_permission(request, sqs),
465 | 'similar_objects': more_like_this,
466 | 'haystack_version': _haystack_version,
467 | 'form': form,
468 | 'form_valid': form_valid,
469 | }
470 | # Update the context with variables that should be available to every page
471 | context.update(self.each_context_compat(request))
472 | return self.do_render(request=request,
473 | template_name='admin/haystackbrowser/view.html',
474 | context=context)
475 | admin.site.register(HaystackResults, HaystackResultsAdmin)
476 |
--------------------------------------------------------------------------------