├── .dockerignore ├── docs ├── _static │ └── .gitkeep ├── apidoc │ ├── modules.rst │ └── drf_haystack.rst ├── rtd_requirements.txt ├── 08_permissions.rst ├── 02_autocomplete.rst ├── 06_term_boost.rst ├── 05_more_like_this.rst ├── 03_geospatial.rst ├── 10_tips_n_tricks.rst ├── 04_highlighting.rst ├── 09_multiple_indexes.rst ├── conf.py ├── 01_intro.rst ├── Makefile ├── make.bat ├── index.rst └── 07_faceting.rst ├── tests ├── logs │ └── .gitkeep ├── mockapp │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_mockperson_birthdate.py │ │ ├── 0002_mockperson.py │ │ ├── 0003_mockpet.py │ │ ├── 0001_initial.py │ │ ├── 0006_mockallfield.py │ │ └── 0004_load_fixtures.py │ ├── admin.py │ ├── templates │ │ └── search │ │ │ └── indexes │ │ │ └── mockapp │ │ │ ├── mockpet_text.txt │ │ │ ├── mockperson_text.txt │ │ │ └── mocklocation_text.txt │ ├── views.py │ ├── serializers.py │ ├── fixtures │ │ ├── mockpet.json │ │ └── mockallfield.json │ ├── models.py │ └── search_indexes.py ├── apps.py ├── wsgi.py ├── mixins.py ├── run_tests.py ├── urls.py ├── constants.py ├── __init__.py ├── test_utils.py ├── settings.py └── test_viewsets.py ├── docker-compose.yml ├── MANIFEST.in ├── ruff.toml ├── .github └── workflows │ └── prek.yml ├── setup.cfg ├── manage.py ├── drf_haystack ├── constants.py ├── viewsets.py ├── utils.py ├── __init__.py ├── fields.py ├── generics.py ├── mixins.py ├── filters.py ├── query.py └── serializers.py ├── Pipfile ├── Dockerfile ├── .pre-commit-config.yaml ├── tox.ini ├── .gitignore ├── LICENSE.txt ├── setup.py ├── README.md └── ez_setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mockapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mockapp/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /tests/mockapp/templates/search/indexes/mockapp/mockpet_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.name }} 2 | -------------------------------------------------------------------------------- /docs/apidoc/modules.rst: -------------------------------------------------------------------------------- 1 | API Docs 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | drf_haystack 8 | -------------------------------------------------------------------------------- /tests/mockapp/templates/search/indexes/mockapp/mockperson_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.firstname }} {{ object.lastname }} 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | elasticsearch2: 4 | image: elasticsearch:2 5 | ports: 6 | - "9200:9200" 7 | - "9300:9300" 8 | -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MockappConfig(AppConfig): 5 | name = "mockapp" 6 | verbose_name = "Mock Application" 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | include Pipfile 4 | include tox.ini 5 | recursive-include docs Makefile *.rst *.py *.bat 6 | recursive-include tests *.py *.json *.txt 7 | -------------------------------------------------------------------------------- /tests/mockapp/templates/search/indexes/mockapp/mocklocation_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.address }} {{ object.zip_code }} {{ object.city }} 2 | Created: {{ object.created }} 3 | Last modified: {{ object.updated }} 4 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | 3 | target-version = "py38" 4 | 5 | [format] 6 | preview = true 7 | 8 | [lint] 9 | preview = true 10 | extend-select = [ 11 | # https://docs.astral.sh/ruff/rules/#isort-i 12 | "I", 13 | ] 14 | -------------------------------------------------------------------------------- /.github/workflows/prek.yml: -------------------------------------------------------------------------------- 1 | name: Check pre-commit hooks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | prek: 7 | runs-on: ubuntu-24.04 8 | steps: 9 | - uses: actions/checkout@v5.0.0 10 | - uses: j178/prek-action@v1.0.11 11 | -------------------------------------------------------------------------------- /docs/rtd_requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | django>=3.1,<=4.2 3 | django-haystack>=2.8,<=3.2 4 | djangorestframework>=3.7.0,<=3.13 5 | elasticsearch>=2.0.0,<=8.3.3 6 | sphinx 7 | sphinx-rtd-theme 8 | urllib3 9 | geopy 10 | tox 11 | python-dateutil 12 | wheel 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | license_file=LICENSE.txt 6 | 7 | [pep8] 8 | max-line-length=120 9 | exclude=docs 10 | 11 | [flake8] 12 | max-line-length=120 13 | exclude=docs 14 | 15 | [frosted] 16 | max-line-length=120 17 | exclude=docs 18 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /drf_haystack/constants.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | DRF_HAYSTACK_NEGATION_KEYWORD = getattr(settings, "DRF_HAYSTACK_NEGATION_KEYWORD", "not") 4 | GEO_SRID = getattr(settings, "GEO_SRID", 4326) 5 | DRF_HAYSTACK_SPATIAL_QUERY_PARAM = getattr(settings, "DRF_HAYSTACK_SPATIAL_QUERY_PARAM", "from") 6 | -------------------------------------------------------------------------------- /tests/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for tests project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/mixins.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | class WarningTestCaseMixin: 5 | """ 6 | TestCase mixin to catch warnings 7 | """ 8 | 9 | def assertWarning(self, warning, callable, *args, **kwargs): 10 | with warnings.catch_warnings(record=True) as warning_list: 11 | warnings.simplefilter(action="always") 12 | callable(*args, **kwargs) 13 | self.assertTrue(any(item.category == warning for item in warning_list)) 14 | -------------------------------------------------------------------------------- /tests/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | import os 5 | import sys 6 | 7 | import django 8 | from django.core.management import call_command 9 | 10 | 11 | def start(argv=None): 12 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 13 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 14 | django.setup() 15 | 16 | call_command("test", sys.argv[1:]) 17 | 18 | 19 | if __name__ == "__main__": 20 | start(sys.argv) 21 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = ">=3.1,<5.2" 8 | django-haystack = ">=2.8,<3.4" 9 | djangorestframework = ">=3.12.0,<3.16" 10 | python-dateutil = "*" 11 | 12 | [dev-packages] 13 | coverage = "*" 14 | sphinx = "*" 15 | sphinx-rtd-theme = "*" 16 | "urllib3" = "*" 17 | geopy = "*" 18 | tox = "*" 19 | wheel = "*" 20 | elasticsearch = ">=2.0.0,<=8.3.3" 21 | 22 | [requires] 23 | python_version = "3" 24 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework import routers 3 | 4 | from tests.mockapp.views import SearchPersonFacetViewSet, SearchPersonMLTViewSet 5 | 6 | router = routers.DefaultRouter() 7 | router.register("search-person-facet", viewset=SearchPersonFacetViewSet, basename="search-person-facet") 8 | router.register("search-person-mlt", viewset=SearchPersonMLTViewSet, basename="search-person-mlt") 9 | 10 | urlpatterns = [path(r"^", include(router.urls))] 11 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from django.conf import settings 5 | 6 | with open(os.path.join(settings.BASE_DIR, "mockapp", "fixtures", "mocklocation.json")) as f: 7 | mocklocation_size = len(json.loads(f.read())) 8 | 9 | MOCKLOCATION_DATA_SET_SIZE = mocklocation_size 10 | 11 | with open(os.path.join(settings.BASE_DIR, "mockapp", "fixtures", "mockperson.json")) as f: 12 | mockperson_size = len(json.loads(f.read())) 13 | 14 | MOCKPERSON_DATA_SET_SIZE = mockperson_size 15 | -------------------------------------------------------------------------------- /drf_haystack/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin 2 | from rest_framework.viewsets import ViewSetMixin 3 | 4 | from drf_haystack.generics import HaystackGenericAPIView 5 | 6 | 7 | class HaystackViewSet(RetrieveModelMixin, ListModelMixin, ViewSetMixin, HaystackGenericAPIView): 8 | """ 9 | The HaystackViewSet class provides the default ``list()`` and 10 | ``retrieve()`` actions with a haystack index as it's data source. 11 | """ 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0005_mockperson_birthdate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.4 on 2016-04-16 07:05 2 | 3 | from django.db import migrations, models 4 | 5 | import tests.mockapp.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("mockapp", "0002_mockperson"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="mockperson", 16 | name="birthdate", 17 | field=models.DateField(default=tests.mockapp.models.get_random_date, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | ENV DEBIAN_FRONTEND noninteractive 4 | ENV PYTHONPATH /usr/local/src 5 | 6 | RUN apk add --no-cache --update \ 7 | --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing \ 8 | binutils build-base python3-dev gdal geos \ 9 | && rm -rf /var/cache/apk/* 10 | 11 | COPY . /usr/local/src 12 | WORKDIR /usr/local/src 13 | RUN pip install -U pip setuptools \ 14 | && pip install -r requirements.txt 15 | 16 | VOLUME /usr/local/src 17 | CMD ["sh"] 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-merge-conflict 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | 11 | - repo: https://github.com/asottile/pyupgrade 12 | rev: v3.21.2 13 | hooks: 14 | - id: pyupgrade 15 | args: ["--py38-plus"] 16 | 17 | - repo: https://github.com/adamchainz/django-upgrade 18 | rev: 1.29.1 19 | hooks: 20 | - id: django-upgrade 21 | args: ["--target-version=3.1"] 22 | 23 | - repo: https://github.com/astral-sh/ruff-pre-commit 24 | rev: v0.14.9 25 | hooks: 26 | - id: ruff-check 27 | args: [ --fix ] 28 | - id: ruff-format 29 | -------------------------------------------------------------------------------- /drf_haystack/utils.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | 4 | def merge_dict(a, b): 5 | """ 6 | Recursively merges and returns dict a with dict b. 7 | Any list values will be combined and returned sorted. 8 | 9 | :param a: dictionary object 10 | :param b: dictionary object 11 | :return: merged dictionary object 12 | """ 13 | 14 | if not isinstance(b, dict): 15 | return b 16 | 17 | result = deepcopy(a) 18 | for key, val in b.items(): 19 | if key in result and isinstance(result[key], dict): 20 | result[key] = merge_dict(result[key], val) 21 | elif key in result and isinstance(result[key], list): 22 | result[key] = sorted(list(set(val) | set(result[key]))) 23 | else: 24 | result[key] = deepcopy(val) 25 | 26 | return result 27 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0002_mockperson.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("mockapp", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.CreateModel( 11 | name="MockPerson", 12 | fields=[ 13 | ("id", models.AutoField(auto_created=True, verbose_name="ID", primary_key=True, serialize=False)), 14 | ("firstname", models.CharField(max_length=20)), 15 | ("lastname", models.CharField(max_length=20)), 16 | ("created", models.DateTimeField(auto_now_add=True)), 17 | ("updated", models.DateTimeField(auto_now=True)), 18 | ], 19 | options={}, 20 | bases=(models.Model,), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0003_mockpet.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("mockapp", "0001_initial"), 7 | ("mockapp", "0002_mockperson"), 8 | ] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="MockPet", 13 | fields=[ 14 | ("id", models.AutoField(auto_created=True, verbose_name="ID", primary_key=True, serialize=False)), 15 | ("name", models.CharField(max_length=20)), 16 | ("species", models.CharField(max_length=20)), 17 | ("created", models.DateTimeField(auto_now_add=True)), 18 | ("updated", models.DateTimeField(auto_now=True)), 19 | ], 20 | options={}, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | docs 4 | py{38,39,310,py}-django{3.1,3.2,4.0,4.1,4.2}-es{1.x,2.x} 5 | 6 | 7 | [testenv] 8 | commands = 9 | coverage run {toxinidir}/tests/run_tests.py 10 | deps = 11 | python-dateutil 12 | geopy==2.0.0 13 | coverage 14 | requests 15 | django3.1: Django>=3.1,<3.2 16 | django3.2: Django>=3.2,<3.3 17 | django4.0: Django>=4.0,<4.1 18 | django4.1: Django>=4.1,<4.2 19 | django4.2: Django>=4.2,<4.3 20 | es1.x: elasticsearch>=1,<2 21 | es2.x: elasticsearch>=2,<3 22 | # es5.x: elasticsearch>=5,<6 23 | # es7.x: elasticsearch>=7,<8 24 | setenv = 25 | es1.x: VERSION_ES=>=1,<2 26 | es2.x: VERSION_ES=>=2,<3 27 | # es5.x: VERSION_ES=>=5,<6 28 | # es7.x: VERSION_ES=>=7,<8 29 | 30 | 31 | [testenv:docs] 32 | changedir = docs 33 | deps = 34 | sphinx 35 | sphinx-rtd-theme 36 | commands = 37 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 38 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [] 6 | 7 | operations = [ 8 | migrations.CreateModel( 9 | name="MockLocation", 10 | fields=[ 11 | ("id", models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name="ID")), 12 | ("latitude", models.FloatField()), 13 | ("longitude", models.FloatField()), 14 | ("address", models.CharField(max_length=100)), 15 | ("city", models.CharField(max_length=30)), 16 | ("zip_code", models.CharField(max_length=10)), 17 | ("created", models.DateTimeField(auto_now_add=True)), 18 | ("updated", models.DateTimeField(auto_now=True)), 19 | ], 20 | options={}, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | .eggs/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | *.db 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Pipenv 61 | .tool-versions 62 | .idea 63 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Inonit AS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0006_mockallfield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.9 on 2018-03-22 22:46 2 | 3 | from django.db import migrations, models 4 | 5 | import tests.mockapp.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("mockapp", "0004_load_fixtures"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="MockAllField", 16 | fields=[ 17 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 18 | ("charfield", models.CharField(max_length=100)), 19 | ("integerfield", models.IntegerField()), 20 | ("floatfield", models.FloatField()), 21 | ("decimalfield", models.DecimalField(decimal_places=2, max_digits=5)), 22 | ("boolfield", models.BooleanField(default=False)), 23 | ("datefield", models.DateField(default=tests.mockapp.models.get_random_date)), 24 | ("datetimefield", models.DateTimeField(default=tests.mockapp.models.get_random_datetime)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/mockapp/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination 2 | 3 | from drf_haystack.filters import ( 4 | HaystackFilter, 5 | ) 6 | from drf_haystack.mixins import FacetMixin, MoreLikeThisMixin 7 | from drf_haystack.viewsets import HaystackViewSet 8 | 9 | from .models import MockPerson 10 | from .serializers import MockPersonFacetSerializer, MoreLikeThisSerializer, SearchSerializer 11 | 12 | 13 | class BasicPageNumberPagination(PageNumberPagination): 14 | page_size = 20 15 | page_size_query_param = "page_size" 16 | 17 | 18 | class BasicLimitOffsetPagination(LimitOffsetPagination): 19 | default_limit = 20 20 | 21 | 22 | class SearchPersonFacetViewSet(FacetMixin, HaystackViewSet): 23 | index_models = [MockPerson] 24 | pagination_class = BasicLimitOffsetPagination 25 | serializer_class = SearchSerializer 26 | filter_backends = [HaystackFilter] 27 | 28 | # Faceting 29 | facet_serializer_class = MockPersonFacetSerializer 30 | 31 | 32 | class SearchPersonMLTViewSet(MoreLikeThisMixin, HaystackViewSet): 33 | index_models = [MockPerson] 34 | serializer_class = MoreLikeThisSerializer 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib.util import find_spec 3 | 4 | import django 5 | 6 | test_runner = None 7 | old_config = None 8 | 9 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 10 | 11 | 12 | if hasattr(django, "setup"): 13 | django.setup() 14 | 15 | 16 | def _geospatial_support(): 17 | return find_spec("geopy") and find_spec("haystack.utils.geo.Point") 18 | 19 | 20 | geospatial_support = _geospatial_support() 21 | 22 | 23 | def _restframework_version(): 24 | import rest_framework 25 | 26 | return tuple(map(int, rest_framework.VERSION.split("."))) 27 | 28 | 29 | restframework_version = _restframework_version() 30 | 31 | 32 | def _elasticsearch_version(): 33 | import elasticsearch 34 | 35 | return elasticsearch.VERSION 36 | 37 | 38 | elasticsearch_version = _elasticsearch_version() 39 | 40 | 41 | def setup(): 42 | from django.test.runner import DiscoverRunner 43 | 44 | global test_runner 45 | global old_config 46 | 47 | test_runner = DiscoverRunner() 48 | test_runner.setup_test_environment() 49 | old_config = test_runner.setup_databases() 50 | 51 | 52 | def teardown(): 53 | test_runner.teardown_databases(old_config) 54 | test_runner.teardown_test_environment() 55 | -------------------------------------------------------------------------------- /docs/apidoc/drf_haystack.rst: -------------------------------------------------------------------------------- 1 | 2 | drf_haystack.fields 3 | ------------------- 4 | 5 | .. automodule:: drf_haystack.fields 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | drf_haystack.filters 11 | -------------------- 12 | 13 | .. automodule:: drf_haystack.filters 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | drf_haystack.generics 19 | --------------------- 20 | 21 | .. automodule:: drf_haystack.generics 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | drf_haystack.mixins 27 | ------------------- 28 | 29 | .. automodule:: drf_haystack.mixins 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | drf_haystack.query 35 | ------------------ 36 | 37 | .. automodule:: drf_haystack.query 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | drf_haystack.serializers 43 | ------------------------ 44 | 45 | .. automodule:: drf_haystack.serializers 46 | :members: 47 | :undoc-members: 48 | :show-inheritance: 49 | 50 | drf_haystack.utils 51 | ------------------ 52 | 53 | .. automodule:: drf_haystack.utils 54 | :members: 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | drf_haystack.viewsets 59 | --------------------- 60 | 61 | .. automodule:: drf_haystack.viewsets 62 | :members: 63 | :undoc-members: 64 | :show-inheritance: 65 | -------------------------------------------------------------------------------- /docs/08_permissions.rst: -------------------------------------------------------------------------------- 1 | .. _permissions-label: 2 | 3 | Permissions 4 | =========== 5 | 6 | Django REST Framework allows setting certain ``permission_classes`` in order to control access to views. 7 | The generic ``HaystackGenericAPIView`` defaults to ``rest_framework.permissions.AllowAny`` which enforce no 8 | restrictions on the views. This can be overridden on a per-view basis as you would normally do in a regular 9 | `REST Framework APIView `_. 10 | 11 | 12 | .. note:: 13 | 14 | Since we have no Django model or queryset, the following permission classes are *not* supported: 15 | 16 | - ``rest_framework.permissions.DjangoModelPermissions`` 17 | - ``rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly`` 18 | - ``rest_framework.permissions.DjangoObjectPermissions`` 19 | 20 | ``POST``, ``PUT``, ``PATCH`` and ``DELETE`` are not supported since Haystack Views 21 | are read-only. So if you are using the ``rest_framework.permissions.IsAuthenticatedOrReadOnly`` 22 | , this will act just as the ``AllowAny`` permission. 23 | 24 | 25 | **Example overriding permission classes** 26 | 27 | .. code-block:: python 28 | 29 | ... 30 | from rest_framework.permissions import IsAuthenticated 31 | 32 | class SearchViewSet(HaystackViewSet): 33 | ... 34 | permission_classes = [IsAuthenticated] 35 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from drf_haystack.utils import merge_dict 4 | 5 | 6 | class MergeDictTestCase(TestCase): 7 | def setUp(self): 8 | self.dict_a = { 9 | "person": {"lastname": "Holmes", "combat_proficiency": ["Pistol", "boxing"]}, 10 | } 11 | self.dict_b = { 12 | "person": { 13 | "gender": "male", 14 | "firstname": "Sherlock", 15 | "location": {"address": "221B Baker Street"}, 16 | "combat_proficiency": [ 17 | "sword", 18 | "Martial arts", 19 | ], 20 | } 21 | } 22 | 23 | def test_utils_merge_dict(self): 24 | self.assertEqual( 25 | merge_dict(self.dict_a, self.dict_b), 26 | { 27 | "person": { 28 | "gender": "male", 29 | "firstname": "Sherlock", 30 | "lastname": "Holmes", 31 | "location": {"address": "221B Baker Street"}, 32 | "combat_proficiency": [ 33 | "Martial arts", 34 | "Pistol", 35 | "boxing", 36 | "sword", 37 | ], 38 | } 39 | }, 40 | ) 41 | 42 | def test_utils_merge_dict_invalid_input(self): 43 | self.assertEqual(merge_dict(self.dict_a, "I'm not a dict!"), "I'm not a dict!") 44 | -------------------------------------------------------------------------------- /drf_haystack/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | __title__ = "drf-haystack" 4 | __version__ = "1.9.1" 5 | __author__ = "Rolf Haavard Blindheim" 6 | __license__ = "MIT License" 7 | 8 | VERSION = __version__ 9 | 10 | 11 | def show_sunset_warning(): 12 | """Show a warning about potential future sunsetting.""" 13 | message = ( 14 | "\n" 15 | "==============================================================================\n" 16 | "The `drf-haystack` project urgently needs new maintainers!\n" 17 | "\n" 18 | "The current maintainers are no longer actively using drf-haystack and would\n" 19 | "like to hand over the project to other developers who are using the package.\n" 20 | "\n" 21 | "We will still do the bare minimum maintenance of keeping dependency references\n" 22 | "up to date with new releases of related packages until January 1st 2026.\n" 23 | "\n" 24 | "If by that time no new maintainers have joined to take over the project, we\n" 25 | "will archive the project and make the repository read-only, with a final\n" 26 | "release with whatever versions the dependencies have at that time.\n" 27 | "\n" 28 | "This gives everyone more than a year to either consider joining as maintainers\n" 29 | "or switch to other packages for handling their search in DRF.\n" 30 | "\n" 31 | "Do you want to join as a maintainer? Have a look at:\n" 32 | "\n" 33 | "==============================================================================\n" 34 | ) 35 | warnings.warn(message, UserWarning, stacklevel=2) 36 | 37 | 38 | show_sunset_warning() 39 | -------------------------------------------------------------------------------- /tests/mockapp/migrations/0004_load_fixtures.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core import serializers 4 | from django.db import migrations 5 | 6 | 7 | def load_data(apps, schema_editor): 8 | """ 9 | Load fixtures for MockPerson, MockPet and MockLocation 10 | """ 11 | 12 | fixtures = os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir, "fixtures")) 13 | 14 | with open(os.path.join(fixtures, "mockperson.json")) as fixture: 15 | objects = serializers.deserialize("json", fixture, ignorenonexistent=True) 16 | for obj in objects: 17 | obj.save() 18 | 19 | with open(os.path.join(fixtures, "mocklocation.json")) as fixture: 20 | objects = serializers.deserialize("json", fixture, ignorenonexistent=True) 21 | for obj in objects: 22 | obj.save() 23 | 24 | with open(os.path.join(fixtures, "mockpet.json")) as fixture: 25 | objects = serializers.deserialize("json", fixture, ignorenonexistent=True) 26 | for obj in objects: 27 | obj.save() 28 | 29 | 30 | def unload_data(apps, schema_editor): 31 | """ 32 | Unload fixtures for MockPerson, MockPet and MockLocation 33 | """ 34 | 35 | MockPerson = apps.get_model("mockapp", "MockPerson") 36 | MockLocation = apps.get_model("mockapp", "MockLocation") 37 | MockPet = apps.get_model("mockapp", "MockPet") 38 | 39 | MockPerson.objects.all().delete() 40 | MockLocation.objects.all().delete() 41 | MockPet.objects.all().delete() 42 | 43 | 44 | class Migration(migrations.Migration): 45 | dependencies = [ 46 | ("mockapp", "0001_initial"), 47 | ("mockapp", "0002_mockperson"), 48 | ("mockapp", "0005_mockperson_birthdate"), 49 | ("mockapp", "0003_mockpet"), 50 | ] 51 | 52 | operations = [migrations.RunPython(load_data, reverse_code=unload_data)] 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from ez_setup import use_setuptools 8 | 9 | use_setuptools() 10 | from setuptools import setup 11 | 12 | 13 | def get_version(package): 14 | """ 15 | Return package version as listed in `__version__` in `init.py`. 16 | """ 17 | init_py = open(os.path.join(package, "__init__.py")).read() 18 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 19 | 20 | 21 | setup( 22 | name="drf-haystack", 23 | version=get_version("drf_haystack"), 24 | description="Makes Haystack play nice with Django REST Framework", 25 | long_description="Implements a ViewSet, FiltersBackends and Serializers in order to play nice with Haystack.", 26 | author="Rolf Håvard Blindheim", 27 | author_email="rhblind@gmail.com", 28 | url="https://github.com/rhblind/drf-haystack", 29 | download_url="https://github.com/rhblind/drf-haystack.git", 30 | license="MIT License", 31 | packages=[ 32 | "drf_haystack", 33 | ], 34 | include_package_data=True, 35 | install_requires=[ 36 | "Django>=3.1,<5.2", 37 | "djangorestframework>=3.12,<3.16", 38 | "django-haystack>=2.8,<3.4", 39 | "python-dateutil", 40 | ], 41 | tests_require=["coverage", "geopy", "requests"], 42 | zip_safe=False, 43 | test_suite="tests.run_tests.start", 44 | classifiers=[ 45 | "Operating System :: OS Independent", 46 | "Development Status :: 5 - Production/Stable", 47 | "Environment :: Web Environment", 48 | "Framework :: Django", 49 | "Intended Audience :: Developers", 50 | "License :: OSI Approved :: MIT License", 51 | "Programming Language :: Python :: 3", 52 | "Topic :: Software Development :: Libraries :: Python Modules", 53 | ], 54 | python_requires=">=3.8", 55 | ) 56 | -------------------------------------------------------------------------------- /tests/mockapp/serializers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from rest_framework.serializers import HyperlinkedIdentityField 4 | 5 | from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer, HighlighterMixin 6 | 7 | from .search_indexes import MockLocationIndex, MockPersonIndex 8 | 9 | 10 | class SearchSerializer(HaystackSerializer): 11 | class Meta: 12 | index_classes = [MockPersonIndex, MockLocationIndex] 13 | fields = [ 14 | "firstname", 15 | "lastname", 16 | "birthdate", 17 | "full_name", 18 | "text", 19 | "address", 20 | "city", 21 | "zip_code", 22 | "highlighted", 23 | ] 24 | 25 | 26 | class HighlighterSerializer(HighlighterMixin, HaystackSerializer): 27 | highlighter_css_class = "my-highlighter-class" 28 | highlighter_html_tag = "em" 29 | 30 | class Meta: 31 | index_classes = [MockPersonIndex, MockLocationIndex] 32 | fields = ["firstname", "lastname", "full_name", "address", "city", "zip_code", "coordinates"] 33 | 34 | 35 | class MoreLikeThisSerializer(HaystackSerializer): 36 | more_like_this = HyperlinkedIdentityField(view_name="search-person-mlt-more-like-this", read_only=True) 37 | 38 | class Meta: 39 | index_classes = [MockPersonIndex] 40 | fields = ["firstname", "lastname", "full_name", "autocomplete"] 41 | 42 | 43 | class MockPersonFacetSerializer(HaystackFacetSerializer): 44 | serialize_objects = True 45 | 46 | class Meta: 47 | index_classes = [MockPersonIndex] 48 | fields = ["firstname", "lastname", "created", "letters"] 49 | field_options = { 50 | "firstname": {}, 51 | "lastname": {}, 52 | "created": { 53 | "start_date": datetime.now() - timedelta(days=3 * 365), 54 | "end_date": datetime.now(), 55 | "gap_by": "day", 56 | "gap_amount": 10, 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /docs/02_autocomplete.rst: -------------------------------------------------------------------------------- 1 | .. _autocomplete-label: 2 | 3 | Autocomplete 4 | ============ 5 | 6 | Some kind of data such as ie. cities and zip codes could be useful to autocomplete. 7 | We have a Django REST Framework filter for performing autocomplete queries. It works 8 | quite like the regular :class:`drf_haystack.filters.HaystackFilter` but *must* be run 9 | against an ``NgramField`` or ``EdgeNgramField`` in order to work properly. The main 10 | difference is that while the HaystackFilter performs a bitwise ``OR`` on terms for the 11 | same parameters, the :class:`drf_haystack.filters.HaystackAutocompleteFilter` reduce query 12 | parameters down to a single filter (using an ``SQ`` object), and performs a bitwise ``AND``. 13 | 14 | By adding a list or tuple of ``ignore_fields`` to the serializer's Meta class, 15 | we can tell the REST framework to ignore these fields. This is handy in cases, 16 | where you do not want to serialize and transfer the content of a text, or n-gram 17 | index down to the client. 18 | 19 | An example using the autocomplete filter might look something like this. 20 | 21 | 22 | .. code-block:: python 23 | 24 | from drf_haystack.filters import HaystackAutocompleteFilter 25 | from drf_haystack.serializers import HaystackSerializer 26 | from drf_haystack.viewsets import HaystackViewSet 27 | 28 | class AutocompleteSerializer(HaystackSerializer): 29 | 30 | class Meta: 31 | index_classes = [LocationIndex] 32 | fields = ["address", "city", "zip_code", "autocomplete"] 33 | ignore_fields = ["autocomplete"] 34 | 35 | # The `field_aliases` attribute can be used in order to alias a 36 | # query parameter to a field attribute. In this case a query like 37 | # /search/?q=oslo would alias the `q` parameter to the `autocomplete` 38 | # field on the index. 39 | field_aliases = { 40 | "q": "autocomplete" 41 | } 42 | 43 | class AutocompleteSearchViewSet(HaystackViewSet): 44 | 45 | index_models = [Location] 46 | serializer_class = AutocompleteSerializer 47 | filter_backends = [HaystackAutocompleteFilter] 48 | -------------------------------------------------------------------------------- /docs/06_term_boost.rst: -------------------------------------------------------------------------------- 1 | .. _term-boost-label: 2 | 3 | Term Boost 4 | ========== 5 | 6 | .. warning:: 7 | 8 | **BIG FAT WARNING** 9 | 10 | As far as I can see, the term boost functionality is implemented by the specs in the 11 | `Haystack documentation `_, 12 | however it does not really work as it should! 13 | 14 | When applying term boost, results are discarded from the search result, and not re-ordered by 15 | boost weight as they should. 16 | These are known problems and there exists open issues for them: 17 | 18 | - https://github.com/inonit/drf-haystack/issues/21 19 | - https://github.com/django-haystack/django-haystack/issues/1235 20 | - https://github.com/django-haystack/django-haystack/issues/508 21 | 22 | **Please do not use this unless you really know what you are doing!** 23 | 24 | (And please let me know if you know how to fix it!) 25 | 26 | 27 | Term boost is achieved on the SearchQuerySet level by calling ``SearchQuerySet().boost()``. It is 28 | implemented as a :class:`drf_haystack.filters.HaystackBoostFilter` filter backend. 29 | The ``HaystackBoostFilter`` does not perform any filtering by itself, and should therefore be combined with 30 | some other filter that does, for example the :class:`drf_haystack.filters.HaystackFilter`. 31 | 32 | .. code-block:: python 33 | 34 | from drf_haystack.filters import HaystackBoostFilter 35 | 36 | class SearchViewSet(HaystackViewSet): 37 | ... 38 | filter_backends = [HaystackFilter, HaystackBoostFilter] 39 | 40 | 41 | The filter expects the query string to contain a ``boost`` parameter, which is a comma separated string 42 | of the term to boost and the boost value. The boost value must be either an integer or float value. 43 | 44 | **Example query** 45 | 46 | .. code-block:: none 47 | 48 | /api/v1/search/?firstname=robin&boost=hood,1.1 49 | 50 | The query above will first filter on ``firstname=robin`` and next apply a slight boost on any document containing 51 | the word ``hood``. 52 | 53 | .. note:: 54 | 55 | Term boost are only applied on terms existing in the ``document field``. 56 | -------------------------------------------------------------------------------- /docs/05_more_like_this.rst: -------------------------------------------------------------------------------- 1 | .. _more-like-this-label: 2 | 3 | More Like This 4 | ============== 5 | 6 | Some search backends supports ``More Like This`` features. In order to take advantage of this, 7 | we have a mixin class :class:`drf_haystack.mixins.MoreLikeThisMixin`, which will append a ``more-like-this`` 8 | detail route to the base name of the ViewSet. Lets say you have a router which looks like this: 9 | 10 | .. code-block:: python 11 | 12 | router = routers.DefaultRouter() 13 | router.register("search", viewset=SearchViewSet, basename="search") # MLT name will be 'search-more-like-this'. 14 | 15 | urlpatterns = patterns( 16 | "", 17 | url(r"^", include(router.urls)) 18 | ) 19 | 20 | The important thing here is that the ``SearchViewSet`` class inherits from the 21 | :class:`drf_haystack.mixins.MoreLikeThisMixin` class in order to get the ``more-like-this`` route automatically added. 22 | The view name will be ``{basename}-more-like-this``, which in this case would be for example ``search-more-like-this``. 23 | 24 | 25 | Serializing the More Like This URL 26 | ---------------------------------- 27 | 28 | In order to include the ``more-like-this`` url in your result you only have to add a ``HyperlinkedIdentityField`` 29 | to your serializer. 30 | Something like this should work okay. 31 | 32 | **Example serializer with More Like This** 33 | 34 | .. code-block:: python 35 | 36 | class SearchSerializer(HaystackSerializer): 37 | 38 | more_like_this = serializers.HyperlinkedIdentityField(view_name="search-more-like-this", read_only=True) 39 | 40 | class Meta: 41 | index_classes = [PersonIndex] 42 | fields = ["firstname", "lastname", "full_name"] 43 | 44 | 45 | class SearchViewSet(MoreLikeThisMixin, HaystackViewSet): 46 | index_models = [Person] 47 | serializer_class = SearchSerializer 48 | 49 | 50 | Now, every result you render with this serializer will include a ``more_like_this`` field containing the url 51 | for similar results. 52 | 53 | Example response 54 | 55 | .. code-block:: json 56 | 57 | [ 58 | { 59 | "full_name": "Jeremy Rowland", 60 | "lastname": "Rowland", 61 | "firstname": "Jeremy", 62 | "more_like_this": "http://example.com/search/5/more-like-this/" 63 | } 64 | ] 65 | -------------------------------------------------------------------------------- /tests/mockapp/fixtures/mockpet.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "mockapp.mockpet", 5 | "fields": { 6 | "name": "John", 7 | "created": "2015-05-19T10:48:08.686Z", 8 | "species": "Iguana", 9 | "updated": "2015-05-19T10:48:08.686Z" 10 | } 11 | }, 12 | { 13 | "pk": 2, 14 | "model": "mockapp.mockpet", 15 | "fields": { 16 | "name": "Fido", 17 | "created": "2015-05-19T10:48:08.688Z", 18 | "species": "Dog", 19 | "updated": "2015-05-19T10:48:08.688Z" 20 | } 21 | }, 22 | { 23 | "pk": 3, 24 | "model": "mockapp.mockpet", 25 | "fields": { 26 | "name": "Zane", 27 | "created": "2015-05-19T10:48:08.689Z", 28 | "species": "Dog", 29 | "updated": "2015-05-19T10:48:08.689Z" 30 | } 31 | }, 32 | { 33 | "pk": 4, 34 | "model": "mockapp.mockpet", 35 | "fields": { 36 | "name": "Spot", 37 | "created": "2015-05-19T10:48:08.690Z", 38 | "species": "Dog", 39 | "updated": "2015-05-19T10:48:08.690Z" 40 | } 41 | }, 42 | { 43 | "pk": 5, 44 | "model": "mockapp.mockpet", 45 | "fields": { 46 | "name": "Sam", 47 | "created": "2015-05-19T10:48:08.691Z", 48 | "species": "Bird", 49 | "updated": "2015-05-19T10:48:08.691Z" 50 | } 51 | }, 52 | { 53 | "pk": 6, 54 | "model": "mockapp.mockpet", 55 | "fields": { 56 | "name": "Jenny", 57 | "created": "2015-05-19T10:48:08.693Z", 58 | "species": "Cat", 59 | "updated": "2015-05-19T10:48:08.693Z" 60 | } 61 | }, 62 | { 63 | "pk": 7, 64 | "model": "mockapp.mockpet", 65 | "fields": { 66 | "name": "Squiggles", 67 | "created": "2015-05-19T10:48:08.694Z", 68 | "species": "Snake", 69 | "updated": "2015-05-19T10:48:08.694Z" 70 | } 71 | }, 72 | { 73 | "pk": 8, 74 | "model": "mockapp.mockpet", 75 | "fields": { 76 | "name": "Buttersworth", 77 | "created": "2015-05-19T10:48:08.695Z", 78 | "species": "Dog", 79 | "updated": "2015-05-19T10:48:08.695Z" 80 | } 81 | }, 82 | { 83 | "pk": 9, 84 | "model": "mockapp.mockpet", 85 | "fields": { 86 | "name": "Tweety", 87 | "created": "2015-05-19T10:48:08.696Z", 88 | "species": "Bird", 89 | "updated": "2015-05-19T10:48:08.696Z" 90 | } 91 | }, 92 | { 93 | "pk": 10, 94 | "model": "mockapp.mockpet", 95 | "fields": { 96 | "name": "Jake", 97 | "created": "2015-05-19T10:48:08.697Z", 98 | "species": "Cat", 99 | "updated": "2015-05-19T10:48:08.697Z" 100 | } 101 | } 102 | ] 103 | -------------------------------------------------------------------------------- /tests/mockapp/fixtures/mockallfield.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "mockapp.mockallfield", 4 | "fields": { 5 | "charfield": "Shauna Robinson", 6 | "integerfield": 674, 7 | "floatfield": 756.1749, 8 | "decimalfield": "21.69", 9 | "boolfield": true 10 | }, 11 | "pk": 1 12 | }, 13 | { 14 | "model": "mockapp.mockallfield", 15 | "fields": { 16 | "charfield": "Zimmerman Warren", 17 | "integerfield": 379, 18 | "floatfield": -386.574, 19 | "decimalfield": "44.87", 20 | "boolfield": true 21 | }, 22 | "pk": 2 23 | }, 24 | { 25 | "model": "mockapp.mockallfield", 26 | "fields": { 27 | "charfield": "Juliana Cooper", 28 | "integerfield": 357, 29 | "floatfield": 666.5266, 30 | "decimalfield": "-57.46", 31 | "boolfield": false 32 | }, 33 | "pk": 3 34 | }, 35 | { 36 | "model": "mockapp.mockallfield", 37 | "fields": { 38 | "charfield": "Wiggins Mccormick", 39 | "integerfield": 441, 40 | "floatfield": 216.1145, 41 | "decimalfield": "192.59", 42 | "boolfield": false 43 | }, 44 | "pk": 4 45 | }, 46 | { 47 | "model": "mockapp.mockallfield", 48 | "fields": { 49 | "charfield": "Rebekah Mclaughlin", 50 | "integerfield": 480, 51 | "floatfield": -976.415, 52 | "decimalfield": "21.86", 53 | "boolfield": true 54 | }, 55 | "pk": 5 56 | }, 57 | { 58 | "model": "mockapp.mockallfield", 59 | "fields": { 60 | "charfield": "Karla Hawkins", 61 | "integerfield": 153, 62 | "floatfield": -506.917, 63 | "decimalfield": "199.86", 64 | "boolfield": false 65 | }, 66 | "pk": 6 67 | }, 68 | { 69 | "model": "mockapp.mockallfield", 70 | "fields": { 71 | "charfield": "Shelly Calhoun", 72 | "integerfield": 528, 73 | "floatfield": 88.5078, 74 | "decimalfield": "-21.48", 75 | "boolfield": false 76 | }, 77 | "pk": 7 78 | }, 79 | { 80 | "model": "mockapp.mockallfield", 81 | "fields": { 82 | "charfield": "Miranda Collins", 83 | "integerfield": 57, 84 | "floatfield": 236.1656, 85 | "decimalfield": "-74.10", 86 | "boolfield": false 87 | }, 88 | "pk": 8 89 | }, 90 | { 91 | "model": "mockapp.mockallfield", 92 | "fields": { 93 | "charfield": "Kathryn Oliver", 94 | "integerfield": 48, 95 | "floatfield": 551.5019, 96 | "decimalfield": "-49.64", 97 | "boolfield": true 98 | }, 99 | "pk": 9 100 | } 101 | ] 102 | -------------------------------------------------------------------------------- /tests/mockapp/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | from random import randint, randrange 3 | 4 | import pytz 5 | from django.db import models 6 | 7 | 8 | def get_random_date(start=date(1950, 1, 1), end=date.today()): 9 | """ 10 | :return a random date between `start` and `end` 11 | """ 12 | delta = (end - start).days * 24 * 60 * 60 13 | return start + timedelta(seconds=randrange(delta)) 14 | 15 | 16 | def get_random_datetime(start=datetime(1950, 1, 1, 0, 0), end=datetime.today()): 17 | """ 18 | :return a random datetime 19 | """ 20 | delta = (end - start).total_seconds() 21 | return (start + timedelta(seconds=randint(0, int(delta)))).replace(tzinfo=pytz.UTC) 22 | 23 | 24 | class MockLocation(models.Model): 25 | latitude = models.FloatField() 26 | longitude = models.FloatField() 27 | address = models.CharField(max_length=100) 28 | city = models.CharField(max_length=30) 29 | zip_code = models.CharField(max_length=10) 30 | 31 | created = models.DateTimeField(auto_now_add=True) 32 | updated = models.DateTimeField(auto_now=True) 33 | 34 | def __str__(self): 35 | return self.address 36 | 37 | @property 38 | def coordinates(self): 39 | try: 40 | from haystack.utils.geo import Point 41 | except ImportError: 42 | return None 43 | else: 44 | return Point(self.longitude, self.latitude, srid=4326) 45 | 46 | 47 | class MockPerson(models.Model): 48 | firstname = models.CharField(max_length=20) 49 | lastname = models.CharField(max_length=20) 50 | birthdate = models.DateField(null=True, default=get_random_date) 51 | 52 | created = models.DateTimeField(auto_now_add=True) 53 | updated = models.DateTimeField(auto_now=True) 54 | 55 | def __str__(self): 56 | return f"{self.firstname} {self.lastname}" 57 | 58 | 59 | class MockPet(models.Model): 60 | name = models.CharField(max_length=20) 61 | species = models.CharField(max_length=20) 62 | 63 | created = models.DateTimeField(auto_now_add=True) 64 | updated = models.DateTimeField(auto_now=True) 65 | 66 | def __str__(self): 67 | return self.name 68 | 69 | 70 | class MockAllField(models.Model): 71 | charfield = models.CharField(max_length=100) 72 | integerfield = models.IntegerField() 73 | floatfield = models.FloatField() 74 | decimalfield = models.DecimalField(max_digits=5, decimal_places=2) 75 | boolfield = models.BooleanField(default=False) 76 | datefield = models.DateField(default=get_random_date) 77 | datetimefield = models.DateTimeField(default=get_random_datetime) 78 | 79 | def __str__(self): 80 | return self.charfield 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🚨 Maintainers wanted 🚨 2 | ======================== 3 | 4 | **This project urgently needs new maintainers!** 5 | 6 | The current maintainers are no longer actively using `drf-haystack` and would 7 | like to [hand over the project to other developers](https://github.com/rhblind/drf-haystack/issues/146) who are using the package. 8 | 9 | We will still do the bare minimum maintenance of keeping dependency references 10 | up to date with new releases of related packages **until January 1st 2026**. 11 | 12 | If by that time no new maintainers have joined to take over the project, we 13 | will archive the project and make the repository read-only, with a final 14 | release with whatever versions the dependencies have at that time. 15 | 16 | This gives everyone more than a year to either consider joining as maintainers 17 | or switch to other packages for handling their search in DRF. 18 | 19 | _The `drf-haystack` maintainers, November 2024_ 20 | 21 | 22 | Haystack for Django REST Framework 23 | ================================== 24 | 25 | Build status 26 | ------------ 27 | 28 | [![Coverage Status](https://coveralls.io/repos/github/rhblind/drf-haystack/badge.svg?branch=main)](https://coveralls.io/github/rhblind/drf-haystack?branch=main) 29 | [![PyPI version](https://badge.fury.io/py/drf-haystack.svg)](https://badge.fury.io/py/drf-haystack) 30 | [![Documentation Status](https://readthedocs.org/projects/drf-haystack/badge/?version=latest)](http://drf-haystack.readthedocs.io/en/latest/?badge=latest) 31 | 32 | 33 | About 34 | ----- 35 | 36 | Small library which tries to simplify integration of Haystack with Django REST Framework. 37 | Fresh [documentation available](https://drf-haystack.readthedocs.io/en/latest/) on Read the docs! 38 | 39 | Supported versions 40 | ------------------ 41 | 42 | - Python 3.7 and above 43 | - Django >=3.1,<5.2 44 | - Haystack >=2.8,<3.4 45 | - Django REST Framework >=3.12.0,<3.16 46 | - elasticsearch >=2.0.0,<=8.3.3, 47 | 48 | 49 | Installation 50 | ------------ 51 | 52 | $ pip install drf-haystack 53 | 54 | Supported features 55 | ------------------ 56 | We aim to support most features Haystack does (or at least those which can be used in a REST API). 57 | Currently, we support: 58 | 59 | - Autocomplete 60 | - Boost (Experimental) 61 | - Faceting 62 | - Geo Spatial Search 63 | - Highlighting 64 | - More Like This 65 | 66 | Show me more! 67 | ------------- 68 | 69 | ```python 70 | from drf_haystack.serializers import HaystackSerializer 71 | from drf_haystack.viewsets import HaystackViewSet 72 | 73 | from myapp.search_indexes import PersonIndex # You would define this Index normally as per Haystack's documentation 74 | 75 | # Serializer 76 | class PersonSearchSerializer(HaystackSerializer): 77 | class Meta: 78 | index_classes = [PersonIndex] 79 | fields = ["firstname", "lastname", "full_name"] 80 | 81 | # ViewSet 82 | class PersonSearchViewSet(HaystackViewSet): 83 | index_models = [Person] 84 | serializer_class = PersonSerializer 85 | ``` 86 | 87 | That's it, you're good to go. Hook it up to a DRF router and happy searching! 88 | -------------------------------------------------------------------------------- /docs/03_geospatial.rst: -------------------------------------------------------------------------------- 1 | .. _geospatial-label: 2 | 3 | GEO spatial locations 4 | ===================== 5 | 6 | Some search backends support geo spatial searching. In order to take advantage of this we 7 | have the :class:`drf_haystack.filters.HaystackGEOSpatialFilter`. 8 | 9 | .. note:: 10 | 11 | The ``HaystackGEOSpatialFilter`` depends on ``geopy`` and ``libgeos``. Make sure to install these 12 | libraries in order to use this filter. 13 | 14 | .. code-block:: none 15 | 16 | $ pip install geopy 17 | $ apt-get install libgeos-c1 (for debian based linux distros) 18 | or 19 | $ brew install geos (for homebrew on OS X) 20 | 21 | 22 | The geospatial filter is somewhat special, and for the time being, relies on a few assumptions. 23 | 24 | #. The index model **must** to have a ``LocationField`` (See :ref:`search-index-example-label` for example). 25 | If your ``LocationField`` is named something other than ``coordinates``, subclass the ``HaystackGEOSpatialFilter`` 26 | and make sure to set the :attr:`drf_haystack.filters.HaystackGEOSpatialFilter.point_field` to the name of the field. 27 | #. The query **must** contain a ``unit`` parameter where the unit is a valid ``UNIT`` in the ``django.contrib.gis.measure.Distance`` class. 28 | #. The query **must** contain a ``from`` parameter which is a comma separated longitude and latitude value. 29 | 30 | You may also change the query param ``from`` by defining ``DRF_HAYSTACK_SPATIAL_QUERY_PARAM`` on your settings. 31 | 32 | **Example Geospatial view** 33 | 34 | .. code-block:: python 35 | 36 | class DistanceSerializer(serializers.Serializer): 37 | m = serializers.FloatField() 38 | km = serializers.FloatField() 39 | 40 | 41 | class LocationSerializer(HaystackSerializer): 42 | 43 | distance = SerializerMethodField() 44 | 45 | class Meta: 46 | index_classes = [LocationIndex] 47 | fields = ["address", "city", "zip_code"] 48 | 49 | def get_distance(self, obj): 50 | if hasattr(obj, "distance"): 51 | return DistanceSerializer(obj.distance, many=False).data 52 | 53 | 54 | class LocationGeoSearchViewSet(HaystackViewSet): 55 | 56 | index_models = [Location] 57 | serializer_class = LocationSerializer 58 | filter_backends = [HaystackGEOSpatialFilter] 59 | 60 | 61 | **Example subclassing the HaystackGEOSpatialFilter** 62 | 63 | Assuming that your ``LocationField`` is named ``location``. 64 | 65 | .. code-block:: python 66 | 67 | from drf_haystack.filters import HaystackGEOSpatialFilter 68 | 69 | class CustomHaystackGEOSpatialFilter(HaystackGEOSpatialFilter): 70 | point_field = 'location' 71 | 72 | 73 | class LocationGeoSearchViewSet(HaystackViewSet): 74 | 75 | index_models = [Location] 76 | serializer_class = LocationSerializer 77 | filter_backends = [CustomHaystackGEOSpatialFilter] 78 | 79 | Assuming the above code works as it should, we would be able to do queries like this: 80 | 81 | .. code-block:: none 82 | 83 | /api/v1/search/?zip_code=0351&km=10&from=59.744076,10.152045 84 | 85 | 86 | The above query would return all entries with zip_code 0351 within 10 kilometers 87 | from the location with latitude 59.744076 and longitude 10.152045. 88 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests")) 4 | 5 | SECRET_KEY = "NOBODY expects the Spanish Inquisition!" 6 | DEBUG = True 7 | 8 | ALLOWED_HOSTS = ["*"] 9 | 10 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, os.pardir, "test.db")}} 11 | 12 | INSTALLED_APPS = ( 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.staticfiles", 17 | "haystack", 18 | "rest_framework", 19 | "tests.mockapp", 20 | ) 21 | 22 | MIDDLEWARE_CLASSES = ( 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.auth.middleware.SessionAuthenticationMiddleware", 28 | "django.contrib.messages.middleware.MessageMiddleware", 29 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 30 | ) 31 | 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "OPTIONS": {"debug": True}, 36 | "APP_DIRS": True, 37 | }, 38 | ] 39 | 40 | REST_FRAMEWORK = {"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",)} 41 | 42 | ROOT_URLCONF = "tests.urls" 43 | WSGI_APPLICATION = "tests.wsgi.application" 44 | LANGUAGE_CODE = "en-us" 45 | TIME_ZONE = "UTC" 46 | USE_I18N = True 47 | USE_L10N = True 48 | USE_TZ = True 49 | STATIC_URL = "/static/" 50 | 51 | HAYSTACK_CONNECTIONS = { 52 | "default": { 53 | "ENGINE": "haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine", 54 | "URL": os.environ.get("ELASTICSEARCH_URL", "http://localhost:9200/"), 55 | "INDEX_NAME": "drf-haystack-test", 56 | "INCLUDE_SPELLING": True, 57 | "TIMEOUT": 300, 58 | }, 59 | } 60 | 61 | DEFAULT_LOG_DIR = os.path.join(BASE_DIR, "logs") 62 | LOGGING = { 63 | "version": 1, 64 | "disable_existing_loggers": False, 65 | "formatters": { 66 | "standard": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"}, 67 | }, 68 | "handlers": { 69 | "console_handler": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "standard"}, 70 | "file_handler": { 71 | "level": "DEBUG", 72 | "class": "logging.FileHandler", 73 | "filename": os.path.join(DEFAULT_LOG_DIR, "tests.log"), 74 | }, 75 | }, 76 | "loggers": { 77 | "default": { 78 | "handlers": ["file_handler"], 79 | "level": "INFO", 80 | "propagate": True, 81 | }, 82 | "elasticsearch": { 83 | "handlers": ["file_handler"], 84 | "level": "ERROR", 85 | "propagate": True, 86 | }, 87 | "elasticsearch.trace": { 88 | "handlers": ["file_handler"], 89 | "level": "ERROR", 90 | "propagate": True, 91 | }, 92 | }, 93 | } 94 | 95 | try: 96 | import elasticsearch 97 | 98 | if (2,) <= elasticsearch.VERSION <= (3,): 99 | HAYSTACK_CONNECTIONS["default"].update({ 100 | "ENGINE": "haystack.backends.elasticsearch2_backend.Elasticsearch2SearchEngine" 101 | }) 102 | except ImportError: 103 | del HAYSTACK_CONNECTIONS["default"] # This will intentionally cause everything to break! 104 | -------------------------------------------------------------------------------- /docs/10_tips_n_tricks.rst: -------------------------------------------------------------------------------- 1 | .. _tips-n-tricks-label: 2 | 3 | Tips'n Tricks 4 | ============= 5 | 6 | Reusing Model serializers 7 | ------------------------- 8 | 9 | It may be useful to be able to use existing model serializers to return data from search requests in the same format 10 | as used elsewhere in your API. This can be done by modifying the ``to_representation`` method of your serializer to 11 | use the ``instance.object`` instead of the search result instance. As a convenience, a mixin class is provided that 12 | does just that. 13 | 14 | .. class:: drf_haystack.serializers.HaystackSerializerMixin 15 | 16 | An example using the mixin might look like the following: 17 | 18 | .. code-block:: python 19 | 20 | class PersonSerializer(serializers.ModelSerializer): 21 | class Meta: 22 | model = Person 23 | fields = ("id", "firstname", "lastname") 24 | 25 | class PersonSearchSerializer(HaystackSerializerMixin, PersonSerializer): 26 | class Meta(PersonSerializer.Meta): 27 | search_fields = ("text", ) 28 | 29 | The results from a search would then contain the fields from the ``PersonSerializer`` rather than fields from the 30 | search index. 31 | 32 | .. note:: 33 | 34 | If your model serializer specifies a ``fields`` attribute in its Meta class, then the search serializer must 35 | specify a ``search_fields`` attribute in its Meta class if you intend to search on any search index fields 36 | that are not in the model serializer fields (e.g. 'text') 37 | 38 | .. warning:: 39 | 40 | It should be noted that doing this will retrieve the underlying object which means a database hit. Thus, it will 41 | not be as performant as only retrieving data from the search index. If performance is a concern, it would be 42 | better to recreate the desired data structure and store it in the search index. 43 | 44 | 45 | Regular Search View 46 | ------------------- 47 | 48 | Sometimes you might not need all the bells and whistles of a ``ViewSet``, 49 | but can do with a regular view. In such scenario you could do something like this. 50 | 51 | .. code-block:: python 52 | 53 | # 54 | # views.py 55 | # 56 | 57 | from rest_framework.mixins import ListModelMixin 58 | from drf_haystack.generics import HaystackGenericAPIView 59 | 60 | 61 | class SearchView(ListModelMixin, HaystackGenericAPIView): 62 | 63 | serializer_class = LocationSerializer 64 | 65 | def get(self, request, *args, **kwargs): 66 | return self.list(request, *args, **kwargs) 67 | 68 | 69 | # 70 | # urls.py 71 | # 72 | 73 | urlpatterns = ( 74 | ... 75 | url(r'^search/', SearchView.as_view()), 76 | ... 77 | ) 78 | 79 | You can also use `FacetMixin` or `MoreLikeThisMixin` in your regular views as well. 80 | 81 | .. code-block:: python 82 | 83 | # 84 | # views.py 85 | # 86 | 87 | from rest_framework.mixins import ListModelMixin 88 | from drf_haystack.mixins import FacetMixin 89 | from drf_haystack.generics import HaystackGenericAPIView 90 | 91 | 92 | class SearchView(ListModelMixin, FacetMixin, HaystackGenericAPIView): 93 | index_models = [Project] 94 | serializer_class = ProjectListSerializer 95 | facet_serializer_class = ProjectListFacetSerializer 96 | 97 | pagination_class = BasicPagination 98 | permission_classes = (AllowAny,) 99 | 100 | def get(self, request, *args, **kwargs): 101 | return self.facets(request, *args, **kwargs) 102 | -------------------------------------------------------------------------------- /drf_haystack/fields.py: -------------------------------------------------------------------------------- 1 | from rest_framework import fields 2 | 3 | 4 | class DRFHaystackFieldMixin: 5 | prefix_field_names = False 6 | 7 | def __init__(self, **kwargs): 8 | self.prefix_field_names = kwargs.pop("prefix_field_names", False) 9 | super().__init__(**kwargs) 10 | 11 | def bind(self, field_name, parent): 12 | """ 13 | Initializes the field name and parent for the field instance. 14 | Called when a field is added to the parent serializer instance. 15 | Taken from DRF and modified to support drf_haystack multiple index 16 | functionality. 17 | """ 18 | 19 | # In order to enforce a consistent style, we error if a redundant 20 | # 'source' argument has been used. For example: 21 | # my_field = serializer.CharField(source='my_field') 22 | assert self.source != field_name, ( 23 | "It is redundant to specify `source='%s'` on field '%s' in " 24 | "serializer '%s', because it is the same as the field name. " 25 | "Remove the `source` keyword argument." % (field_name, self.__class__.__name__, parent.__class__.__name__) 26 | ) 27 | 28 | self.field_name = field_name 29 | self.parent = parent 30 | 31 | # `self.label` should default to being based on the field name. 32 | if self.label is None: 33 | self.label = field_name.replace("_", " ").capitalize() 34 | 35 | # self.source should default to being the same as the field name. 36 | if self.source is None: 37 | self.source = self.convert_field_name(field_name) 38 | 39 | # self.source_attrs is a list of attributes that need to be looked up 40 | # when serializing the instance, or populating the validated data. 41 | if self.source == "*": 42 | self.source_attrs = [] 43 | else: 44 | self.source_attrs = self.source.split(".") 45 | 46 | def convert_field_name(self, field_name): 47 | if not self.prefix_field_names: 48 | return field_name 49 | return field_name.split("__")[-1] 50 | 51 | 52 | class HaystackBooleanField(DRFHaystackFieldMixin, fields.BooleanField): 53 | pass 54 | 55 | 56 | class HaystackCharField(DRFHaystackFieldMixin, fields.CharField): 57 | pass 58 | 59 | 60 | class HaystackDateField(DRFHaystackFieldMixin, fields.DateField): 61 | pass 62 | 63 | 64 | class HaystackDateTimeField(DRFHaystackFieldMixin, fields.DateTimeField): 65 | pass 66 | 67 | 68 | class HaystackDecimalField(DRFHaystackFieldMixin, fields.DecimalField): 69 | pass 70 | 71 | 72 | class HaystackFloatField(DRFHaystackFieldMixin, fields.FloatField): 73 | pass 74 | 75 | 76 | class HaystackIntegerField(DRFHaystackFieldMixin, fields.IntegerField): 77 | pass 78 | 79 | 80 | class HaystackMultiValueField(DRFHaystackFieldMixin, fields.ListField): 81 | pass 82 | 83 | 84 | class FacetDictField(fields.DictField): 85 | """ 86 | A special DictField which passes the key attribute down to the children's 87 | ``to_representation()`` in order to let the serializer know what field they're 88 | currently processing. 89 | """ 90 | 91 | def to_representation(self, value): 92 | return {str(key): self.child.to_representation(key, val) for key, val in value.items()} 93 | 94 | 95 | class FacetListField(fields.ListField): 96 | """ 97 | The ``FacetListField`` just pass along the key derived from 98 | ``FacetDictField``. 99 | """ 100 | 101 | def to_representation(self, key, data): 102 | return [self.child.to_representation(key, item) for item in data] 103 | -------------------------------------------------------------------------------- /tests/mockapp/search_indexes.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from haystack import indexes 3 | 4 | from .models import MockAllField, MockLocation, MockPerson, MockPet 5 | 6 | 7 | class MockLocationIndex(indexes.SearchIndex, indexes.Indexable): 8 | text = indexes.CharField(document=True, use_template=True) 9 | address = indexes.CharField(model_attr="address") 10 | city = indexes.CharField(model_attr="city") 11 | zip_code = indexes.CharField(model_attr="zip_code") 12 | 13 | autocomplete = indexes.EdgeNgramField() 14 | coordinates = indexes.LocationField(model_attr="coordinates") 15 | 16 | @staticmethod 17 | def prepare_autocomplete(obj): 18 | return " ".join((obj.address, obj.city, obj.zip_code)) 19 | 20 | def get_model(self): 21 | return MockLocation 22 | 23 | def index_queryset(self, using=None): 24 | return self.get_model().objects.filter(created__lte=timezone.now()) 25 | 26 | 27 | class MockPersonIndex(indexes.SearchIndex, indexes.Indexable): 28 | text = indexes.CharField(document=True, use_template=True) 29 | firstname = indexes.CharField(model_attr="firstname", faceted=True) 30 | lastname = indexes.CharField(model_attr="lastname", faceted=True) 31 | birthdate = indexes.DateField(model_attr="birthdate", faceted=True) 32 | letters = indexes.MultiValueField(faceted=True) 33 | full_name = indexes.CharField() 34 | description = indexes.CharField() 35 | 36 | autocomplete = indexes.EdgeNgramField() 37 | created = indexes.FacetDateTimeField(model_attr="created") 38 | 39 | @staticmethod 40 | def prepare_full_name(obj): 41 | return " ".join((obj.firstname, obj.lastname)) 42 | 43 | @staticmethod 44 | def prepare_letters(obj): 45 | return [x for x in obj.firstname] 46 | 47 | @staticmethod 48 | def prepare_description(obj): 49 | return " ".join((obj.firstname, "is a nice chap!")) 50 | 51 | @staticmethod 52 | def prepare_autocomplete(obj): 53 | return " ".join((obj.firstname, obj.lastname)) 54 | 55 | def get_model(self): 56 | return MockPerson 57 | 58 | def index_queryset(self, using=None): 59 | return self.get_model().objects.filter(created__lte=timezone.now()) 60 | 61 | 62 | class MockPetIndex(indexes.SearchIndex, indexes.Indexable): 63 | text = indexes.CharField(document=True, use_template=True) 64 | name = indexes.CharField(model_attr="name") 65 | species = indexes.CharField(model_attr="species") 66 | has_rabies = indexes.BooleanField(indexed=False) 67 | description = indexes.CharField() 68 | 69 | autocomplete = indexes.EdgeNgramField() 70 | 71 | @staticmethod 72 | def prepare_description(obj): 73 | return " ".join((obj.name, "the", obj.species)) 74 | 75 | @staticmethod 76 | def prepare_has_rabies(obj): 77 | return True if obj.species == "Dog" else False 78 | 79 | @staticmethod 80 | def prepare_autocomplete(obj): 81 | return obj.name 82 | 83 | def get_model(self): 84 | return MockPet 85 | 86 | 87 | class MockAllFieldIndex(indexes.SearchIndex, indexes.Indexable): 88 | text = indexes.CharField(document=True, use_template=False) 89 | charfield = indexes.CharField(model_attr="charfield") 90 | integerfield = indexes.IntegerField(model_attr="integerfield") 91 | floatfield = indexes.FloatField(model_attr="floatfield") 92 | decimalfield = indexes.DecimalField(model_attr="decimalfield") 93 | boolfield = indexes.BooleanField(model_attr="boolfield") 94 | datefield = indexes.DateField(model_attr="datefield") 95 | datetimefield = indexes.DateTimeField(model_attr="datetimefield") 96 | multivaluefield = indexes.MultiValueField() 97 | 98 | @staticmethod 99 | def prepare_multivaluefield(obj): 100 | return obj.charfield.split(" ", 1) 101 | 102 | def get_model(self): 103 | return MockAllField 104 | -------------------------------------------------------------------------------- /drf_haystack/generics.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.http import Http404 3 | from haystack.backends import SQ 4 | from haystack.query import SearchQuerySet 5 | from rest_framework.generics import GenericAPIView 6 | 7 | from drf_haystack.filters import HaystackFilter 8 | 9 | 10 | class HaystackGenericAPIView(GenericAPIView): 11 | """ 12 | Base class for all haystack generic views. 13 | """ 14 | 15 | # Use `index_models` to filter on which search index models we 16 | # should include in the search result. 17 | index_models = [] 18 | 19 | object_class = SearchQuerySet 20 | query_object = SQ 21 | 22 | # Override document_uid_field with whatever field in your index 23 | # you use to uniquely identify a single document. This value will be 24 | # used wherever the view references the `lookup_field` kwarg. 25 | document_uid_field = "id" 26 | lookup_sep = "," 27 | 28 | # If set to False, DB lookups are done on a per-object basis, 29 | # resulting in in many individual trips to the database. If True, 30 | # the SearchQuerySet will group similar objects into a single query. 31 | load_all = False 32 | 33 | filter_backends = [HaystackFilter] 34 | 35 | def get_queryset(self, index_models=[]): 36 | """ 37 | Get the list of items for this view. 38 | Returns ``self.queryset`` if defined and is a ``self.object_class`` 39 | instance. 40 | 41 | @:param index_models: override `self.index_models` 42 | """ 43 | if self.queryset is not None and isinstance(self.queryset, self.object_class): 44 | queryset = self.queryset.all() 45 | else: 46 | queryset = self.object_class()._clone() 47 | if len(index_models): 48 | queryset = queryset.models(*index_models) 49 | elif len(self.index_models): 50 | queryset = queryset.models(*self.index_models) 51 | return queryset 52 | 53 | def get_object(self): 54 | """ 55 | Fetch a single document from the data store according to whatever 56 | unique identifier is available for that document in the 57 | SearchIndex. 58 | 59 | In cases where the view has multiple ``index_models``, add a ``model`` query 60 | parameter containing a single `app_label.model` name to the request in order 61 | to override which model to include in the SearchQuerySet. 62 | 63 | Example: 64 | /api/v1/search/42/?model=myapp.person 65 | """ 66 | queryset = self.get_queryset() 67 | if "model" in self.request.query_params: 68 | try: 69 | app_label, model = map(str.lower, self.request.query_params["model"].split(".", 1)) 70 | ctype = ContentType.objects.get(app_label=app_label, model=model) 71 | queryset = self.get_queryset(index_models=[ctype.model_class()]) 72 | except (ValueError, ContentType.DoesNotExist): 73 | raise Http404( 74 | "Could not find any models matching '%s'. Make sure to use a valid " 75 | "'app_label.model' name for the 'model' query parameter." % self.request.query_params["model"] 76 | ) 77 | 78 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 79 | if lookup_url_kwarg not in self.kwargs: 80 | raise AttributeError( 81 | "Expected view %s to be called with a URL keyword argument " 82 | "named '%s'. Fix your URL conf, or set the `.lookup_field` " 83 | "attribute on the view correctly." % (self.__class__.__name__, lookup_url_kwarg) 84 | ) 85 | queryset = queryset.filter(self.query_object((self.document_uid_field, self.kwargs[lookup_url_kwarg]))) 86 | count = queryset.count() 87 | if count == 1: 88 | return queryset[0] 89 | elif count > 1: 90 | raise Http404("Multiple results matches the given query. Expected a single result.") 91 | 92 | raise Http404("No result matches the given query.") 93 | 94 | def filter_queryset(self, queryset): 95 | queryset = super().filter_queryset(queryset) 96 | 97 | if self.load_all: 98 | queryset = queryset.load_all() 99 | 100 | return queryset 101 | -------------------------------------------------------------------------------- /drf_haystack/mixins.py: -------------------------------------------------------------------------------- 1 | from rest_framework.decorators import action 2 | from rest_framework.response import Response 3 | 4 | from drf_haystack.filters import HaystackFacetFilter 5 | 6 | 7 | class MoreLikeThisMixin: 8 | """ 9 | Mixin class for supporting "more like this" on an API View. 10 | """ 11 | 12 | @action(detail=True, methods=["get"], url_path="more-like-this") 13 | def more_like_this(self, request, pk=None): 14 | """ 15 | Sets up a detail route for ``more-like-this`` results. 16 | Note that you'll need backend support in order to take advantage of this. 17 | 18 | This will add ie. ^search/{pk}/more-like-this/$ to your existing ^search pattern. 19 | """ 20 | obj = self.get_object().object 21 | queryset = self.filter_queryset(self.get_queryset()).more_like_this(obj) 22 | 23 | page = self.paginate_queryset(queryset) 24 | if page is not None: 25 | serializer = self.get_serializer(page, many=True) 26 | return self.get_paginated_response(serializer.data) 27 | 28 | serializer = self.get_serializer(queryset, many=True) 29 | return Response(serializer.data) 30 | 31 | 32 | class FacetMixin: 33 | """ 34 | Mixin class for supporting faceting on an API View. 35 | """ 36 | 37 | facet_filter_backends = [HaystackFacetFilter] 38 | facet_serializer_class = None 39 | facet_objects_serializer_class = None 40 | facet_query_params_text = "selected_facets" 41 | 42 | @action(detail=False, methods=["get"], url_path="facets") 43 | def facets(self, request): 44 | """ 45 | Sets up a list route for ``faceted`` results. 46 | This will add ie ^search/facets/$ to your existing ^search pattern. 47 | """ 48 | queryset = self.filter_facet_queryset(self.get_queryset()) 49 | 50 | for facet in request.query_params.getlist(self.facet_query_params_text): 51 | if ":" not in facet: 52 | continue 53 | 54 | field, value = facet.split(":", 1) 55 | if value: 56 | queryset = queryset.narrow(f'{field}:"{queryset.query.clean(value)}"') 57 | 58 | serializer = self.get_facet_serializer(queryset.facet_counts(), objects=queryset, many=False) 59 | return Response(serializer.data) 60 | 61 | def filter_facet_queryset(self, queryset): 62 | """ 63 | Given a search queryset, filter it with whichever facet filter backends 64 | in use. 65 | """ 66 | for backend in list(self.facet_filter_backends): 67 | queryset = backend().filter_queryset(self.request, queryset, self) 68 | 69 | if self.load_all: 70 | queryset = queryset.load_all() 71 | 72 | return queryset 73 | 74 | def get_facet_serializer(self, *args, **kwargs): 75 | """ 76 | Return the facet serializer instance that should be used for 77 | serializing faceted output. 78 | """ 79 | assert "objects" in kwargs, "`objects` is a required argument to `get_facet_serializer()`" 80 | 81 | facet_serializer_class = self.get_facet_serializer_class() 82 | kwargs["context"] = self.get_serializer_context() 83 | kwargs["context"].update({ 84 | "objects": kwargs.pop("objects"), 85 | "facet_query_params_text": self.facet_query_params_text, 86 | }) 87 | return facet_serializer_class(*args, **kwargs) 88 | 89 | def get_facet_serializer_class(self): 90 | """ 91 | Return the class to use for serializing facets. 92 | Defaults to using ``self.facet_serializer_class``. 93 | """ 94 | if self.facet_serializer_class is None: 95 | raise AttributeError( 96 | "%(cls)s should either include a `facet_serializer_class` attribute, " 97 | "or override %(cls)s.get_facet_serializer_class() method." % {"cls": self.__class__.__name__} 98 | ) 99 | return self.facet_serializer_class 100 | 101 | def get_facet_objects_serializer(self, *args, **kwargs): 102 | """ 103 | Return the serializer instance which should be used for 104 | serializing faceted objects. 105 | """ 106 | facet_objects_serializer_class = self.get_facet_objects_serializer_class() 107 | kwargs["context"] = self.get_serializer_context() 108 | return facet_objects_serializer_class(*args, **kwargs) 109 | 110 | def get_facet_objects_serializer_class(self): 111 | """ 112 | Return the class to use for serializing faceted objects. 113 | Defaults to using the views ``self.serializer_class`` if not 114 | ``self.facet_objects_serializer_class`` is set. 115 | """ 116 | return self.facet_objects_serializer_class or super().get_serializer_class() 117 | -------------------------------------------------------------------------------- /docs/04_highlighting.rst: -------------------------------------------------------------------------------- 1 | .. _highlighting-label: 2 | 3 | Highlighting 4 | ============ 5 | 6 | Haystack supports two kinds of `Highlighting `_, 7 | and we support them both. 8 | 9 | #. SearchQuerySet highlighting. This kind of highlighting requires a search backend which has support for 10 | highlighting, such as Elasticsearch or Solr. 11 | #. Pure python highlighting. This implementation is somewhat slower, but enables highlighting support 12 | even if your search backend does not support it. 13 | 14 | 15 | .. note:: 16 | 17 | The highlighter will always use the ``document=True`` field on your index to hightlight on. 18 | See examples below. 19 | 20 | SearchQuerySet Highlighting 21 | --------------------------- 22 | 23 | In order to add support for ``SearchQuerySet().highlight()``, all you have to do is to add the 24 | :class:`drf_haystack.filters.HaystackHighlightFilter` to the ``filter_backends`` in your view. The ``HaystackSerializer`` will 25 | check if your queryset has highlighting enabled, and render an additional ``highlighted`` field to 26 | your result. The highlighted words will be encapsulated in an ``words go here`` html tag. 27 | 28 | 29 | **Example view with highlighting enabled** 30 | 31 | .. code-block:: python 32 | 33 | from drf_haystack.viewsets import HaystackViewSet 34 | from drf_haystack.filters import HaystackHighlightFilter 35 | 36 | from .models import Person 37 | from .serializers import PersonSerializer 38 | 39 | 40 | class SearchViewSet(HaystackViewSet): 41 | index_models = [Person] 42 | serializer_class = PersonSerializer 43 | filter_backends = [HaystackHighlightFilter] 44 | 45 | 46 | Given a query like below 47 | 48 | .. code-block:: none 49 | 50 | /api/v1/search/?firstname=jeremy 51 | 52 | 53 | We would get a result like this 54 | 55 | .. code-block:: json 56 | 57 | [ 58 | { 59 | "lastname": "Rowland", 60 | "full_name": "Jeremy Rowland", 61 | "firstname": "Jeremy", 62 | "highlighted": "Jeremy Rowland\nCreated: May 19, 2015, 10:48 a.m.\nLast modified: May 19, 2015, 10:48 a.m.\n" 63 | }, 64 | { 65 | "lastname": "Fowler", 66 | "full_name": "Jeremy Fowler", 67 | "firstname": "Jeremy", 68 | "highlighted": "Jeremy Fowler\nCreated: May 19, 2015, 10:48 a.m.\nLast modified: May 19, 2015, 10:48 a.m.\n" 69 | } 70 | ] 71 | 72 | 73 | 74 | Pure Python Highlighting 75 | ------------------------ 76 | 77 | This implementation make use of the haystack ``Highlighter()`` class. 78 | It is implemented as :class:`drf_haystack.serializers.HighlighterMixin` mixin class, and must be applied on the ``Serializer``. 79 | This is somewhat slower, but more configurable than the :class:`drf_haystack.filters.HaystackHighlightFilter` filter class. 80 | 81 | The Highlighter class will be initialized with the following default options, but can be overridden by 82 | changing any of the following class attributes. 83 | 84 | .. code-block:: python 85 | 86 | highlighter_class = Highlighter 87 | highlighter_css_class = "highlighted" 88 | highlighter_html_tag = "span" 89 | highlighter_max_length = 200 90 | highlighter_field = None 91 | 92 | The Highlighter class will usually highlight the ``document_field`` (the field marked ``document=True`` on your 93 | search index class), but this may be overridden by changing the ``highlighter_field``. 94 | 95 | You can of course also use your own ``Highlighter`` class by overriding the ``highlighter_class = MyFancyHighLighter`` 96 | class attribute. 97 | 98 | 99 | **Example serializer with highlighter support** 100 | 101 | .. code-block:: python 102 | 103 | from drf_haystack.serializers import HighlighterMixin, HaystackSerializer 104 | 105 | class PersonSerializer(HighlighterMixin, HaystackSerializer): 106 | 107 | highlighter_css_class = "my-highlighter-class" 108 | highlighter_html_tag = "em" 109 | 110 | class Meta: 111 | index_classes = [PersonIndex] 112 | fields = ["firstname", "lastname", "full_name"] 113 | 114 | 115 | Response 116 | 117 | .. code-block:: json 118 | 119 | [ 120 | { 121 | "full_name": "Jeremy Rowland", 122 | "lastname": "Rowland", 123 | "firstname": "Jeremy", 124 | "highlighted": "Jeremy Rowland\nCreated: May 19, 2015, 10:48 a.m.\nLast modified: May 19, 2015, 10:48 a.m.\n" 125 | }, 126 | { 127 | "full_name": "Jeremy Fowler", 128 | "lastname": "Fowler", 129 | "firstname": "Jeremy", 130 | "highlighted": "Jeremy Fowler\nCreated: May 19, 2015, 10:48 a.m.\nLast modified: May 19, 2015, 10:48 a.m.\n" 131 | } 132 | ] 133 | -------------------------------------------------------------------------------- /docs/09_multiple_indexes.rst: -------------------------------------------------------------------------------- 1 | .. _multiple-indexes-label: 2 | 3 | Multiple search indexes 4 | ======================= 5 | 6 | So far, we have only used one class in the ``index_classes`` attribute of our serializers. However, you are able to specify 7 | a list of them. This can be useful when your search engine has indexed multiple models and you want to provide aggregate 8 | results across two or more of them. To use the default multiple index support, simply add multiple indexes the ``index_classes`` 9 | list 10 | 11 | .. code-block:: python 12 | 13 | class PersonIndex(indexes.SearchIndex, indexes.Indexable): 14 | text = indexes.CharField(document=True, use_template=True) 15 | firstname = indexes.CharField(model_attr="first_name") 16 | lastname = indexes.CharField(model_attr="last_name") 17 | 18 | def get_model(self): 19 | return Person 20 | 21 | class PlaceIndex(indexes.SearchIndex, indexes.Indexable): 22 | text = indexes.CharField(document=True, use_template=True) 23 | address = indexes.CharField(model_attr="address") 24 | 25 | def get_model(self): 26 | return Place 27 | 28 | class ThingIndex(indexes.SearchIndex, indexes.Indexable): 29 | text = indexes.CharField(document=True, use_template=True) 30 | name = indexes.CharField(model_attr="name") 31 | 32 | def get_model(self): 33 | return Thing 34 | 35 | class AggregateSerializer(HaystackSerializer): 36 | 37 | class Meta: 38 | index_classes = [PersonIndex, PlaceIndex, ThingIndex] 39 | fields = ["firstname", "lastname", "address", "name"] 40 | 41 | 42 | class AggregateSearchViewSet(HaystackViewSet): 43 | 44 | serializer_class = AggregateSerializer 45 | 46 | .. note:: 47 | 48 | The ``AggregateSearchViewSet`` class above omits the optional ``index_models`` attribute. This way results from all the 49 | models are returned. 50 | 51 | The result from searches using multiple indexes is a list of objects, each of which contains only the fields appropriate to 52 | the model from which the result came. For instance if a search returned a list containing one each of the above models, it 53 | might look like the following: 54 | 55 | .. code-block:: javascript 56 | 57 | [ 58 | { 59 | "text": "John Doe", 60 | "firstname": "John", 61 | "lastname": "Doe" 62 | }, 63 | { 64 | "text": "123 Doe Street", 65 | "address": "123 Doe Street" 66 | }, 67 | { 68 | "text": "Doe", 69 | "name": "Doe" 70 | } 71 | ] 72 | 73 | Declared fields 74 | --------------- 75 | 76 | You can include field declarations in the serializer class like normal. Depending on how they are named, they will be 77 | treated as common fields and added to every result or as specific to results from a particular index. 78 | 79 | Common fields are declared as you would any serializer field. Index-specific fields must be prefixed with "___". 80 | The following example illustrates this usage: 81 | 82 | .. code-block:: python 83 | 84 | class AggregateSerializer(HaystackSerializer): 85 | extra = serializers.SerializerMethodField() 86 | _ThingIndex__number = serializers.SerializerMethodField() 87 | 88 | class Meta: 89 | index_classes = [PersonIndex, PlaceIndex, ThingIndex] 90 | fields = ["firstname", "lastname", "address", "name"] 91 | 92 | def get_extra(self,instance): 93 | return "whatever" 94 | 95 | def get__ThingIndex__number(self,instance): 96 | return 42 97 | 98 | The results of a search might then look like the following: 99 | 100 | .. code-block:: javascript 101 | 102 | [ 103 | { 104 | "text": "John Doe", 105 | "firstname": "John", 106 | "lastname": "Doe", 107 | "extra": "whatever" 108 | }, 109 | { 110 | "text": "123 Doe Street", 111 | "address": "123 Doe Street", 112 | "extra": "whatever" 113 | }, 114 | { 115 | "text": "Doe", 116 | "name": "Doe", 117 | "extra": "whatever", 118 | "number": 42 119 | } 120 | ] 121 | 122 | Multiple Serializers 123 | -------------------- 124 | 125 | Alternatively, you can specify a 'serializers' attribute on your Meta class to use a different serializer class 126 | for different indexes as show below: 127 | 128 | .. code-block:: python 129 | 130 | class AggregateSearchSerializer(HaystackSerializer): 131 | class Meta: 132 | serializers = { 133 | PersonIndex: PersonSearchSerializer, 134 | PlaceIndex: PlaceSearchSerializer, 135 | ThingIndex: ThingSearchSerializer 136 | } 137 | 138 | The ``serializers`` attribute is the important thing here, It's a dictionary with ``SearchIndex`` classes as 139 | keys and ``Serializer`` classes as values. Each result in the list of results from a search that contained 140 | items from multiple indexes would be serialized according to the appropriate serializer. 141 | 142 | .. warning:: 143 | 144 | If a field name is shared across serializers, and one serializer overrides the field mapping, the overridden 145 | mapping will be used for *all* serializers. See the example below for more details. 146 | 147 | .. code-block:: python 148 | 149 | from rest_framework import serializers 150 | 151 | class PersonSearchSerializer(HaystackSerializer): 152 | # NOTE: This override will be used for both Person and Place objects. 153 | name = serializers.SerializerMethodField() 154 | 155 | class Meta: 156 | fields = ['name'] 157 | 158 | class PlaceSearchSerializer(HaystackSerializer): 159 | class Meta: 160 | fields = ['name'] 161 | 162 | class AggregateSearchSerializer(HaystackSerializer): 163 | class Meta: 164 | serializers = { 165 | PersonIndex: PersonSearchSerializer, 166 | PlaceIndex: PlaceSearchSerializer, 167 | ThingIndex: ThingSearchSerializer 168 | } 169 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import re 5 | import sys 6 | from datetime import date 7 | from importlib.util import find_spec 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | sys.path.insert(0, os.path.abspath("..")) 13 | os.environ["RUNTIME_ENV"] = "TESTSUITE" 14 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 15 | 16 | if find_spec("django") and find_spec("sphinx_rtd_theme"): 17 | use_sphinx_rtd_theme = True 18 | 19 | import django 20 | 21 | if hasattr(django, "setup"): 22 | django.setup() 23 | else: 24 | use_sphinx_rtd_theme = os.environ.get("READTHEDOCS", False) 25 | 26 | 27 | def get_version(package): 28 | """ 29 | Return package version as listed in `__version__` in `init.py`. 30 | """ 31 | init_py = open(os.path.join(package, "__init__.py")).read() 32 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 33 | 34 | 35 | # -- General configuration ------------------------------------------------ 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | # 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 43 | # ones. 44 | extensions = [ 45 | "sphinx.ext.autodoc", 46 | "sphinx.ext.intersphinx", 47 | "sphinx.ext.extlinks", 48 | "sphinx.ext.todo", 49 | "sphinx.ext.viewcode", 50 | ] 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ["_templates"] 54 | 55 | # The suffix(es) of source filenames. 56 | # You can specify multiple suffix as a list of string: 57 | # 58 | # source_suffix = ['.rst', '.md'] 59 | source_suffix = ".rst" 60 | 61 | # The master toctree document. 62 | master_doc = "index" 63 | 64 | # General information about the project. 65 | project = "drf_haystack" 66 | copyright = "%d, Rolf Håvard Blindheim" % date.today().year 67 | author = "Rolf Håvard Blindheim" 68 | 69 | # The version info for the project you're documenting, acts as replacement for 70 | # |version| and |release|, also used in various other places throughout the 71 | # built documents. 72 | # 73 | # The full version, including alpha/beta/rc tags. 74 | version = release = get_version(os.path.join("..", "drf_haystack")) 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | # 79 | # This is also used if you do content translation via gettext catalogs. 80 | # Usually you set "language" from the command line for these cases. 81 | language = None 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | # This patterns also effect to html_static_path and html_extra_path 86 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = "sphinx" 90 | 91 | # If true, `todo` and `todoList` produce output, else they produce nothing. 92 | todo_include_todos = True 93 | 94 | 95 | # -- Options for HTML output ---------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | # 100 | html_theme = "sphinx_rtd_theme" if use_sphinx_rtd_theme else "alabaster" 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | # 106 | # html_theme_options = {} 107 | 108 | # Add any paths that contain custom static files (such as style sheets) here, 109 | # relative to this directory. They are copied after the builtin static files, 110 | # so a file named "default.css" will overwrite the builtin "default.css". 111 | html_static_path = ["_static"] 112 | 113 | 114 | # -- Options for HTMLHelp output ------------------------------------------ 115 | 116 | # Output file base name for HTML help builder. 117 | htmlhelp_basename = "drfhaystackdoc" 118 | 119 | 120 | # -- Options for LaTeX output --------------------------------------------- 121 | 122 | latex_elements = { 123 | # The paper size ('letterpaper' or 'a4paper'). 124 | "papersize": "a4paper", 125 | # The font size ('10pt', '11pt' or '12pt'). 126 | "pointsize": "11pt", 127 | # Additional stuff for the LaTeX preamble. 128 | # 129 | # 'preamble': '', 130 | # Latex figure (float) alignment 131 | # 132 | # 'figure_align': 'htbp', 133 | } 134 | 135 | # Grouping the document tree into LaTeX files. List of tuples 136 | # (source start file, target name, title, 137 | # author, documentclass [howto, manual, or own class]). 138 | latex_documents = [ 139 | ("index", "drf-haystack.tex", "drf-haystack documentation", "Inonit", "manual"), 140 | ] 141 | 142 | 143 | # -- Options for manual page output --------------------------------------- 144 | 145 | # One entry per manual page. List of tuples 146 | # (source start file, name, description, authors, manual section). 147 | man_pages = [("index", "drf-haystack", "drf-haystack documentation", ["Inonit"], 1)] 148 | 149 | 150 | # -- Options for Texinfo output ------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | ( 157 | "index", 158 | "drf-haystack", 159 | "drf-haystack documentation", 160 | "Inonit", 161 | "drf-haystack", 162 | "Haystack for Django REST Framework", 163 | "Miscellaneous", 164 | ), 165 | ] 166 | 167 | # Example configuration for intersphinx: refer to the Python standard library. 168 | intersphinx_mapping = { 169 | "http://docs.python.org/": None, 170 | "django": ("https://django.readthedocs.io/en/latest/", None), 171 | "haystack": ("https://django-haystack.readthedocs.io/en/latest/", None), 172 | } 173 | 174 | # Configurations for extlinks 175 | extlinks = { 176 | "drf-pr": ("https://github.com/rhblind/drf-haystack/pull/%s", "PR#"), 177 | "drf-issue": ("https://github.com/rhblind/drf-haystack/issues/%s", "#"), 178 | "haystack-issue": ("https://github.com/django-haystack/django-haystack/issues/%s", "#"), 179 | } 180 | -------------------------------------------------------------------------------- /docs/01_intro.rst: -------------------------------------------------------------------------------- 1 | .. _basic-usage-label: 2 | 3 | =========== 4 | Basic Usage 5 | =========== 6 | 7 | Usage is best demonstrated with some simple examples. 8 | 9 | .. warning:: 10 | 11 | The code here is for demonstration purposes only! It might work (or not, I haven't 12 | tested), but as always, don't blindly copy code from the internet. 13 | 14 | Examples 15 | ======== 16 | 17 | models.py 18 | --------- 19 | 20 | Let's say we have an app which contains a model `Location`. It could look something like this. 21 | 22 | .. code-block:: python 23 | 24 | # 25 | # models.py 26 | # 27 | 28 | from django.db import models 29 | from haystack.utils.geo import Point 30 | 31 | 32 | class Location(models.Model): 33 | 34 | latitude = models.FloatField() 35 | longitude = models.FloatField() 36 | address = models.CharField(max_length=100) 37 | city = models.CharField(max_length=30) 38 | zip_code = models.CharField(max_length=10) 39 | 40 | created = models.DateTimeField(auto_now_add=True) 41 | updated = models.DateTimeField(auto_now=True) 42 | 43 | def __str__(self): 44 | return self.address 45 | 46 | @property 47 | def coordinates(self): 48 | return Point(self.longitude, self.latitude) 49 | 50 | 51 | .. _search-index-example-label: 52 | 53 | search_indexes.py 54 | ----------------- 55 | 56 | We would have to make a ``search_indexes.py`` file for haystack to pick it up. 57 | 58 | .. code-block:: python 59 | 60 | # 61 | # search_indexes.py 62 | # 63 | 64 | from django.utils import timezone 65 | from haystack import indexes 66 | from .models import Location 67 | 68 | 69 | class LocationIndex(indexes.SearchIndex, indexes.Indexable): 70 | 71 | text = indexes.CharField(document=True, use_template=True) 72 | address = indexes.CharField(model_attr="address") 73 | city = indexes.CharField(model_attr="city") 74 | zip_code = indexes.CharField(model_attr="zip_code") 75 | 76 | autocomplete = indexes.EdgeNgramField() 77 | coordinates = indexes.LocationField(model_attr="coordinates") 78 | 79 | @staticmethod 80 | def prepare_autocomplete(obj): 81 | return " ".join(( 82 | obj.address, obj.city, obj.zip_code 83 | )) 84 | 85 | def get_model(self): 86 | return Location 87 | 88 | def index_queryset(self, using=None): 89 | return self.get_model().objects.filter( 90 | created__lte=timezone.now() 91 | ) 92 | 93 | 94 | views.py 95 | -------- 96 | 97 | For a generic Django REST Framework view, you could do something like this. 98 | 99 | .. code-block:: python 100 | 101 | # 102 | # views.py 103 | # 104 | 105 | from drf_haystack.serializers import HaystackSerializer 106 | from drf_haystack.viewsets import HaystackViewSet 107 | 108 | from .models import Location 109 | from .search_indexes import LocationIndex 110 | 111 | 112 | class LocationSerializer(HaystackSerializer): 113 | 114 | class Meta: 115 | # The `index_classes` attribute is a list of which search indexes 116 | # we want to include in the search. 117 | index_classes = [LocationIndex] 118 | 119 | # The `fields` contains all the fields we want to include. 120 | # NOTE: Make sure you don't confuse these with model attributes. These 121 | # fields belong to the search index! 122 | fields = [ 123 | "text", "address", "city", "zip_code", "autocomplete" 124 | ] 125 | 126 | 127 | class LocationSearchView(HaystackViewSet): 128 | 129 | # `index_models` is an optional list of which models you would like to include 130 | # in the search result. You might have several models indexed, and this provides 131 | # a way to filter out those of no interest for this particular view. 132 | # (Translates to `SearchQuerySet().models(*index_models)` behind the scenes. 133 | index_models = [Location] 134 | 135 | serializer_class = LocationSerializer 136 | 137 | 138 | urls.py 139 | ------- 140 | 141 | Finally, hook up the views in your `urls.py` file. 142 | 143 | .. note:: 144 | 145 | Make sure you specify the `basename` attribute when wiring up the view in the router. 146 | Since we don't have any single `model` for the view, it is impossible for the router to 147 | automatically figure out the base name for the view. 148 | 149 | .. code-block:: python 150 | 151 | # 152 | # urls.py 153 | # 154 | 155 | from django.conf.urls import patterns, url, include 156 | from rest_framework import routers 157 | 158 | from .views import LocationSearchView 159 | 160 | router = routers.DefaultRouter() 161 | router.register("location/search", LocationSearchView, basename="location-search") 162 | 163 | 164 | urlpatterns = patterns( 165 | "", 166 | url(r"/api/v1/", include(router.urls)), 167 | ) 168 | 169 | 170 | Query time! 171 | ----------- 172 | 173 | Now that we have a view wired up, we can start using it. 174 | By default, the `HaystackViewSet` (which, more importantly inherits the `HaystackGenericAPIView` 175 | class) is set up to use the `HaystackFilter`. This is the most basic filter included and can do 176 | basic search by querying any of the field included in the `fields` attribute on the 177 | `Serializer`. 178 | 179 | .. code-block:: none 180 | 181 | http://example.com/api/v1/location/search/?city=Oslo 182 | 183 | Would perform a query looking up all documents where the `city field` equals "Oslo". 184 | 185 | 186 | Field Lookups 187 | ............. 188 | 189 | You can also use field lookups in your field queries. See the 190 | Haystack `field lookups `_ 191 | documentation for info on what lookups are available. A query using a lookup might look like the 192 | following: 193 | 194 | .. code-block:: none 195 | 196 | http://example.com/api/v1/location/search/?city__startswith=Os 197 | 198 | This would perform a query looking up all documents where the `city field` started with "Os". 199 | You might get "Oslo", "Osaka", and "Ostrava". 200 | 201 | Term Negation 202 | ............. 203 | 204 | You can also specify terms to exclude from the search results using the negation keyword. 205 | The default keyword is ``not``, but is configurable via settings using ``DRF_HAYSTACK_NEGATION_KEYWORD``. 206 | 207 | .. code-block:: none 208 | 209 | http://example.com/api/v1/location/search/?city__not=Oslo 210 | http://example.com/api/v1/location/search/?city__not__contains=Los 211 | http://example.com/api/v1/location/search/?city__contains=Los&city__not__contains=Angeles 212 | -------------------------------------------------------------------------------- /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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-pushit.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-pushit.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-pushit" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-pushit" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 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. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-pushit.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-pushit.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /drf_haystack/filters.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from functools import reduce 3 | 4 | from django.core.exceptions import ImproperlyConfigured 5 | from haystack.query import SearchQuerySet 6 | from rest_framework.filters import BaseFilterBackend, OrderingFilter 7 | 8 | from drf_haystack.query import BoostQueryBuilder, FacetQueryBuilder, FilterQueryBuilder, SpatialQueryBuilder 9 | 10 | 11 | class BaseHaystackFilterBackend(BaseFilterBackend): 12 | """ 13 | A base class from which all Haystack filter backend classes should inherit. 14 | """ 15 | 16 | query_builder_class = None 17 | 18 | @staticmethod 19 | def get_request_filters(request): 20 | return request.query_params.copy() 21 | 22 | def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None): 23 | """ 24 | Apply constructed filters and excludes and return the queryset 25 | 26 | :param queryset: queryset to filter 27 | :param applicable_filters: filters which are passed directly to queryset.filter() 28 | :param applicable_exclusions: filters which are passed directly to queryset.exclude() 29 | :returns filtered queryset 30 | """ 31 | if applicable_filters: 32 | queryset = queryset.filter(applicable_filters) 33 | if applicable_exclusions: 34 | queryset = queryset.exclude(applicable_exclusions) 35 | return queryset 36 | 37 | def build_filters(self, view, filters=None): 38 | """ 39 | Get the query builder instance and return constructed query filters. 40 | """ 41 | query_builder = self.get_query_builder(backend=self, view=view) 42 | return query_builder.build_query(**(filters if filters else {})) 43 | 44 | def process_filters(self, filters, queryset, view): 45 | """ 46 | Convenient hook to do any post-processing of the filters before they 47 | are applied to the queryset. 48 | """ 49 | return filters 50 | 51 | def filter_queryset(self, request, queryset, view): 52 | """ 53 | Return the filtered queryset. 54 | """ 55 | applicable_filters, applicable_exclusions = self.build_filters(view, filters=self.get_request_filters(request)) 56 | return self.apply_filters( 57 | queryset=queryset, 58 | applicable_filters=self.process_filters(applicable_filters, queryset, view), 59 | applicable_exclusions=self.process_filters(applicable_exclusions, queryset, view), 60 | ) 61 | 62 | def get_query_builder(self, *args, **kwargs): 63 | """ 64 | Return the query builder class instance that should be used to 65 | build the query which is passed to the search engine backend. 66 | """ 67 | query_builder = self.get_query_builder_class() 68 | return query_builder(*args, **kwargs) 69 | 70 | def get_query_builder_class(self): 71 | """ 72 | Return the class to use for building the query. 73 | Defaults to using `self.query_builder_class`. 74 | 75 | You may want to override this if you need to provide different 76 | methods of building the query sent to the search engine backend. 77 | """ 78 | assert self.query_builder_class is not None, ( 79 | "'%s' should either include a `query_builder_class` attribute, " 80 | "or override the `get_query_builder_class()` method." % self.__class__.__name__ 81 | ) 82 | return self.query_builder_class 83 | 84 | 85 | class HaystackFilter(BaseHaystackFilterBackend): 86 | """ 87 | A filter backend that compiles a haystack compatible filtering query. 88 | """ 89 | 90 | query_builder_class = FilterQueryBuilder 91 | default_operator = operator.and_ 92 | default_same_param_operator = operator.or_ 93 | 94 | 95 | class HaystackAutocompleteFilter(HaystackFilter): 96 | """ 97 | A filter backend to perform autocomplete search. 98 | 99 | Must be run against fields that are either `NgramField` or 100 | `EdgeNgramField`. 101 | """ 102 | 103 | def process_filters(self, filters, queryset, view): 104 | if not filters: 105 | return filters 106 | 107 | query_bits = [] 108 | for field_name, query in filters.children: 109 | for word in query.split(" "): 110 | bit = queryset.query.clean(word.strip()) 111 | kwargs = {field_name: bit} 112 | query_bits.append(view.query_object(**kwargs)) 113 | return reduce(operator.and_, filter(lambda x: x, query_bits)) 114 | 115 | 116 | class HaystackGEOSpatialFilter(BaseHaystackFilterBackend): 117 | """ 118 | A base filter backend for doing geo spatial filtering. 119 | If using this filter make sure to provide a `point_field` with the name of 120 | your the `LocationField` of your index. 121 | 122 | We'll always do the somewhat slower but more accurate `dwithin` 123 | (radius) filter. 124 | """ 125 | 126 | query_builder_class = SpatialQueryBuilder 127 | point_field = "coordinates" 128 | 129 | def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None): 130 | if applicable_filters: 131 | queryset = queryset.dwithin(**applicable_filters["dwithin"]).distance(**applicable_filters["distance"]) 132 | return queryset 133 | 134 | def filter_queryset(self, request, queryset, view): 135 | return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request))) 136 | 137 | 138 | class HaystackHighlightFilter(HaystackFilter): 139 | """ 140 | A filter backend which adds support for ``highlighting`` on the 141 | SearchQuerySet level (the fast one). 142 | Note that you need to use a search backend which supports highlighting 143 | in order to use this. 144 | 145 | This will add a ``hightlighted`` entry to your response, encapsulating the 146 | highlighted words in an `highlighted results` block. 147 | """ 148 | 149 | def filter_queryset(self, request, queryset, view): 150 | queryset = super().filter_queryset(request, queryset, view) 151 | if self.get_request_filters(request) and isinstance(queryset, SearchQuerySet): 152 | queryset = queryset.highlight() 153 | return queryset 154 | 155 | 156 | class HaystackBoostFilter(BaseHaystackFilterBackend): 157 | """ 158 | Filter backend for applying term boost on query time. 159 | 160 | Apply by adding a comma separated ``boost`` query parameter containing 161 | a the term you want to boost and a floating point or integer for 162 | the boost value. The boost value is based around ``1.0`` as 100% - no boost. 163 | 164 | Gives a slight increase in relevance for documents that include "banana": 165 | /api/v1/search/?boost=banana,1.1 166 | """ 167 | 168 | query_builder_class = BoostQueryBuilder 169 | query_param = "boost" 170 | 171 | def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None): 172 | if applicable_filters: 173 | queryset = queryset.boost(**applicable_filters) 174 | return queryset 175 | 176 | def filter_queryset(self, request, queryset, view): 177 | return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request))) 178 | 179 | 180 | class HaystackFacetFilter(BaseHaystackFilterBackend): 181 | """ 182 | Filter backend for faceting search results. 183 | This backend does not apply regular filtering. 184 | 185 | Faceting field options can be set by using the ``field_options`` attribute 186 | on the serializer, and can be overridden by query parameters. Dates will be 187 | parsed by the ``python-dateutil.parser()`` which can handle most date formats. 188 | 189 | Query parameters is parsed in the following format: 190 | ?field1=option1:value1,option2:value2&field2=option1:value1,option2:value2 191 | where each options ``key:value`` pair is separated by the ``view.lookup_sep`` attribute. 192 | """ 193 | 194 | query_builder_class = FacetQueryBuilder 195 | 196 | def apply_filters(self, queryset, applicable_filters=None, applicable_exclusions=None): 197 | """ 198 | Apply faceting to the queryset 199 | """ 200 | for field, options in applicable_filters["field_facets"].items(): 201 | queryset = queryset.facet(field, **options) 202 | 203 | for field, options in applicable_filters["date_facets"].items(): 204 | queryset = queryset.date_facet(field, **options) 205 | 206 | for field, options in applicable_filters["query_facets"].items(): 207 | queryset = queryset.query_facet(field, **options) 208 | 209 | return queryset 210 | 211 | def filter_queryset(self, request, queryset, view): 212 | return self.apply_filters(queryset, self.build_filters(view, filters=self.get_request_filters(request))) 213 | 214 | 215 | class HaystackOrderingFilter(OrderingFilter): 216 | """ 217 | Some docstring here! 218 | """ 219 | 220 | def get_default_valid_fields(self, queryset, view, context={}): 221 | valid_fields = super().get_default_valid_fields(queryset, view, context) 222 | 223 | # Check if we need to support aggregate serializers 224 | serializer_class = view.get_serializer_class() 225 | if hasattr(serializer_class.Meta, "serializers"): 226 | raise NotImplementedError("Ordering on aggregate serializers is not yet implemented.") 227 | 228 | return valid_fields 229 | 230 | def get_valid_fields(self, queryset, view, context={}): 231 | valid_fields = getattr(view, "ordering_fields", self.ordering_fields) 232 | 233 | if valid_fields is None: 234 | return self.get_default_valid_fields(queryset, view, context) 235 | 236 | elif valid_fields == "__all__": 237 | # View explicitly allows filtering on all model fields. 238 | if not queryset.query.models: 239 | raise ImproperlyConfigured( 240 | "Cannot use %s with '__all__' as 'ordering_fields' attribute on a view " 241 | "which has no 'index_models' set. Either specify some 'ordering_fields', " 242 | "set the 'index_models' attribute or override the 'get_queryset' " 243 | "method and pass some 'index_models'." % self.__class__.__name__ 244 | ) 245 | 246 | model_fields = map( 247 | lambda model: [(field.name, field.verbose_name) for field in model._meta.fields], queryset.query.models 248 | ) 249 | valid_fields = list(set(reduce(operator.concat, model_fields))) 250 | else: 251 | valid_fields = [(item, item) if isinstance(item, str) else item for item in valid_fields] 252 | 253 | return valid_fields 254 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Haystack for Django REST Framework 3 | ================================== 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | 01_intro 11 | 02_autocomplete 12 | 03_geospatial 13 | 04_highlighting 14 | 05_more_like_this 15 | 06_term_boost 16 | 07_faceting 17 | 08_permissions 18 | 09_multiple_indexes 19 | 10_tips_n_tricks 20 | apidoc/modules 21 | 22 | About 23 | ===== 24 | Small library aiming to simplify using Haystack with Django REST Framework 25 | 26 | Features 27 | ======== 28 | 29 | Supported Python and Django versions: 30 | 31 | - Python 3.8+ 32 | - `All supported versions of Django `_ 33 | 34 | 35 | Installation 36 | ============ 37 | It's in the cheese shop! 38 | 39 | .. code-block:: none 40 | 41 | $ pip install drf-haystack 42 | 43 | 44 | Requirements 45 | ============ 46 | - A Supported Django install 47 | - Django REST Framework v3.2.0 and later 48 | - Haystack v2.5 and later 49 | - A supported search engine such as Solr, Elasticsearch, Whoosh, etc. 50 | - Python bindings for the chosen backend (see below). 51 | - (geopy and libgeos if you want to use geo spatial filtering) 52 | 53 | Python bindings 54 | --------------- 55 | 56 | You will also need to install python bindings for the search engine you'll use. 57 | 58 | Elasticsearch 59 | ............. 60 | 61 | See haystack `Elasticsearch `_ 62 | docs for details 63 | 64 | .. code-block:: none 65 | 66 | $ pip install elasticsearch<2.0.0 # For Elasticsearch 1.x 67 | $ pip install elasticsearch>=2.0.0,<3.0.0 # For Elasticsearch 2.x 68 | 69 | Solr 70 | .... 71 | 72 | See haystack `Solr `_ 73 | docs for details. 74 | 75 | .. code-block:: none 76 | 77 | $ pip install pysolr 78 | 79 | Whoosh 80 | ...... 81 | 82 | See haystack `Whoosh `_ 83 | docs for details. 84 | 85 | .. code-block:: none 86 | 87 | $ pip install whoosh 88 | 89 | Xapian 90 | ...... 91 | 92 | See haystack `Xapian `_ 93 | docs for details. 94 | 95 | 96 | Contributors 97 | ============ 98 | 99 | This library has mainly been written by `me `_ while working 100 | at `Inonit `_. I have also had some help from these amazing people! 101 | Thanks guys! 102 | 103 | - See the full list of `contributors `_. 104 | 105 | Changelog 106 | ========= 107 | 108 | v1.9.1 109 | ------ 110 | *Release date: 2024-11-05* 111 | 112 | - Add deprecation warning about potential future sunsetting of the project, due to lack of active maintainers. 113 | 114 | v1.9 115 | ------ 116 | *Release date: 2024-11-04* 117 | 118 | - Return an empty query object instead of an empty list when no filter is passed (:drf-issue:`202`). 119 | - add "id" as an optional value of the serializer fields returning the haystack internal id (:drf-issue:`193`). 120 | - Update supported versions of main dependencies (Django >=2.2,<5.2; djangorestframework >=3.12.0,<3.16; django-haystack >=2.8,<3.4). 121 | 122 | v1.8.13 123 | ------ 124 | *Release date: 2023-10-04* 125 | 126 | - Support patch versions of Django 4.2.x. 127 | - Upgrade a few dependencies. 128 | 129 | v1.8.12 130 | ------ 131 | *Release date: 2023-01-03* 132 | 133 | - Updated supported Django-Haystack versions 134 | 135 | v1.8.11 136 | ------ 137 | *Release date: 2021-08-27* 138 | 139 | - Updated supported Django-Haystack versions 140 | 141 | 142 | v1.8.10 143 | ------ 144 | *Release date: 2021-04-12* 145 | 146 | - Updated supported Django versions 147 | - Updated supported Python versions 148 | 149 | 150 | v1.8.9 151 | ------ 152 | *Release date: 2020-10-05* 153 | 154 | - Updated supported Django versions 155 | 156 | 157 | v1.8.8 158 | ------ 159 | *Release date: 2020-09-28* 160 | 161 | - Updated supported DRF versions 162 | 163 | 164 | v1.8.7 165 | ------ 166 | *Release date: 2020-08-01* 167 | 168 | - Updated supported Python, Haystack and DRF versions 169 | 170 | 171 | v1.8.6 172 | ------ 173 | *Release date: 2019-10-13* 174 | 175 | - Fixed :drf-issue:`139`. Overriding declared fields must now use ``serializers.SerializerMethodField()`` and are 176 | handled by stock DRF. We don't need any custom functionality for this. 177 | - Added support for Django REST Framework v3.10.x 178 | - Dropped Python 2.x support 179 | 180 | v1.8.5 181 | ------ 182 | *Release date: 2019-05-21* 183 | 184 | - Django2.2 support 185 | - Django REST Framework 3.9.x support 186 | 187 | v1.8.4 188 | ------ 189 | *Release date: 2018-08-15* 190 | 191 | - Fixed Django2.1 support 192 | - Replaced `requirements.txt` with `Pipfile` for development dependencies management 193 | 194 | v1.8.3 195 | ------ 196 | *Release date: 2018-06-16* 197 | 198 | - Fixed issues with ``__in=[...]`` and ``__range=[...]`` filters. Closes :drf-issue:`128`. 199 | 200 | v1.8.2 201 | ------ 202 | *Release date: 2018-05-22* 203 | 204 | - Fixed issue with ``_get_count`` for DRF v3.8 205 | 206 | v1.8.1 207 | ------ 208 | *Release date: 2018-04-20* 209 | 210 | - Fixed errors in test suite which caused all tests to run on Elasticsearch 1.x 211 | 212 | v1.8.0 213 | ------ 214 | *Release date: 2018-04-16* 215 | 216 | **This release was pulled because of critical errors in the test suite.** 217 | 218 | - Dropped support for Django v1.10.x and added support for Django v2.0.x 219 | - Updated minimum Django REST Framework requirement to v3.7 220 | - Updated minimum Haystack requirements to v2.8 221 | 222 | v1.7.1rc2 223 | --------- 224 | *Release date: 2018-01-30* 225 | 226 | - Fixes issues with building documentation. 227 | - Fixed some minor typos in documentation. 228 | - Dropped unittest2 in favor of standard lib unittest 229 | 230 | v1.7.1rc1 231 | --------- 232 | *Release date: 2018-01-06* 233 | 234 | - Locked Django versions in order to comply with Haystack requirements. 235 | - Requires development release of Haystack (v2.7.1dev0). 236 | 237 | v1.7.0 238 | ------ 239 | *Release date: 2018-01-06 (Removed from pypi due to critical bugs)* 240 | 241 | - Bumping minimum support for Django to v1.10. 242 | - Bumping minimum support for Django REST Framework to v1.6.0 243 | - Adding support for Elasticsearch 2.x Haystack backend 244 | 245 | v1.6.1 246 | ------ 247 | *Release date: 2017-01-13* 248 | 249 | - Updated docs with correct name for libgeos-c1. 250 | - Updated ``.travis.yml`` with correct name for libgeos-c1. 251 | - Fixed an issue where queryset in the whould be evaluated if attribute is set but has no results, 252 | thus triggering the wrong clause in condition check. :drf-pr:`88` closes :drf-issue:`86`. 253 | 254 | 255 | v1.6.0 256 | ------ 257 | *Release date: 2016-11-08* 258 | 259 | - Added Django 1.10 compatibility. 260 | - Fixed multiple minor issues. 261 | 262 | 263 | v1.6.0rc3 264 | --------- 265 | *Release date: 2016-06-29* 266 | 267 | - Fixed :drf-issue:`61`. Introduction of custom serializers for serializing faceted objects contained a 268 | breaking change. 269 | 270 | v1.6.0rc2 271 | --------- 272 | *Release date: 2016-06-28* 273 | 274 | - Restructured and updated documentation 275 | - Added support for using a custom serializer when serializing faceted objects. 276 | 277 | v1.6.0rc1 278 | --------- 279 | *Release date: 2016-06-24* 280 | 281 | .. note:: 282 | 283 | **This release include breaking changes to the API** 284 | 285 | - Dropped support for Python 2.6, Django 1.5, 1.6 and 1.7 286 | - Will follow `Haystack's `_ supported versions 287 | 288 | 289 | - Removed deprecated ``SQHighlighterMixin``. 290 | - Removed redundant ``BaseHaystackGEOSpatialFilter``. If name of ``indexes.LocationField`` needs to be changed, subclass the ``HaystackGEOSpatialFilter`` directly. 291 | - Reworked filters: 292 | - More consistent naming of methods. 293 | - All filters follow the same logic for building and applying filters and exclusions. 294 | - All filter classes use a ``QueryBuilder`` class for working out validation and building queries which are to be passed to the ``SearchQuerySet``. 295 | - Most filters does *not* inherit from ``HaystackFilter`` anymore (except ``HaystackAutocompleteFilter`` and ``HaystackHighlightFilter``) and will no longer do basic field filtering. Filters should be properly placed in the ``filter_backends`` class attribute in their respective order to be applied. This solves issues where inherited filters responds to query parameters they should ignore. 296 | - HaystackFacetSerializer ``narrow_url`` now returns an absolute url. 297 | - HaystackFacetSerializer now properly serializes ``MultiValueField`` and ``FacetMultiValueField`` items as a JSON Array. 298 | - ``HaystackGenericAPIView.get_object()`` optional ``model`` query parameter now requires a ``app_label.model`` instead of just the ``model``. 299 | - Extracted internal fields and serializer from the ``HaystackFacetSerializer`` in order to ease customization. 300 | - ``HaystackFacetSerializer`` now supports all three `builtin `_ pagination classes, and a hook to support custom pagination classes. 301 | - Extracted the ``more-like-this`` detail route and ``facets`` list route from the generic HaystackViewSet. 302 | - Support for ``more-like-this`` is available as a :class:`drf_haystack.mixins.MoreLikeThisMixin` class. 303 | - Support for ``facets`` is available as a :class:`drf_haystack.mixins.FacetMixin` class. 304 | 305 | v1.5.6 306 | ------ 307 | *Release date: 2015-12-02* 308 | 309 | - Fixed a bug where ``ignore_fields`` on ``HaystackSerializer`` did not work unless ``exclude`` evaluates 310 | to ``True``. 311 | - Removed ``elasticsearch`` from ``install_requires``. Elasticsearch should not be a mandatory requirement, 312 | since it's useless if not using Elasticsearch as backend. 313 | 314 | v1.5.5 315 | ------ 316 | *Release date: 2015-10-31* 317 | 318 | - Added support for Django REST Framework 3.3.0 (Only for Python 2.7/Django 1.7 and above) 319 | - Locked elasticsearch < 2.0.0 (See :drf-issue:`29`) 320 | 321 | v1.5.4 322 | ------ 323 | *Release date: 2015-10-08* 324 | 325 | - Added support for serializing faceted results. Closing :drf-issue:`27`. 326 | 327 | v1.5.3 328 | ------ 329 | *Release date: 2015-10-05* 330 | 331 | - Added support for :ref:`faceting-label` (Github :drf-issue:`11`). 332 | 333 | v1.5.2 334 | ------ 335 | *Release date: 2015-08-23* 336 | 337 | - Proper support for :ref:`multiple-indexes-label` (Github :drf-issue:`22`). 338 | - Experimental support for :ref:`term-boost-label` (This seems to have some issues upstreams, 339 | so unfortunately it does not really work as expected). 340 | - Support for negate in filters. 341 | 342 | v1.5.1 343 | ------ 344 | *Release date: 2015-07-28* 345 | 346 | - Support for More Like This results (Github :drf-issue:`10`). 347 | - Deprecated ``SQHighlighterMixin`` in favor of ``HaystackHighlightFilter``. 348 | - ``HaystackGenericAPIView`` now returns 404 for detail views if more than one entry is found (Github :drf-issue:`19`). 349 | 350 | v1.5.0 351 | ------ 352 | *Release date: 2015-06-29* 353 | 354 | - Added support for field lookups in queries, such as ``field__contains=foobar``. 355 | Check out `Haystack docs `_ 356 | for details. 357 | - Added default ``permission_classes`` on ``HaystackGenericAPIView`` in order to avoid crash when 358 | using global permission classes on REST Framework. See :ref:`permissions-label` for details. 359 | 360 | v1.4 361 | ---- 362 | *Release date: 2015-06-15* 363 | 364 | - Fixed issues for Geo spatial filtering on django-haystack v2.4.x with Elasticsearch. 365 | - A serializer class now accepts a list or tuple of ``ignore_field`` to bypass serialization. 366 | - Added support for Highlighting. 367 | 368 | v1.3 369 | ---- 370 | *Release date: 2015-05-19* 371 | 372 | - ``HaystackGenericAPIView().get_object()`` now returns Http404 instead of an empty ``SearchQueryset`` 373 | if no object is found. This mimics the behaviour from ``GenericAPIView().get_object()``. 374 | - Removed hard dependencies for ``geopy`` and ``libgeos`` (See Github :drf-issue:`5`). This means 375 | that if you want to use the ``HaystackGEOSpatialFilter``, you have to install these libraries 376 | manually. 377 | 378 | v1.2 379 | ---- 380 | *Release date: 2015-03-23* 381 | 382 | - Fixed ``MissingDependency`` error when using another search backend than Elasticsearch. 383 | - Fixed converting distance to D object before filtering in HaystackGEOSpatialFilter. 384 | - Added Python 3 classifier. 385 | 386 | v1.1 387 | ---- 388 | *Release date: 2015-02-16* 389 | 390 | - Full coverage (almost) test suite 391 | - Documentation 392 | - Beta release Development classifier 393 | 394 | v1.0 395 | ---- 396 | *Release date: 2015-02-14* 397 | 398 | - Initial release. 399 | 400 | 401 | Indices and tables 402 | ================== 403 | 404 | * :ref:`genindex` 405 | * :ref:`modindex` 406 | * :ref:`search` 407 | -------------------------------------------------------------------------------- /drf_haystack/query.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import warnings 3 | from functools import reduce 4 | from itertools import chain 5 | 6 | from dateutil import parser 7 | 8 | from drf_haystack import constants 9 | from drf_haystack.utils import merge_dict 10 | 11 | 12 | class BaseQueryBuilder: 13 | """ 14 | Query builder base class. 15 | """ 16 | 17 | def __init__(self, backend, view): 18 | self.backend = backend 19 | self.view = view 20 | 21 | def build_query(self, **filters): 22 | """ 23 | :param dict[str, list[str]] filters: is an expanded QueryDict or 24 | a mapping of keys to a list of parameters. 25 | """ 26 | raise NotImplementedError("You should override this method in subclasses.") 27 | 28 | @staticmethod 29 | def tokenize(stream, separator): 30 | """ 31 | Tokenize and yield query parameter values. 32 | 33 | :param stream: Input value 34 | :param separator: Character to use to separate the tokens. 35 | :return: 36 | """ 37 | for value in stream: 38 | for token in value.split(separator): 39 | if token: 40 | yield token.strip() 41 | 42 | 43 | class BoostQueryBuilder(BaseQueryBuilder): 44 | """ 45 | Query builder class for adding boost to queries. 46 | """ 47 | 48 | def build_query(self, **filters): 49 | 50 | applicable_filters = None 51 | query_param = getattr(self.backend, "query_param", None) 52 | 53 | value = filters.pop(query_param, None) 54 | if value: 55 | try: 56 | term, val = chain.from_iterable(zip(self.tokenize(value, self.view.lookup_sep))) 57 | except ValueError: 58 | raise ValueError("Cannot convert the '%s' query parameter to a valid boost filter." % query_param) 59 | else: 60 | try: 61 | applicable_filters = {"term": term, "boost": float(val)} 62 | except ValueError: 63 | raise ValueError( 64 | "Cannot convert boost to float value. Make sure to provide a numerical boost value." 65 | ) 66 | 67 | return applicable_filters 68 | 69 | 70 | class FilterQueryBuilder(BaseQueryBuilder): 71 | """ 72 | Query builder class suitable for doing basic filtering. 73 | """ 74 | 75 | def __init__(self, backend, view): 76 | super().__init__(backend, view) 77 | 78 | assert getattr(self.backend, "default_operator", None) in (operator.and_, operator.or_), ( 79 | "{cls}.default_operator must be either 'operator.and_' or 'operator.or_'.".format( 80 | cls=self.backend.__class__.__name__ 81 | ) 82 | ) 83 | self.default_operator = self.backend.default_operator 84 | self.default_same_param_operator = getattr(self.backend, "default_same_param_operator", self.default_operator) 85 | 86 | def get_same_param_operator(self, param): 87 | """ 88 | Helper method to allow per param configuration of which operator should be used when multiple filters for the 89 | same param are found. 90 | 91 | :param str param: is the param for which you want to get the operator 92 | :return: Either operator.or_ or operator.and_ 93 | """ 94 | return self.default_same_param_operator 95 | 96 | def build_query(self, **filters): 97 | """ 98 | Creates a single SQ filter from querystring parameters that correspond to the SearchIndex fields 99 | that have been "registered" in `view.fields`. 100 | 101 | Default behavior is to `OR` terms for the same parameters, and `AND` between parameters. Any 102 | querystring parameters that are not registered in `view.fields` will be ignored. 103 | 104 | :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping of keys to a list of 105 | parameters. 106 | """ 107 | 108 | applicable_filters = [] 109 | applicable_exclusions = [] 110 | 111 | for param, value in filters.items(): 112 | excluding_term = False 113 | param_parts = param.split("__") 114 | base_param = param_parts[0] # only test against field without lookup 115 | negation_keyword = constants.DRF_HAYSTACK_NEGATION_KEYWORD 116 | if len(param_parts) > 1 and param_parts[1] == negation_keyword: 117 | excluding_term = True 118 | param = param.replace("__%s" % negation_keyword, "") # haystack wouldn't understand our negation 119 | 120 | if self.view.serializer_class: 121 | if hasattr(self.view.serializer_class.Meta, "field_aliases"): 122 | old_base = base_param 123 | base_param = self.view.serializer_class.Meta.field_aliases.get(base_param, base_param) 124 | param = param.replace(old_base, base_param) # need to replace the alias 125 | 126 | fields = getattr(self.view.serializer_class.Meta, "fields", []) 127 | exclude = getattr(self.view.serializer_class.Meta, "exclude", []) 128 | search_fields = getattr(self.view.serializer_class.Meta, "search_fields", []) 129 | 130 | # Skip if the parameter is not listed in the serializer's `fields` 131 | # or if it's in the `exclude` list. 132 | if ( 133 | ((fields or search_fields) and base_param not in chain(fields, search_fields)) 134 | or base_param in exclude 135 | or not value 136 | ): 137 | continue 138 | 139 | param_queries = [] 140 | if len(param_parts) > 1 and param_parts[-1] in ("in", "range"): 141 | # `in` and `range` filters expects a list of values 142 | param_queries.append(self.view.query_object((param, list(self.tokenize(value, self.view.lookup_sep))))) 143 | else: 144 | for token in self.tokenize(value, self.view.lookup_sep): 145 | param_queries.append(self.view.query_object((param, token))) 146 | 147 | param_queries = [pq for pq in param_queries if pq] 148 | if len(param_queries) > 0: 149 | term = reduce(self.get_same_param_operator(param), param_queries) 150 | if excluding_term: 151 | applicable_exclusions.append(term) 152 | else: 153 | applicable_filters.append(term) 154 | 155 | applicable_filters = ( 156 | reduce(self.default_operator, filter(lambda x: x, applicable_filters)) 157 | if applicable_filters 158 | else self.view.query_object() 159 | ) 160 | 161 | applicable_exclusions = ( 162 | reduce(self.default_operator, filter(lambda x: x, applicable_exclusions)) 163 | if applicable_exclusions 164 | else self.view.query_object() 165 | ) 166 | 167 | return applicable_filters, applicable_exclusions 168 | 169 | 170 | class FacetQueryBuilder(BaseQueryBuilder): 171 | """ 172 | Query builder class suitable for constructing faceted queries. 173 | """ 174 | 175 | def build_query(self, **filters): 176 | """ 177 | Creates a dict of dictionaries suitable for passing to the SearchQuerySet `facet`, 178 | `date_facet` or `query_facet` method. All key word arguments should be wrapped in a list. 179 | 180 | :param view: API View 181 | :param dict[str, list[str]] filters: is an expanded QueryDict or a mapping 182 | of keys to a list of parameters. 183 | """ 184 | field_facets = {} 185 | date_facets = {} 186 | query_facets = {} 187 | facet_serializer_cls = self.view.get_facet_serializer_class() 188 | 189 | if self.view.lookup_sep == ":": 190 | raise AttributeError( 191 | "The %(cls)s.lookup_sep attribute conflicts with the HaystackFacetFilter " 192 | "query parameter parser. Please choose another `lookup_sep` attribute " 193 | "for %(cls)s." % {"cls": self.view.__class__.__name__} 194 | ) 195 | 196 | fields = facet_serializer_cls.Meta.fields 197 | exclude = facet_serializer_cls.Meta.exclude 198 | field_options = facet_serializer_cls.Meta.field_options 199 | 200 | for field, options in filters.items(): 201 | if field not in fields or field in exclude: 202 | continue 203 | 204 | field_options = merge_dict(field_options, {field: self.parse_field_options(self.view.lookup_sep, *options)}) 205 | 206 | valid_gap = ("year", "month", "day", "hour", "minute", "second") 207 | for field, options in field_options.items(): 208 | if any([k in options for k in ("start_date", "end_date", "gap_by", "gap_amount")]): 209 | if not all(("start_date", "end_date", "gap_by" in options)): 210 | raise ValueError("Date faceting requires at least 'start_date', 'end_date' and 'gap_by' to be set.") 211 | 212 | if options["gap_by"] not in valid_gap: 213 | raise ValueError("The 'gap_by' parameter must be one of %s." % ", ".join(valid_gap)) 214 | 215 | options.setdefault("gap_amount", 1) 216 | date_facets[field] = field_options[field] 217 | 218 | else: 219 | field_facets[field] = field_options[field] 220 | 221 | return {"date_facets": date_facets, "field_facets": field_facets, "query_facets": query_facets} 222 | 223 | def parse_field_options(self, *options): 224 | """ 225 | Parse the field options query string and return it as a dictionary. 226 | """ 227 | defaults = {} 228 | for option in options: 229 | if isinstance(option, str): 230 | tokens = [token.strip() for token in option.split(self.view.lookup_sep)] 231 | 232 | for token in tokens: 233 | if not len(token.split(":")) == 2: 234 | warnings.warn( 235 | "The %s token is not properly formatted. Tokens need to be " 236 | "formatted as 'token:value' pairs." % token 237 | ) 238 | continue 239 | 240 | param, value = token.split(":", 1) 241 | 242 | if any([k == param for k in ("start_date", "end_date", "gap_amount")]): 243 | if param in ("start_date", "end_date"): 244 | value = parser.parse(value) 245 | 246 | if param == "gap_amount": 247 | value = int(value) 248 | 249 | defaults[param] = value 250 | 251 | return defaults 252 | 253 | 254 | class SpatialQueryBuilder(BaseQueryBuilder): 255 | """ 256 | Query builder class suitable for construction spatial queries. 257 | """ 258 | 259 | def __init__(self, backend, view): 260 | super().__init__(backend, view) 261 | 262 | assert getattr(self.backend, "point_field", None) is not None, ( 263 | "%(cls)s.point_field cannot be None. Set the %(cls)s.point_field " 264 | "to the name of the `LocationField` you want to filter on your index class." 265 | % {"cls": self.backend.__class__.__name__} 266 | ) 267 | 268 | try: 269 | from haystack.utils.geo import D, Point 270 | 271 | self.D = D 272 | self.Point = Point 273 | except ImportError: 274 | warnings.warn( 275 | "Make sure you've installed the `libgeos` library. " 276 | "Run `apt-get install libgeos` on debian based linux systems, " 277 | "or `brew install geos` on OS X." 278 | ) 279 | raise 280 | 281 | def build_query(self, **filters): 282 | """ 283 | Build queries for geo spatial filtering. 284 | 285 | Expected query parameters are: 286 | - a `unit=value` parameter where the unit is a valid UNIT in the 287 | `django.contrib.gis.measure.Distance` class. 288 | - `from` which must be a comma separated latitude and longitude. 289 | 290 | Example query: 291 | /api/v1/search/?km=10&from=59.744076,10.152045 292 | 293 | Will perform a `dwithin` query within 10 km from the point 294 | with latitude 59.744076 and longitude 10.152045. 295 | """ 296 | 297 | applicable_filters = None 298 | 299 | filters = { 300 | k: filters[k] 301 | for k in chain(self.D.UNITS.keys(), [constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM]) 302 | if k in filters 303 | } 304 | distance = {k: v for k, v in filters.items() if k in self.D.UNITS.keys()} 305 | 306 | try: 307 | latitude, longitude = map( 308 | float, self.tokenize(filters[constants.DRF_HAYSTACK_SPATIAL_QUERY_PARAM], self.view.lookup_sep) 309 | ) 310 | point = self.Point(longitude, latitude, srid=constants.GEO_SRID) 311 | except ValueError: 312 | raise ValueError( 313 | "Cannot convert `from=latitude,longitude` query parameter to " 314 | "float values. Make sure to provide numerical values only!" 315 | ) 316 | except KeyError: 317 | # If the user has not provided any `from` query string parameter, 318 | # just return. 319 | pass 320 | else: 321 | for unit in distance.keys(): 322 | if not len(distance[unit]) == 1: 323 | raise ValueError("Each unit must have exactly one value.") 324 | distance[unit] = float(distance[unit][0]) 325 | 326 | if point and distance: 327 | applicable_filters = { 328 | "dwithin": {"field": self.backend.point_field, "point": point, "distance": self.D(**distance)}, 329 | "distance": {"field": self.backend.point_field, "point": point}, 330 | } 331 | 332 | return applicable_filters 333 | -------------------------------------------------------------------------------- /docs/07_faceting.rst: -------------------------------------------------------------------------------- 1 | .. _faceting-label: 2 | 3 | Faceting 4 | ======== 5 | 6 | Faceting is a way of grouping and narrowing search results by a common factor, for example we can group 7 | all results which are registered on a certain date. Similar to :ref:`more-like-this-label`, the faceting 8 | functionality is implemented by setting up a special ``^search/facets/$`` route on any view which inherits from the 9 | :class:`drf_haystack.mixins.FacetMixin` class. 10 | 11 | 12 | .. note:: 13 | 14 | Options used for faceting is **not** portable across search backends. Make sure to provide 15 | options suitable for the backend you're using. 16 | 17 | 18 | First, read the `Haystack faceting docs `_ and set up 19 | your search index for faceting. 20 | 21 | Serializing faceted counts 22 | -------------------------- 23 | 24 | Faceting is a little special in terms that it *does not* care about SearchQuerySet filtering. Faceting is performed 25 | by calling the ``SearchQuerySet().facet(field, **options)`` and ``SearchQuerySet().date_facet(field, **options)`` 26 | methods, which will apply facets to the SearchQuerySet. Next we need to call the ``SearchQuerySet().facet_counts()`` 27 | in order to retrieve a dictionary with all the *counts* for the faceted fields. 28 | We have a special :class:`drf_haystack.serializers.HaystackFacetSerializer` class which is designed to serialize 29 | these results. 30 | 31 | .. tip:: 32 | 33 | It *is* possible to perform faceting on a subset of the queryset, in which case you'd have to override the 34 | ``get_queryset()`` method of the view to limit the queryset before it is passed on to the 35 | ``filter_facet_queryset()`` method. 36 | 37 | Any serializer subclassed from the ``HaystackFacetSerializer`` is expected to have a ``field_options`` dictionary 38 | containing a set of default options passed to ``facet()`` and ``date_facet()``. 39 | 40 | **Facet serializer example** 41 | 42 | .. code-block:: python 43 | 44 | class PersonFacetSerializer(HaystackFacetSerializer): 45 | 46 | serialize_objects = False # Setting this to True will serialize the 47 | # queryset into an `objects` list. This 48 | # is useful if you need to display the faceted 49 | # results. Defaults to False. 50 | class Meta: 51 | index_classes = [PersonIndex] 52 | fields = ["firstname", "lastname", "created"] 53 | field_options = { 54 | "firstname": {}, 55 | "lastname": {}, 56 | "created": { 57 | "start_date": datetime.now() - timedelta(days=3 * 365), 58 | "end_date": datetime.now(), 59 | "gap_by": "month", 60 | "gap_amount": 3 61 | } 62 | } 63 | 64 | The declared ``field_options`` will be used as default options when faceting is applied to the queryset, but can be 65 | overridden by supplying query string parameters in the following format. 66 | 67 | .. code-block:: none 68 | 69 | ?firstname=limit:1&created=start_date:20th May 2014,gap_by:year 70 | 71 | Each field can be fed options as ``key:value`` pairs. Multiple ``key:value`` pairs can be supplied and 72 | will be separated by the ``view.lookup_sep`` attribute (which defaults to comma). Any ``start_date`` and ``end_date`` 73 | parameters will be parsed by the python-dateutil 74 | `parser() `_ (which can handle most 75 | common date formats). 76 | 77 | .. note:: 78 | 79 | - The ``HaystackFacetFilter`` parses query string parameter options, separated with the ``view.lookup_sep`` 80 | attribute. Each option is parsed as ``key:value`` pairs where the ``:`` is a hardcoded separator. Setting 81 | the ``view.lookup_sep`` attribute to ``":"`` will raise an AttributeError. 82 | 83 | - The date parsing in the ``HaystackFacetFilter`` does intentionally blow up if fed a string format it can't 84 | handle. No exception handling is done, so make sure to convert values to a format you know it can handle 85 | before passing it to the filter. Ie., don't let your users feed their own values in here ;) 86 | 87 | .. warning:: 88 | 89 | Do *not* use the ``HaystackFacetFilter`` in the regular ``filter_backends`` list on the serializer. 90 | It will almost certainly produce errors or weird results. Faceting filters should go in the 91 | ``facet_filter_backends`` list. 92 | 93 | **Example serialized content** 94 | 95 | The serialized content will look a little different than the default Haystack faceted output. 96 | The top level items will *always* be **queries**, **fields** and **dates**, each containing a subset of fields 97 | matching the category. In the example below, we have faceted on the fields *firstname* and *lastname*, which will 98 | make them appear under the **fields** category. We also have faceted on the date field *created*, which will show up 99 | under the **dates** category. Next, each faceted result will have a ``text``, ``count`` and ``narrow_url`` 100 | attribute which should be quite self explaining. 101 | 102 | .. code-block:: json 103 | 104 | { 105 | "queries": {}, 106 | "fields": { 107 | "firstname": [ 108 | { 109 | "text": "John", 110 | "count": 3, 111 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3AJohn" 112 | }, 113 | { 114 | "text": "Randall", 115 | "count": 2, 116 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3ARandall" 117 | }, 118 | { 119 | "text": "Nehru", 120 | "count": 2, 121 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3ANehru" 122 | } 123 | ], 124 | "lastname": [ 125 | { 126 | "text": "Porter", 127 | "count": 2, 128 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=lastname_exact%3APorter" 129 | }, 130 | { 131 | "text": "Odonnell", 132 | "count": 2, 133 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=lastname_exact%3AOdonnell" 134 | }, 135 | { 136 | "text": "Hood", 137 | "count": 2, 138 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=lastname_exact%3AHood" 139 | } 140 | ] 141 | }, 142 | "dates": { 143 | "created": [ 144 | { 145 | "text": "2015-05-15T00:00:00", 146 | "count": 100, 147 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=created_exact%3A2015-05-15+00%3A00%3A00" 148 | } 149 | ] 150 | } 151 | } 152 | 153 | 154 | Serializing faceted results 155 | --------------------------- 156 | 157 | When a ``HaystackFacetSerializer`` class determines what fields to serialize, it will check 158 | the ``serialize_objects`` class attribute to see if it is ``True`` or ``False``. Setting this value to ``True`` 159 | will add an additional ``objects`` field to the serialized results, which will contain the results for the 160 | faceted ``SearchQuerySet``. The results will by default be serialized using the view's ``serializer_class``. 161 | If you wish to use a different serializer for serializing the results, set the 162 | :attr:`drf_haystack.mixins.FacetMixin.facet_objects_serializer_class` class attribute to whatever serializer you want 163 | to use, or override the :meth:`drf_haystack.mixins.FacetMixin.get_facet_objects_serializer_class` method. 164 | 165 | **Example faceted results with paginated serialized objects** 166 | 167 | .. code-block:: json 168 | 169 | { 170 | "fields": { 171 | "firstname": [ 172 | {"...": "..."} 173 | ], 174 | "lastname": [ 175 | {"...": "..."} 176 | ] 177 | }, 178 | "dates": { 179 | "created": [ 180 | {"...": "..."} 181 | ] 182 | }, 183 | "queries": {}, 184 | "objects": { 185 | "count": 3, 186 | "next": "http://example.com/api/v1/search/facets/?page=2&selected_facets=firstname_exact%3AJohn", 187 | "previous": null, 188 | "results": [ 189 | { 190 | "lastname": "Baker", 191 | "firstname": "John", 192 | "full_name": "John Baker", 193 | "text": "John Baker\n" 194 | }, 195 | { 196 | "lastname": "McClane", 197 | "firstname": "John", 198 | "full_name": "John McClane", 199 | "text": "John McClane\n" 200 | } 201 | ] 202 | } 203 | } 204 | 205 | 206 | 207 | Setting up the view 208 | ------------------- 209 | 210 | Any view that inherits the :class:`drf_haystack.mixins.FacetMixin` will have a special 211 | `action route `_ added as 212 | ``^/facets/$``. This view action will not care about regular filtering but will by default use the 213 | ``HaystackFacetFilter`` to perform filtering. 214 | 215 | .. note:: 216 | 217 | In order to avoid confusing the filtering mechanisms in Django Rest Framework, the ``FacetMixin`` 218 | class has a couple of hooks for dealing with faceting, namely: 219 | 220 | - :attr:`drf_haystack.mixins.FacetMixin.facet_filter_backends` - A list of filter backends that will be used to 221 | apply faceting to the queryset. Defaults to :class:`drf_haystack.filters.HaystackFacetFilter`, which should be 222 | sufficient in most cases. 223 | - :attr:`drf_haystack.mixins.FacetMixin.facet_serializer_class` - The :class:`drf_haystack.serializers.HaystackFacetSerializer` 224 | instance that will be used for serializing the result. 225 | - :attr:`drf_haystack.mixins.FacetMixin.facet_objects_serializer_class` - Optional. Set to the serializer class 226 | which should be used for serializing faceted objects. If not set, defaults to ``self.serializer_class``. 227 | - :attr:`drf_haystack.mixins.FacetMixin.filter_facet_queryset()` - Works exactly as the normal 228 | :meth:`drf_haystack.generics.HaystackGenericAPIView.filter_queryset` method, but will only filter on 229 | backends in the ``self.facet_filter_backends`` list. 230 | - :meth:`drf_haystack.mixins.FacetMixin.get_facet_serializer_class` - Returns the ``self.facet_serializer_class`` 231 | class attribute. 232 | - :meth:`drf_haystack.mixins.FacetMixin.get_facet_serializer` - Instantiates and returns the 233 | :class:`drf_haystack.serializers.HaystackFacetSerializer` class returned from 234 | :meth:`drf_haystack.mixins.FacetMixin.get_facet_serializer_class` method. 235 | - :meth:`drf_haystack.mixins.FacetMixin.get_facet_objects_serializer` - Instantiates and returns the serializer 236 | class which will be used to serialize faceted objects. 237 | - :meth:`drf_haystack.mixins.FacetMixin.get_facet_objects_serializer_class` - Returns the 238 | ``self.facet_objects_serializer_class``, or if not set, the ``self.serializer_class``. 239 | 240 | 241 | In order to set up a view which can respond to regular queries under ie ``^search/$`` and faceted queries under 242 | ``^search/facets/$``, we could do something like this. 243 | 244 | We can also change the query param text from ``selected_facets`` to our own choice like ``params`` or ``p``. For this 245 | to make happen please provide ``facet_query_params_text`` attribute as shown in the example. 246 | 247 | .. code-block:: python 248 | 249 | class SearchPersonViewSet(FacetMixin, HaystackViewSet): 250 | 251 | index_models = [MockPerson] 252 | 253 | # This will be used to filter and serialize regular queries as well 254 | # as the results if the `facet_serializer_class` has the 255 | # `serialize_objects = True` set. 256 | serializer_class = SearchSerializer 257 | filter_backends = [HaystackHighlightFilter, HaystackAutocompleteFilter] 258 | 259 | # This will be used to filter and serialize faceted results 260 | facet_serializer_class = PersonFacetSerializer # See example above! 261 | facet_filter_backends = [HaystackFacetFilter] # This is the default facet filter, and 262 | # can be left out. 263 | facet_query_params_text = 'params' #Default is 'selected_facets' 264 | 265 | 266 | Narrowing 267 | --------- 268 | 269 | As we have seen in the examples above, the ``HaystackFacetSerializer`` will add a ``narrow_url`` attribute to each 270 | result it serializes. Follow that link to narrow the search result. 271 | 272 | The ``narrow_url`` is constructed like this: 273 | 274 | - Read all query parameters from the request 275 | - Get a list of ``selected_facets`` 276 | - Update the query parameters by adding the current item to ``selected_facets`` 277 | - Pop the :attr:`drf_haystack.serializers.HaystackFacetSerializer.paginate_by_param` parameter if any in order to 278 | always start at the first page if returning a paginated result. 279 | - Return a ``serializers.Hyperlink`` with URL encoded query parameters 280 | 281 | This means that for each drill-down performed, the original query parameters will be kept in order to make 282 | the ``HaystackFacetFilter`` happy. Additionally, all the previous ``selected_facets`` will be kept and applied 283 | to narrow the ``SearchQuerySet`` properly. 284 | 285 | **Example narrowed result** 286 | 287 | .. code-block:: json 288 | 289 | { 290 | "queries": {}, 291 | "fields": { 292 | "firstname": [ 293 | { 294 | "text": "John", 295 | "count": 1, 296 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3AJohn&selected_facets=lastname_exact%3AMcLaughlin" 297 | } 298 | ], 299 | "lastname": [ 300 | { 301 | "text": "McLaughlin", 302 | "count": 1, 303 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3AJohn&selected_facets=lastname_exact%3AMcLaughlin" 304 | } 305 | ] 306 | }, 307 | "dates": { 308 | "created": [ 309 | { 310 | "text": "2015-05-15T00:00:00", 311 | "count": 1, 312 | "narrow_url": "http://example.com/api/v1/search/facets/?selected_facets=firstname_exact%3AJohn&selected_facets=lastname_exact%3AMcLaughlin&selected_facets=created_exact%3A2015-05-15+00%3A00%3A00" 313 | } 314 | ] 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /tests/test_viewsets.py: -------------------------------------------------------------------------------- 1 | # 2 | # Unit tests for the `drf_haystack.viewsets` classes. 3 | # 4 | 5 | 6 | import json 7 | from unittest import skipIf 8 | 9 | from django.contrib.auth.models import User 10 | from django.test import TestCase 11 | from haystack.query import SearchQuerySet 12 | from rest_framework import status 13 | from rest_framework.pagination import PageNumberPagination 14 | from rest_framework.routers import SimpleRouter 15 | from rest_framework.serializers import Serializer 16 | from rest_framework.test import APIRequestFactory, force_authenticate 17 | 18 | from drf_haystack.mixins import FacetMixin, MoreLikeThisMixin 19 | from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer 20 | from drf_haystack.viewsets import HaystackViewSet 21 | 22 | from . import restframework_version 23 | from .mockapp.models import MockPerson, MockPet 24 | from .mockapp.search_indexes import MockPersonIndex, MockPetIndex 25 | 26 | factory = APIRequestFactory() 27 | 28 | 29 | class HaystackViewSetTestCase(TestCase): 30 | fixtures = ["mockperson", "mockpet"] 31 | 32 | def setUp(self): 33 | MockPersonIndex().reindex() 34 | MockPetIndex().reindex() 35 | self.router = SimpleRouter() 36 | 37 | class FacetSerializer(HaystackFacetSerializer): 38 | class Meta: 39 | fields = ["firstname", "lastname", "created"] 40 | 41 | class ViewSet1(FacetMixin, HaystackViewSet): 42 | index_models = [MockPerson] 43 | serializer_class = Serializer 44 | facet_serializer_class = FacetSerializer 45 | 46 | class ViewSet2(MoreLikeThisMixin, HaystackViewSet): 47 | index_models = [MockPerson] 48 | serializer_class = Serializer 49 | 50 | class ViewSet3(HaystackViewSet): 51 | index_models = [MockPerson, MockPet] 52 | serializer_class = Serializer 53 | 54 | self.view1 = ViewSet1 55 | self.view2 = ViewSet2 56 | self.view3 = ViewSet3 57 | 58 | def tearDown(self): 59 | MockPersonIndex().clear() 60 | 61 | def test_viewset_get_queryset_no_queryset(self): 62 | request = factory.get(path="/", data="", content_type="application/json") 63 | response = self.view1.as_view(actions={"get": "list"})(request) 64 | self.assertEqual(response.status_code, status.HTTP_200_OK) 65 | 66 | def test_viewset_get_queryset_with_queryset(self): 67 | setattr(self.view1, "queryset", SearchQuerySet().all()) 68 | request = factory.get(path="/", data="", content_type="application/json") 69 | response = self.view1.as_view(actions={"get": "list"})(request) 70 | self.assertEqual(response.status_code, status.HTTP_200_OK) 71 | 72 | def test_viewset_get_object_single_index(self): 73 | request = factory.get(path="/", data="", content_type="application/json") 74 | response = self.view1.as_view(actions={"get": "retrieve"})(request, pk=1) 75 | self.assertEqual(response.status_code, status.HTTP_200_OK) 76 | 77 | def test_viewset_get_object_multiple_indices(self): 78 | request = factory.get(path="/", data={"model": "mockapp.mockperson"}, content_type="application/json") 79 | response = self.view3.as_view(actions={"get": "retrieve"})(request, pk=1) 80 | self.assertEqual(response.status_code, status.HTTP_200_OK) 81 | 82 | def test_viewset_get_object_multiple_indices_no_model_query_param(self): 83 | request = factory.get(path="/", data="", content_type="application/json") 84 | response = self.view3.as_view(actions={"get": "retrieve"})(request, pk=1) 85 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 86 | 87 | def test_viewset_get_object_multiple_indices_invalid_modelname(self): 88 | request = factory.get(path="/", data={"model": "spam"}, content_type="application/json") 89 | response = self.view3.as_view(actions={"get": "retrieve"})(request, pk=1) 90 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 91 | 92 | def test_viewset_get_obj_raise_404(self): 93 | request = factory.get(path="/", data="", content_type="application/json") 94 | response = self.view1.as_view(actions={"get": "retrieve"})(request, pk=100000) 95 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 96 | 97 | def test_viewset_get_object_invalid_lookup_field(self): 98 | request = factory.get(path="/", data="", content_type="application/json") 99 | self.assertRaises(AttributeError, self.view1.as_view(actions={"get": "retrieve"}), request, invalid_lookup=1) 100 | 101 | def test_viewset_get_obj_override_lookup_field(self): 102 | setattr(self.view1, "lookup_field", "custom_lookup") 103 | request = factory.get(path="/", data="", content_type="application/json") 104 | response = self.view1.as_view(actions={"get": "retrieve"})(request, custom_lookup=1) 105 | setattr(self.view1, "lookup_field", "pk") 106 | self.assertEqual(response.status_code, status.HTTP_200_OK) 107 | 108 | def test_viewset_more_like_this_decorator(self): 109 | route = self.router.get_routes(self.view2)[2:].pop() 110 | self.assertEqual(route.url, "^{prefix}/{lookup}/more-like-this{trailing_slash}$") 111 | self.assertEqual(route.mapping, {"get": "more_like_this"}) 112 | 113 | def test_viewset_more_like_this_action_route(self): 114 | request = factory.get(path="/", data={}, content_type="application/json") 115 | response = self.view2.as_view(actions={"get": "more_like_this"})(request, pk=1) 116 | self.assertEqual(response.status_code, status.HTTP_200_OK) 117 | 118 | def test_viewset_facets_action_route(self): 119 | request = factory.get(path="/", data={}, content_type="application/json") 120 | response = self.view1.as_view(actions={"get": "facets"})(request) 121 | self.assertEqual(response.status_code, status.HTTP_200_OK) 122 | 123 | 124 | class HaystackViewSetPermissionsTestCase(TestCase): 125 | fixtures = ["mockperson"] 126 | 127 | def setUp(self): 128 | MockPersonIndex().reindex() 129 | 130 | class ViewSet(HaystackViewSet): 131 | serializer_class = Serializer 132 | 133 | self.view = ViewSet 134 | self.user = User.objects.create_user(username="user", email="user@example.com", password="user") 135 | self.admin_user = User.objects.create_superuser(username="admin", email="admin@example.com", password="admin") 136 | 137 | def tearDown(self): 138 | MockPersonIndex().clear() 139 | 140 | def test_viewset_get_queryset_with_no_permsission(self): 141 | setattr(self.view, "permission_classes", []) 142 | 143 | request = factory.get(path="/", data="", content_type="application/json") 144 | response = self.view.as_view(actions={"get": "list"})(request) 145 | self.assertEqual(response.status_code, status.HTTP_200_OK) 146 | 147 | def test_viewset_get_queryset_with_AllowAny_permission(self): 148 | from rest_framework.permissions import AllowAny 149 | 150 | setattr(self.view, "permission_classes", (AllowAny,)) 151 | 152 | request = factory.get(path="/", data="", content_type="application/json") 153 | response = self.view.as_view(actions={"get": "list"})(request) 154 | self.assertEqual(response.status_code, status.HTTP_200_OK) 155 | 156 | def test_viewset_get_queryset_with_IsAuthenticated_permission(self): 157 | from rest_framework.permissions import IsAuthenticated 158 | 159 | setattr(self.view, "permission_classes", (IsAuthenticated,)) 160 | 161 | request = factory.get(path="/", data="", content_type="application/json") 162 | response = self.view.as_view(actions={"get": "list"})(request) 163 | self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 164 | 165 | force_authenticate(request, user=self.user) 166 | response = self.view.as_view(actions={"get": "list"})(request) 167 | self.assertEqual(response.status_code, status.HTTP_200_OK) 168 | 169 | def test_viewset_get_queryset_with_IsAdminUser_permission(self): 170 | from rest_framework.permissions import IsAdminUser 171 | 172 | setattr(self.view, "permission_classes", (IsAdminUser,)) 173 | 174 | request = factory.get(path="/", data="", content_type="application/json") 175 | force_authenticate(request, user=self.user) 176 | response = self.view.as_view(actions={"get": "list"})(request) 177 | self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 178 | 179 | force_authenticate(request, user=self.admin_user) 180 | response = self.view.as_view(actions={"get": "list"})(request) 181 | self.assertEqual(response.status_code, status.HTTP_200_OK) 182 | 183 | def test_viewset_get_queryset_with_IsAuthenticatedOrReadOnly_permission(self): 184 | from rest_framework.permissions import IsAuthenticatedOrReadOnly 185 | 186 | setattr(self.view, "permission_classes", (IsAuthenticatedOrReadOnly,)) 187 | 188 | # Unauthenticated GET requests should pass 189 | request = factory.get(path="/", data="", content_type="application/json") 190 | response = self.view.as_view(actions={"get": "list"})(request) 191 | self.assertEqual(response.status_code, status.HTTP_200_OK) 192 | 193 | # Authenticated GET requests should pass 194 | request = factory.get(path="/", data="", content_type="application/json") 195 | force_authenticate(request, user=self.user) 196 | response = self.view.as_view(actions={"get": "list"})(request) 197 | self.assertEqual(response.status_code, status.HTTP_200_OK) 198 | 199 | # POST, PUT, PATCH and DELETE requests are not supported, so they will 200 | # raise an error. No need to test the permission. 201 | 202 | @skipIf(not restframework_version < (3, 7), "Skipped due to fix in django-rest-framework > 3.6") 203 | def test_viewset_get_queryset_with_DjangoModelPermissions_permission(self): 204 | from rest_framework.permissions import DjangoModelPermissions 205 | 206 | setattr(self.view, "permission_classes", (DjangoModelPermissions,)) 207 | 208 | # The `DjangoModelPermissions` is not supported and should raise an 209 | # AssertionError from rest_framework.permissions. 210 | request = factory.get(path="/", data="", content_type="application/json") 211 | try: 212 | self.view.as_view(actions={"get": "list"})(request) 213 | self.fail( 214 | "Did not fail with AssertionError or AttributeError " 215 | "when calling HaystackView with DjangoModelPermissions" 216 | ) 217 | except (AttributeError, AssertionError) as e: 218 | if isinstance(e, AttributeError): 219 | self.assertEqual(str(e), "'SearchQuerySet' object has no attribute 'model'") 220 | else: 221 | self.assertEqual( 222 | str(e), 223 | "Cannot apply DjangoModelPermissions on a view that does " 224 | "not have `.model` or `.queryset` property.", 225 | ) 226 | 227 | def test_viewset_get_queryset_with_DjangoModelPermissionsOrAnonReadOnly_permission(self): 228 | from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly 229 | 230 | setattr(self.view, "permission_classes", (DjangoModelPermissionsOrAnonReadOnly,)) 231 | 232 | # The `DjangoModelPermissionsOrAnonReadOnly` is not supported and should raise an 233 | # AssertionError from rest_framework.permissions. 234 | request = factory.get(path="/", data="", content_type="application/json") 235 | try: 236 | self.view.as_view(actions={"get": "list"})(request) 237 | self.fail( 238 | "Did not fail with AssertionError when calling HaystackView with DjangoModelPermissionsOrAnonReadOnly" 239 | ) 240 | except (AttributeError, AssertionError) as e: 241 | if isinstance(e, AttributeError): 242 | self.assertEqual(str(e), "'SearchQuerySet' object has no attribute 'model'") 243 | else: 244 | self.assertEqual( 245 | str(e), 246 | "Cannot apply DjangoModelPermissions on a view that does " 247 | "not have `.model` or `.queryset` property.", 248 | ) 249 | 250 | @skipIf(not restframework_version < (3, 7), "Skipped due to fix in django-rest-framework > 3.6") 251 | def test_viewset_get_queryset_with_DjangoObjectPermissions_permission(self): 252 | from rest_framework.permissions import DjangoObjectPermissions 253 | 254 | setattr(self.view, "permission_classes", (DjangoObjectPermissions,)) 255 | 256 | # The `DjangoObjectPermissions` is a subclass of `DjangoModelPermissions` and 257 | # therefore unsupported. 258 | request = factory.get(path="/", data="", content_type="application/json") 259 | try: 260 | self.view.as_view(actions={"get": "list"})(request) 261 | self.fail("Did not fail with AssertionError when calling HaystackView with DjangoModelPermissions") 262 | except (AttributeError, AssertionError) as e: 263 | if isinstance(e, AttributeError): 264 | self.assertEqual(str(e), "'SearchQuerySet' object has no attribute 'model'") 265 | else: 266 | self.assertEqual( 267 | str(e), 268 | "Cannot apply DjangoModelPermissions on a view that does " 269 | "not have `.model` or `.queryset` property.", 270 | ) 271 | 272 | 273 | class PaginatedHaystackViewSetTestCase(TestCase): 274 | fixtures = ["mockperson"] 275 | 276 | def setUp(self): 277 | 278 | MockPersonIndex().reindex() 279 | 280 | class Serializer1(HaystackSerializer): 281 | class Meta: 282 | fields = ["firstname", "lastname"] 283 | index_classes = [MockPersonIndex] 284 | 285 | class NumberPagination(PageNumberPagination): 286 | page_size = 5 287 | 288 | class ViewSet1(HaystackViewSet): 289 | index_models = [MockPerson] 290 | serializer_class = Serializer1 291 | pagination_class = NumberPagination 292 | 293 | self.view1 = ViewSet1 294 | 295 | def tearDown(self): 296 | MockPersonIndex().clear() 297 | 298 | def test_viewset_PageNumberPagination_results(self): 299 | request = factory.get(path="/", data="", content_type="application/json") 300 | response = self.view1.as_view(actions={"get": "list"})(request) 301 | response.render() 302 | content = json.loads(response.content.decode()) 303 | 304 | self.assertTrue(all(k in content for k in ("count", "next", "previous", "results"))) 305 | self.assertEqual(len(content["results"]), 5) 306 | 307 | def test_viewset_PageNumberPagination_navigation_urls(self): 308 | request = factory.get(path="/", data={"page": 2}, content_type="application/json") 309 | response = self.view1.as_view(actions={"get": "list"})(request) 310 | response.render() 311 | content = json.loads(response.content.decode()) 312 | 313 | self.assertEqual(content["previous"], "http://testserver/") 314 | self.assertEqual(content["next"], "http://testserver/?page=3") 315 | -------------------------------------------------------------------------------- /ez_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap distribute installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from distribute_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | 17 | import fnmatch 18 | import os 19 | import sys 20 | import tarfile 21 | import tempfile 22 | import time 23 | from distutils import log 24 | 25 | try: 26 | from site import USER_SITE 27 | except ImportError: 28 | USER_SITE = None 29 | 30 | try: 31 | import subprocess 32 | 33 | def _python_cmd(*args): 34 | args = (sys.executable,) + args 35 | return subprocess.call(args) == 0 36 | 37 | except ImportError: 38 | # will be used for python 2.3 39 | def _python_cmd(*args): 40 | args = (sys.executable,) + args 41 | # quoting arguments if windows 42 | if sys.platform == "win32": 43 | 44 | def quote(arg): 45 | if " " in arg: 46 | return '"%s"' % arg 47 | return arg 48 | 49 | args = [quote(arg) for arg in args] 50 | return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 51 | 52 | 53 | DEFAULT_VERSION = "0.6.14" 54 | DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" 55 | SETUPTOOLS_FAKED_VERSION = "0.6c11" 56 | 57 | SETUPTOOLS_PKG_INFO = ( 58 | """\ 59 | Metadata-Version: 1.0 60 | Name: setuptools 61 | Version: %s 62 | Summary: xxxx 63 | Home-page: xxx 64 | Author: xxx 65 | Author-email: xxx 66 | License: xxx 67 | Description: xxx 68 | """ 69 | % SETUPTOOLS_FAKED_VERSION 70 | ) 71 | 72 | 73 | def _install(tarball): 74 | # extracting the tarball 75 | tmpdir = tempfile.mkdtemp() 76 | log.warn("Extracting in %s", tmpdir) 77 | old_wd = os.getcwd() 78 | try: 79 | os.chdir(tmpdir) 80 | tar = tarfile.open(tarball) 81 | _extractall(tar) 82 | tar.close() 83 | 84 | # going in the directory 85 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 86 | os.chdir(subdir) 87 | log.warn("Now working in %s", subdir) 88 | 89 | # installing 90 | log.warn("Installing Distribute") 91 | if not _python_cmd("setup.py", "install"): 92 | log.warn("Something went wrong during the installation.") 93 | log.warn("See the error message above.") 94 | finally: 95 | os.chdir(old_wd) 96 | 97 | 98 | def _build_egg(egg, tarball, to_dir): 99 | # extracting the tarball 100 | tmpdir = tempfile.mkdtemp() 101 | log.warn("Extracting in %s", tmpdir) 102 | old_wd = os.getcwd() 103 | try: 104 | os.chdir(tmpdir) 105 | tar = tarfile.open(tarball) 106 | _extractall(tar) 107 | tar.close() 108 | 109 | # going in the directory 110 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 111 | os.chdir(subdir) 112 | log.warn("Now working in %s", subdir) 113 | 114 | # building an egg 115 | log.warn("Building a Distribute egg in %s", to_dir) 116 | _python_cmd("setup.py", "-q", "bdist_egg", "--dist-dir", to_dir) 117 | 118 | finally: 119 | os.chdir(old_wd) 120 | # returning the result 121 | log.warn(egg) 122 | if not os.path.exists(egg): 123 | raise OSError("Could not build the egg.") 124 | 125 | 126 | def _do_download(version, download_base, to_dir, download_delay): 127 | egg = os.path.join(to_dir, "distribute-%s-py%d.%d.egg" % (version, sys.version_info[0], sys.version_info[1])) 128 | if not os.path.exists(egg): 129 | tarball = download_setuptools(version, download_base, to_dir, download_delay) 130 | _build_egg(egg, tarball, to_dir) 131 | sys.path.insert(0, egg) 132 | import setuptools 133 | 134 | setuptools.bootstrap_install_from = egg 135 | 136 | 137 | def use_setuptools( 138 | version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, download_delay=15, no_fake=True 139 | ): 140 | # making sure we use the absolute path 141 | to_dir = os.path.abspath(to_dir) 142 | was_imported = "pkg_resources" in sys.modules or "setuptools" in sys.modules 143 | try: 144 | try: 145 | import pkg_resources 146 | 147 | if not hasattr(pkg_resources, "_distribute"): 148 | if not no_fake: 149 | _fake_setuptools() 150 | raise ImportError 151 | except ImportError: 152 | return _do_download(version, download_base, to_dir, download_delay) 153 | try: 154 | pkg_resources.require("distribute>=" + version) 155 | return 156 | except pkg_resources.VersionConflict: 157 | e = sys.exc_info()[1] 158 | if was_imported: 159 | sys.stderr.write( 160 | "The required version of distribute (>=%s) is not available,\n" 161 | "and can't be installed while this script is running. Please\n" 162 | "install a more recent version first, using\n" 163 | "'easy_install -U distribute'." 164 | "\n\n(Currently using %r)\n" % (version, e.args[0]) 165 | ) 166 | sys.exit(2) 167 | else: 168 | del pkg_resources, sys.modules["pkg_resources"] # reload ok 169 | return _do_download(version, download_base, to_dir, download_delay) 170 | except pkg_resources.DistributionNotFound: 171 | return _do_download(version, download_base, to_dir, download_delay) 172 | finally: 173 | if not no_fake: 174 | _create_fake_setuptools_pkg_info(to_dir) 175 | 176 | 177 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir, delay=15): 178 | """Download distribute from a specified location and return its filename 179 | 180 | `version` should be a valid distribute version number that is available 181 | as an egg for download under the `download_base` URL (which should end 182 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 183 | `delay` is the number of seconds to pause before an actual download 184 | attempt. 185 | """ 186 | # making sure we use the absolute path 187 | to_dir = os.path.abspath(to_dir) 188 | try: 189 | from urllib.request import urlopen 190 | except ImportError: 191 | from urllib2 import urlopen 192 | tgz_name = "distribute-%s.tar.gz" % version 193 | url = download_base + tgz_name 194 | saveto = os.path.join(to_dir, tgz_name) 195 | src = dst = None 196 | if not os.path.exists(saveto): # Avoid repeated downloads 197 | try: 198 | log.warn("Downloading %s", url) 199 | src = urlopen(url) 200 | # Read/write all in one block, so we don't create a corrupt file 201 | # if the download is interrupted. 202 | data = src.read() 203 | dst = open(saveto, "wb") 204 | dst.write(data) 205 | finally: 206 | if src: 207 | src.close() 208 | if dst: 209 | dst.close() 210 | return os.path.realpath(saveto) 211 | 212 | 213 | def _no_sandbox(function): 214 | def __no_sandbox(*args, **kw): 215 | try: 216 | from setuptools.sandbox import DirectorySandbox 217 | 218 | if not hasattr(DirectorySandbox, "_old"): 219 | 220 | def violation(*args): 221 | pass 222 | 223 | DirectorySandbox._old = DirectorySandbox._violation 224 | DirectorySandbox._violation = violation 225 | patched = True 226 | else: 227 | patched = False 228 | except ImportError: 229 | patched = False 230 | 231 | try: 232 | return function(*args, **kw) 233 | finally: 234 | if patched: 235 | DirectorySandbox._violation = DirectorySandbox._old 236 | del DirectorySandbox._old 237 | 238 | return __no_sandbox 239 | 240 | 241 | def _patch_file(path, content): 242 | """Will backup the file then patch it""" 243 | existing_content = open(path).read() 244 | if existing_content == content: 245 | # already patched 246 | log.warn("Already patched.") 247 | return False 248 | log.warn("Patching...") 249 | _rename_path(path) 250 | f = open(path, "w") 251 | try: 252 | f.write(content) 253 | finally: 254 | f.close() 255 | return True 256 | 257 | 258 | _patch_file = _no_sandbox(_patch_file) 259 | 260 | 261 | def _same_content(path, content): 262 | return open(path).read() == content 263 | 264 | 265 | def _rename_path(path): 266 | new_name = path + ".OLD.%s" % time.time() 267 | log.warn("Renaming %s into %s", path, new_name) 268 | os.rename(path, new_name) 269 | return new_name 270 | 271 | 272 | def _remove_flat_installation(placeholder): 273 | if not os.path.isdir(placeholder): 274 | log.warn("Unkown installation at %s", placeholder) 275 | return False 276 | found = False 277 | for file in os.listdir(placeholder): 278 | if fnmatch.fnmatch(file, "setuptools*.egg-info"): 279 | found = True 280 | break 281 | if not found: 282 | log.warn("Could not locate setuptools*.egg-info") 283 | return 284 | 285 | log.warn("Removing elements out of the way...") 286 | pkg_info = os.path.join(placeholder, file) 287 | if os.path.isdir(pkg_info): 288 | patched = _patch_egg_dir(pkg_info) 289 | else: 290 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 291 | 292 | if not patched: 293 | log.warn("%s already patched.", pkg_info) 294 | return False 295 | # now let's move the files out of the way 296 | for element in ("setuptools", "pkg_resources.py", "site.py"): 297 | element = os.path.join(placeholder, element) 298 | if os.path.exists(element): 299 | _rename_path(element) 300 | else: 301 | log.warn("Could not find the %s element of the Setuptools distribution", element) 302 | return True 303 | 304 | 305 | _remove_flat_installation = _no_sandbox(_remove_flat_installation) 306 | 307 | 308 | def _after_install(dist): 309 | log.warn("After install bootstrap.") 310 | placeholder = dist.get_command_obj("install").install_purelib 311 | _create_fake_setuptools_pkg_info(placeholder) 312 | 313 | 314 | def _create_fake_setuptools_pkg_info(placeholder): 315 | if not placeholder or not os.path.exists(placeholder): 316 | log.warn("Could not find the install location") 317 | return 318 | pyver = f"{sys.version_info[0]}.{sys.version_info[1]}" 319 | setuptools_file = f"setuptools-{SETUPTOOLS_FAKED_VERSION}-py{pyver}.egg-info" 320 | pkg_info = os.path.join(placeholder, setuptools_file) 321 | if os.path.exists(pkg_info): 322 | log.warn("%s already exists", pkg_info) 323 | return 324 | 325 | log.warn("Creating %s", pkg_info) 326 | f = open(pkg_info, "w") 327 | try: 328 | f.write(SETUPTOOLS_PKG_INFO) 329 | finally: 330 | f.close() 331 | 332 | pth_file = os.path.join(placeholder, "setuptools.pth") 333 | log.warn("Creating %s", pth_file) 334 | f = open(pth_file, "w") 335 | try: 336 | f.write(os.path.join(os.curdir, setuptools_file)) 337 | finally: 338 | f.close() 339 | 340 | 341 | _create_fake_setuptools_pkg_info = _no_sandbox(_create_fake_setuptools_pkg_info) 342 | 343 | 344 | def _patch_egg_dir(path): 345 | # let's check if it's already patched 346 | pkg_info = os.path.join(path, "EGG-INFO", "PKG-INFO") 347 | if os.path.exists(pkg_info): 348 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 349 | log.warn("%s already patched.", pkg_info) 350 | return False 351 | _rename_path(path) 352 | os.mkdir(path) 353 | os.mkdir(os.path.join(path, "EGG-INFO")) 354 | pkg_info = os.path.join(path, "EGG-INFO", "PKG-INFO") 355 | f = open(pkg_info, "w") 356 | try: 357 | f.write(SETUPTOOLS_PKG_INFO) 358 | finally: 359 | f.close() 360 | return True 361 | 362 | 363 | _patch_egg_dir = _no_sandbox(_patch_egg_dir) 364 | 365 | 366 | def _before_install(): 367 | log.warn("Before install bootstrap.") 368 | _fake_setuptools() 369 | 370 | 371 | def _under_prefix(location): 372 | if "install" not in sys.argv: 373 | return True 374 | args = sys.argv[sys.argv.index("install") + 1 :] 375 | for index, arg in enumerate(args): 376 | for option in ("--root", "--prefix"): 377 | if arg.startswith("%s=" % option): 378 | top_dir = arg.split("root=")[-1] 379 | return location.startswith(top_dir) 380 | elif arg == option: 381 | if len(args) > index: 382 | top_dir = args[index + 1] 383 | return location.startswith(top_dir) 384 | if arg == "--user" and USER_SITE is not None: 385 | return location.startswith(USER_SITE) 386 | return True 387 | 388 | 389 | def _fake_setuptools(): 390 | log.warn("Scanning installed packages") 391 | try: 392 | import pkg_resources 393 | except ImportError: 394 | # we're cool 395 | log.warn("Setuptools or Distribute does not seem to be installed.") 396 | return 397 | ws = pkg_resources.working_set 398 | try: 399 | setuptools_dist = ws.find(pkg_resources.Requirement.parse("setuptools", replacement=False)) 400 | except TypeError: 401 | # old distribute API 402 | setuptools_dist = ws.find(pkg_resources.Requirement.parse("setuptools")) 403 | 404 | if setuptools_dist is None: 405 | log.warn("No setuptools distribution found") 406 | return 407 | # detecting if it was already faked 408 | setuptools_location = setuptools_dist.location 409 | log.warn("Setuptools installation detected at %s", setuptools_location) 410 | 411 | # if --root or --preix was provided, and if 412 | # setuptools is not located in them, we don't patch it 413 | if not _under_prefix(setuptools_location): 414 | log.warn("Not patching, --root or --prefix is installing Distribute in another location") 415 | return 416 | 417 | # let's see if its an egg 418 | if not setuptools_location.endswith(".egg"): 419 | log.warn("Non-egg installation") 420 | res = _remove_flat_installation(setuptools_location) 421 | if not res: 422 | return 423 | else: 424 | log.warn("Egg installation") 425 | pkg_info = os.path.join(setuptools_location, "EGG-INFO", "PKG-INFO") 426 | if os.path.exists(pkg_info) and _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 427 | log.warn("Already patched.") 428 | return 429 | log.warn("Patching...") 430 | # let's create a fake egg replacing setuptools one 431 | res = _patch_egg_dir(setuptools_location) 432 | if not res: 433 | return 434 | log.warn("Patched done.") 435 | _relaunch() 436 | 437 | 438 | def _relaunch(): 439 | log.warn("Relaunching...") 440 | # we have to relaunch the process 441 | # pip marker to avoid a relaunch bug 442 | if sys.argv[:3] == ["-c", "install", "--single-version-externally-managed"]: 443 | sys.argv[0] = "setup.py" 444 | args = [sys.executable] + sys.argv 445 | sys.exit(subprocess.call(args)) 446 | 447 | 448 | def _extractall(self, path=".", members=None): 449 | """Extract all members from the archive to the current working 450 | directory and set owner, modification time and permissions on 451 | directories afterwards. `path' specifies a different directory 452 | to extract to. `members' is optional and must be a subset of the 453 | list returned by getmembers(). 454 | """ 455 | import copy 456 | import operator 457 | from tarfile import ExtractError 458 | 459 | directories = [] 460 | 461 | if members is None: 462 | members = self 463 | 464 | for tarinfo in members: 465 | if tarinfo.isdir(): 466 | # Extract directories with a safe mode. 467 | directories.append(tarinfo) 468 | tarinfo = copy.copy(tarinfo) 469 | tarinfo.mode = 448 # decimal for oct 0700 470 | self.extract(tarinfo, path) 471 | 472 | # Reverse sort directories. 473 | directories.sort(key=operator.attrgetter("name"), reverse=True) 474 | 475 | # Set correct owner, mtime and filemode on directories. 476 | for tarinfo in directories: 477 | dirpath = os.path.join(path, tarinfo.name) 478 | try: 479 | self.chown(tarinfo, dirpath) 480 | self.utime(tarinfo, dirpath) 481 | self.chmod(tarinfo, dirpath) 482 | except ExtractError: 483 | e = sys.exc_info()[1] 484 | if self.errorlevel > 1: 485 | raise 486 | else: 487 | self._dbg(1, "tarfile: %s" % e) 488 | 489 | 490 | def main(argv, version=DEFAULT_VERSION): 491 | """Install or upgrade setuptools and EasyInstall""" 492 | tarball = download_setuptools() 493 | _install(tarball) 494 | 495 | 496 | if __name__ == "__main__": 497 | main(sys.argv[1:]) 498 | -------------------------------------------------------------------------------- /drf_haystack/serializers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from datetime import datetime 3 | from itertools import chain 4 | 5 | try: 6 | from collections import OrderedDict 7 | except ImportError: 8 | from django.utils.datastructures import SortedDict as OrderedDict 9 | 10 | from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured 11 | from haystack import fields as haystack_fields 12 | from haystack.query import EmptySearchQuerySet 13 | from haystack.utils.highlighting import Highlighter 14 | from rest_framework import serializers 15 | from rest_framework.fields import empty 16 | from rest_framework.utils.field_mapping import ClassLookupDict, get_field_kwargs 17 | 18 | from drf_haystack.fields import ( 19 | FacetDictField, 20 | FacetListField, 21 | HaystackBooleanField, 22 | HaystackCharField, 23 | HaystackDateField, 24 | HaystackDateTimeField, 25 | HaystackDecimalField, 26 | HaystackFloatField, 27 | HaystackIntegerField, 28 | HaystackMultiValueField, 29 | ) 30 | 31 | 32 | class Meta(type): 33 | """ 34 | Template for the HaystackSerializerMeta.Meta class. 35 | """ 36 | 37 | fields = tuple() 38 | exclude = tuple() 39 | search_fields = tuple() 40 | index_classes = tuple() 41 | serializers = tuple() 42 | ignore_fields = tuple() 43 | field_aliases = {} 44 | field_options = {} 45 | index_aliases = {} 46 | 47 | def __new__(mcs, name, bases, attrs): 48 | cls = super().__new__(mcs, str(name), bases, attrs) 49 | 50 | if cls.fields and cls.exclude: 51 | raise ImproperlyConfigured("%s cannot define both 'fields' and 'exclude'." % name) 52 | 53 | return cls 54 | 55 | def __setattr__(cls, key, value): 56 | raise AttributeError("Meta class is immutable.") 57 | 58 | def __delattr__(cls, key, value): 59 | raise AttributeError("Meta class is immutable.") 60 | 61 | 62 | class HaystackSerializerMeta(serializers.SerializerMetaclass): 63 | """ 64 | Metaclass for the HaystackSerializer that ensures that all declared subclasses implemented a Meta. 65 | """ 66 | 67 | def __new__(mcs, name, bases, attrs): 68 | attrs.setdefault("_abstract", False) 69 | 70 | cls = super().__new__(mcs, str(name), bases, attrs) 71 | 72 | if getattr(cls, "Meta", None): 73 | cls.Meta = Meta("Meta", (Meta,), dict(cls.Meta.__dict__)) 74 | 75 | elif not cls._abstract: 76 | raise ImproperlyConfigured("%s must implement a Meta class or have the property _abstract" % name) 77 | 78 | return cls 79 | 80 | 81 | class HaystackSerializer(serializers.Serializer, metaclass=HaystackSerializerMeta): 82 | """ 83 | A `HaystackSerializer` which populates fields based on 84 | which models that are available in the SearchQueryset. 85 | """ 86 | 87 | _abstract = True 88 | 89 | _field_mapping = ClassLookupDict({ 90 | haystack_fields.BooleanField: HaystackBooleanField, 91 | haystack_fields.CharField: HaystackCharField, 92 | haystack_fields.DateField: HaystackDateField, 93 | haystack_fields.DateTimeField: HaystackDateTimeField, 94 | haystack_fields.DecimalField: HaystackDecimalField, 95 | haystack_fields.EdgeNgramField: HaystackCharField, 96 | haystack_fields.FacetBooleanField: HaystackBooleanField, 97 | haystack_fields.FacetCharField: HaystackCharField, 98 | haystack_fields.FacetDateField: HaystackDateField, 99 | haystack_fields.FacetDateTimeField: HaystackDateTimeField, 100 | haystack_fields.FacetDecimalField: HaystackDecimalField, 101 | haystack_fields.FacetFloatField: HaystackFloatField, 102 | haystack_fields.FacetIntegerField: HaystackIntegerField, 103 | haystack_fields.FacetMultiValueField: HaystackMultiValueField, 104 | haystack_fields.FloatField: HaystackFloatField, 105 | haystack_fields.IntegerField: HaystackIntegerField, 106 | haystack_fields.LocationField: HaystackCharField, 107 | haystack_fields.MultiValueField: HaystackMultiValueField, 108 | haystack_fields.NgramField: HaystackCharField, 109 | }) 110 | 111 | def __init__(self, instance=None, data=empty, **kwargs): 112 | super().__init__(instance, data, **kwargs) 113 | 114 | if not self.Meta.index_classes and not self.Meta.serializers: 115 | raise ImproperlyConfigured( 116 | "You must set either the 'index_classes' or 'serializers' attribute on the serializer Meta class." 117 | ) 118 | 119 | if not self.instance: 120 | self.instance = EmptySearchQuerySet() 121 | 122 | @staticmethod 123 | def _get_default_field_kwargs(model, field): 124 | """ 125 | Get the required attributes from the model field in order 126 | to instantiate a REST Framework serializer field. 127 | """ 128 | kwargs = {} 129 | try: 130 | field_name = field.model_attr or field.index_fieldname 131 | model_field = model._meta.get_field(field_name) 132 | kwargs.update(get_field_kwargs(field_name, model_field)) 133 | 134 | # Remove stuff we don't care about! 135 | delete_attrs = [ 136 | "allow_blank", 137 | "choices", 138 | "model_field", 139 | "allow_unicode", 140 | ] 141 | for attr in delete_attrs: 142 | if attr in kwargs: 143 | del kwargs[attr] 144 | except FieldDoesNotExist: 145 | pass 146 | 147 | return kwargs 148 | 149 | def _get_index_field(self, field_name): 150 | """ 151 | Returns the correct index field. 152 | """ 153 | return field_name 154 | 155 | def _get_index_class_name(self, index_cls): 156 | """ 157 | Converts in index model class to a name suitable for use as a field name prefix. A user 158 | may optionally specify custom aliases via an 'index_aliases' attribute on the Meta class 159 | """ 160 | cls_name = index_cls.__name__ 161 | aliases = self.Meta.index_aliases 162 | return aliases.get(cls_name, cls_name.split(".")[-1]) 163 | 164 | def get_fields(self): 165 | """ 166 | Get the required fields for serializing the result. 167 | """ 168 | 169 | fields = self.Meta.fields 170 | exclude = self.Meta.exclude 171 | ignore_fields = self.Meta.ignore_fields 172 | indices = self.Meta.index_classes 173 | 174 | declared_fields = copy.deepcopy(self._declared_fields) 175 | prefix_field_names = len(indices) > 1 176 | field_mapping = OrderedDict() 177 | 178 | # overlapping fields on multiple indices is supported by internally prefixing the field 179 | # names with the index class to which they belong or, optionally, a user-provided alias 180 | # for the index. 181 | for index_cls in self.Meta.index_classes: 182 | prefix = "" 183 | if prefix_field_names: 184 | prefix = "_%s__" % self._get_index_class_name(index_cls) 185 | for field_name, field_type in index_cls.fields.items(): 186 | orig_name = field_name 187 | field_name = f"{prefix}{field_name}" 188 | 189 | # Don't use this field if it is in `ignore_fields` 190 | if orig_name in ignore_fields or field_name in ignore_fields: 191 | continue 192 | # When fields to include are decided by `exclude` 193 | if exclude: 194 | if orig_name in exclude or field_name in exclude: 195 | continue 196 | # When fields to include are decided by `fields` 197 | if fields: 198 | if orig_name not in fields and field_name not in fields: 199 | continue 200 | 201 | # Look up the field attributes on the current index model, 202 | # in order to correctly instantiate the serializer field. 203 | model = index_cls().get_model() 204 | kwargs = self._get_default_field_kwargs(model, field_type) 205 | kwargs["prefix_field_names"] = prefix_field_names 206 | field_mapping[field_name] = self._field_mapping[field_type](**kwargs) 207 | 208 | # Add any explicitly declared fields. They *will* override any index fields 209 | # in case of naming collision!. 210 | if declared_fields: 211 | for field_name in declared_fields: 212 | field_mapping[field_name] = declared_fields[field_name] 213 | return field_mapping 214 | 215 | def to_representation(self, instance): 216 | """ 217 | If we have a serializer mapping, use that. Otherwise, use standard serializer behavior 218 | Since we might be dealing with multiple indexes, some fields might 219 | not be valid for all results. Do not render the fields which don't belong 220 | to the search result. 221 | """ 222 | if self.Meta.serializers: 223 | ret = self.multi_serializer_representation(instance) 224 | else: 225 | ret = super().to_representation(instance) 226 | prefix_field_names = len(getattr(self.Meta, "index_classes")) > 1 227 | current_index = self._get_index_class_name(type(instance.searchindex)) 228 | for field in self.fields.keys(): 229 | orig_field = field 230 | if prefix_field_names: 231 | parts = field.split("__") 232 | if len(parts) > 1: 233 | index = parts[0][1:] # trim the preceding '_' 234 | field = parts[1] 235 | if index == current_index: 236 | ret[field] = ret[orig_field] 237 | del ret[orig_field] 238 | elif field not in chain(instance.searchindex.fields.keys(), self._declared_fields.keys()): 239 | del ret[orig_field] 240 | 241 | # include the highlighted field in either case 242 | if getattr(instance, "highlighted", None): 243 | ret["highlighted"] = instance.highlighted[0] 244 | return ret 245 | 246 | def multi_serializer_representation(self, instance): 247 | serializers = self.Meta.serializers 248 | index = instance.searchindex 249 | serializer_class = serializers.get(type(index), None) 250 | if not serializer_class: 251 | raise ImproperlyConfigured("Could not find serializer for %s in mapping" % index) 252 | return serializer_class(context=self._context).to_representation(instance) 253 | 254 | 255 | class FacetFieldSerializer(serializers.Serializer): 256 | """ 257 | Responsible for serializing a faceted result. 258 | """ 259 | 260 | text = serializers.SerializerMethodField() 261 | count = serializers.SerializerMethodField() 262 | narrow_url = serializers.SerializerMethodField() 263 | 264 | def __init__(self, *args, **kwargs): 265 | self._parent_field = None 266 | super().__init__(*args, **kwargs) 267 | 268 | @property 269 | def parent_field(self): 270 | return self._parent_field 271 | 272 | @parent_field.setter 273 | def parent_field(self, value): 274 | self._parent_field = value 275 | 276 | def get_paginate_by_param(self): 277 | """ 278 | Returns the ``paginate_by_param`` for the (root) view paginator class. 279 | This is needed in order to remove the query parameter from faceted 280 | narrow urls. 281 | 282 | If using a custom pagination class, this class attribute needs to 283 | be set manually. 284 | """ 285 | if hasattr(self.root, "paginate_by_param") and self.root.paginate_by_param: 286 | return self.root.paginate_by_param 287 | 288 | pagination_class = self.context["view"].pagination_class 289 | if not pagination_class: 290 | return None 291 | 292 | # PageNumberPagination 293 | if hasattr(pagination_class, "page_query_param"): 294 | return pagination_class.page_query_param 295 | 296 | # LimitOffsetPagination 297 | elif hasattr(pagination_class, "offset_query_param"): 298 | return pagination_class.offset_query_param 299 | 300 | # CursorPagination 301 | elif hasattr(pagination_class, "cursor_query_param"): 302 | return pagination_class.cursor_query_param 303 | 304 | else: 305 | raise AttributeError( 306 | "%(root_cls)s is missing a `paginate_by_param` attribute. " 307 | "Define a %(root_cls)s.paginate_by_param or override " 308 | "%(cls)s.get_paginate_by_param()." 309 | % {"root_cls": self.root.__class__.__name__, "cls": self.__class__.__name__} 310 | ) 311 | 312 | def get_text(self, instance): 313 | """ 314 | Haystack facets are returned as a two-tuple (value, count). 315 | The text field should contain the faceted value. 316 | """ 317 | instance = instance[0] 318 | if isinstance(instance, (str, (str,))): 319 | return serializers.CharField(read_only=True).to_representation(instance) 320 | elif isinstance(instance, datetime): 321 | return serializers.DateTimeField(read_only=True).to_representation(instance) 322 | return instance 323 | 324 | def get_count(self, instance): 325 | """ 326 | Haystack facets are returned as a two-tuple (value, count). 327 | The count field should contain the faceted count. 328 | """ 329 | instance = instance[1] 330 | return serializers.IntegerField(read_only=True).to_representation(instance) 331 | 332 | def get_narrow_url(self, instance): 333 | """ 334 | Return a link suitable for narrowing on the current item. 335 | """ 336 | text = instance[0] 337 | request = self.context["request"] 338 | query_params = request.GET.copy() 339 | 340 | # Never keep the page query parameter in narrowing urls. 341 | # It will raise a NotFound exception when trying to paginate a narrowed queryset. 342 | page_query_param = self.get_paginate_by_param() 343 | if page_query_param and page_query_param in query_params: 344 | del query_params[page_query_param] 345 | 346 | selected_facets = set(query_params.pop(self.root.facet_query_params_text, [])) 347 | selected_facets.add(f"{self.parent_field}_exact:{text}") 348 | query_params.setlist(self.root.facet_query_params_text, sorted(selected_facets)) 349 | 350 | path = f"{request.path_info}?{query_params.urlencode()}" 351 | url = request.build_absolute_uri(path) 352 | return serializers.Hyperlink(url, "narrow-url") 353 | 354 | def to_representation(self, field, instance): 355 | """ 356 | Set the ``parent_field`` property equal to the current field on the serializer class, 357 | so that each field can query it to see what kind of attribute they are processing. 358 | """ 359 | self.parent_field = field 360 | return super().to_representation(instance) 361 | 362 | 363 | class HaystackFacetSerializer(serializers.Serializer, metaclass=HaystackSerializerMeta): 364 | """ 365 | The ``HaystackFacetSerializer`` is used to serialize the ``facet_counts()`` 366 | dictionary results on a ``SearchQuerySet`` instance. 367 | """ 368 | 369 | _abstract = True 370 | serialize_objects = False 371 | paginate_by_param = None 372 | facet_dict_field_class = FacetDictField 373 | facet_list_field_class = FacetListField 374 | facet_field_serializer_class = FacetFieldSerializer 375 | 376 | def get_fields(self): 377 | """ 378 | This returns a dictionary containing the top most fields, 379 | ``dates``, ``fields`` and ``queries``. 380 | """ 381 | field_mapping = OrderedDict() 382 | for field, data in self.instance.items(): 383 | field_mapping.update({ 384 | field: self.facet_dict_field_class( 385 | child=self.facet_list_field_class(child=self.facet_field_serializer_class(data)), required=False 386 | ) 387 | }) 388 | 389 | if self.serialize_objects is True: 390 | field_mapping["objects"] = serializers.SerializerMethodField() 391 | 392 | return field_mapping 393 | 394 | def get_objects(self, instance): 395 | """ 396 | Return a list of objects matching the faceted result. 397 | """ 398 | view = self.context["view"] 399 | queryset = self.context["objects"] 400 | 401 | page = view.paginate_queryset(queryset) 402 | if page is not None: 403 | serializer = view.get_facet_objects_serializer(page, many=True) 404 | return OrderedDict([ 405 | ("count", self.get_count(queryset)), 406 | ("next", view.paginator.get_next_link()), 407 | ("previous", view.paginator.get_previous_link()), 408 | ("results", serializer.data), 409 | ]) 410 | 411 | serializer = view.get_serializer(queryset, many=True) 412 | return serializer.data 413 | 414 | def get_count(self, queryset): 415 | """ 416 | Determine an object count, supporting either querysets or regular lists. 417 | """ 418 | try: 419 | return queryset.count() 420 | except (AttributeError, TypeError): 421 | return len(queryset) 422 | 423 | @property 424 | def facet_query_params_text(self): 425 | return self.context["facet_query_params_text"] 426 | 427 | 428 | class HaystackSerializerMixin: 429 | """ 430 | This mixin can be added to a serializer to use the actual object as the data source for serialization rather 431 | than the data stored in the search index fields. This makes it easy to return data from search results in 432 | the same format as elsewhere in your API and reuse your existing serializers 433 | """ 434 | 435 | def to_representation(self, instance): 436 | obj = instance.object 437 | return super().to_representation(obj) 438 | 439 | 440 | class HighlighterMixin: 441 | """ 442 | This mixin adds support for ``highlighting`` (the pure python, portable 443 | version, not SearchQuerySet().highlight()). See Haystack docs 444 | for more info). 445 | """ 446 | 447 | highlighter_class = Highlighter 448 | highlighter_css_class = "highlighted" 449 | highlighter_html_tag = "span" 450 | highlighter_max_length = 200 451 | highlighter_field = None 452 | 453 | def get_highlighter(self): 454 | if not self.highlighter_class: 455 | raise ImproperlyConfigured( 456 | "%(cls)s is missing a highlighter_class. Define %(cls)s.highlighter_class, " 457 | "or override %(cls)s.get_highlighter()." % {"cls": self.__class__.__name__} 458 | ) 459 | return self.highlighter_class 460 | 461 | @staticmethod 462 | def get_document_field(instance): 463 | """ 464 | Returns which field the search index has marked as it's 465 | `document=True` field. 466 | """ 467 | for name, field in instance.searchindex.fields.items(): 468 | if field.document is True: 469 | return name 470 | 471 | def get_terms(self, data): 472 | """ 473 | Returns the terms to be highlighted 474 | """ 475 | terms = " ".join(self.context["request"].GET.values()) 476 | return terms 477 | 478 | def to_representation(self, instance): 479 | ret = super().to_representation(instance) 480 | terms = self.get_terms(ret) 481 | if terms: 482 | highlighter = self.get_highlighter()( 483 | terms, 484 | **{ 485 | "html_tag": self.highlighter_html_tag, 486 | "css_class": self.highlighter_css_class, 487 | "max_length": self.highlighter_max_length, 488 | }, 489 | ) 490 | document_field = self.get_document_field(instance) 491 | if highlighter and document_field: 492 | # Handle case where this data is None, but highlight expects it to be a string 493 | data_to_highlight = getattr(instance, self.highlighter_field or document_field) or "" 494 | ret["highlighted"] = highlighter.highlight(data_to_highlight) 495 | return ret 496 | --------------------------------------------------------------------------------