├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── conftest.py ├── demo_project.py ├── docs ├── Makefile ├── about.rst ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst └── required_modules │ └── search_sites.py ├── haystackbrowser ├── __init__.py ├── admin.py ├── forms.py ├── models.py ├── templates │ └── admin │ │ └── haystackbrowser │ │ ├── change_form_with_data.html │ │ ├── change_form_with_link.html │ │ ├── result.html │ │ ├── result_list.html │ │ ├── result_list_headers.html │ │ ├── view.html │ │ └── view_data.html ├── templatetags │ ├── __init__.py │ ├── haystackbrowser_compat.py │ └── haystackbrowser_data.py ├── test_admin.py ├── test_app.py ├── test_config.py ├── test_forms.py ├── tests_compat.py └── utils.py ├── setup.cfg ├── setup.py ├── tests_search_sites.py ├── tests_settings.py ├── tests_urls.py └── tox.ini /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /haystackbrowser/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version_info__ = '0.6.3' 3 | __version__ = '0.6.3' 4 | version = '0.6.3' 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |
    47 |
    53 |
    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 |
    89 | 90 | 91 | {% trans "clear all filters" %} 92 | 93 | 94 |
    95 |
    96 |
    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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /haystackbrowser/templates/admin/haystackbrowser/view_data.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    3 |
    4 |
    5 |

    6 | {% blocktrans count original.get_stored_field_count as field_count %} 7 | {{ field_count }} stored field 8 | {% plural %} 9 | {{ field_count }} stored fields 10 | {% endblocktrans %} 11 |

    12 |
    13 | Data returned from the Search Result's stored_fields.
    14 | See here for an explanation of what 'Stored' means 15 |
    16 | {% for key, value in original.get_stored_fields.items %} 17 |
    18 | 19 | {% if value.raw != value.safe %} 20 |

    {{ value.raw }}  

    21 |

    {{ value.safe }}  

    22 |

    {% trans "This field has been sanitised for display; hover to view the raw data" %}

    23 | {% else %} 24 |

    {{ value.safe }}  

    25 | {% endif %} 26 | {% if key == original.get_content_field %} 27 |

    {% trans "This field supplies the primary document to be indexed" %}

    28 | {% endif %} 29 |
    30 | {% empty %} 31 |
    32 |

    {% trans "No stored fields found" %}

    33 |
    34 | {% endfor %} 35 |
    36 |
    37 |

    38 | {% blocktrans count original.get_additional_field_count as field_count %} 39 | {{ field_count }} additional field 40 | {% plural %} 41 | {{ field_count }} additional fields 42 | {% endblocktrans %} 43 |

    44 |
    45 | Data returned from the Search Result's additional_fields. 46 |
    47 | {% for key, value in original.get_additional_fields.items %} 48 |
    49 | 50 | {% if value.raw != value.safe %} 51 |

    {{ value.raw }}  

    52 |

    {{ value.safe }}  

    53 |

    {% trans "This field has been sanitised for display; hover to view the raw data" %}

    54 | {% else %} 55 |

    {{ value.safe }}  

    56 | {% endif %} 57 |
    58 | {% empty %} 59 |
    60 |

    {% trans "No additional fields found" %}

    61 |
    62 | {% endfor %} 63 |
    64 |
    65 |

    66 | {% blocktrans with haystack_version as version %} 67 | Haystack v{{ version }} settings 68 | {% endblocktrans %} 69 |

    70 |
    71 | {% trans "All relevant settings found in the Django settings module." %} 72 |
    73 | {% for setting in haystack_settings %} 74 |
    75 | 76 |

    {{ setting.1 }}

    77 | {% if setting.2 %}

    {{ setting.2 }} backend

    {% endif %} 78 |
    79 | {% empty %} 80 |
    81 |

    {% trans "No settings found. Which is probably a bad sign." %}

    82 |
    83 | {% endfor %} 84 |
    85 | 86 | {% if similar_objects %} 87 |
    88 |

    {% trans "More like this" %}

    89 |
    90 | 91 | 92 | 93 | {% include "admin/haystackbrowser/result_list_headers.html" %} 94 | 95 | 96 | 97 | {% for result in similar_objects %} 98 | 99 | {% include "admin/haystackbrowser/result.html" %} 100 | 101 | {% endfor %} 102 | 103 |
    104 |
    105 |
    106 | {% endif %} 107 |
    108 |
    109 | -------------------------------------------------------------------------------- /haystackbrowser/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------