├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── api │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── requirements.txt ├── requirements.txt ├── rest_fuzzysearch ├── __init__.py ├── mixin.py ├── search.py └── sort.py └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = rest_fuzzysearch 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /.pydevproject 3 | 4 | /.idea 5 | 6 | /example/db.sqlite3 7 | 8 | *.pyc 9 | 10 | /build 11 | /dist 12 | /*.egg-info 13 | 14 | .coverage 15 | htmlcov 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.6" 5 | 6 | addons: 7 | postgresql: "9.6" 8 | 9 | services: 10 | - postgresql 11 | 12 | install: 13 | - pip install . 14 | - pip install -r example/requirements.txt 15 | - pip install coverage 16 | 17 | cache: pip 18 | 19 | before_script: 20 | - psql -U postgres -c 'create database rest_fuzzysearch;' 21 | 22 | script: 23 | - coverage run example/manage.py test example 24 | - coverage report 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.5.1] - 2017-09-02 10 | ### Added 11 | - Allow the filtering of search results by non-zero rank (min_rank=None). 12 | 13 | ### Changed 14 | - Filter search results by non-zero rank (min_rank=None) by default. 15 | 16 | ## 0.5.0 - 2017-09-02 17 | ### Added 18 | - Initial import from [boomerang] 19 | 20 | [Unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/0.5.1...HEAD 21 | [0.5.1]: https://github.com/vsemionov/django-rest-fuzzysearch/compare/0.5.0...0.5.1 22 | 23 | [boomerang]: https://github.com/vsemionov/boomerang 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Victor Semionov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django REST Fuzzy Search 2 | ======================== 3 | 4 | Fuzzy Search for Django REST Framework 5 | -------------------------------------- 6 | 7 | [![Build Status](https://travis-ci.org/vsemionov/django-rest-fuzzysearch.svg?branch=master)](https://travis-ci.org/vsemionov/django-rest-fuzzysearch) 8 | 9 | 10 | ### Fuzzy Search 11 | 12 | This package provides REST APIs with support for fuzzy (approximate) full-text searching. This allows searching for results with unknown exact contents, as well as searches with spelling mistakes. The returned results are ranked and may be ordered by similarity. 13 | 14 | This package requires *PostgreSQL* and uses its trigram extension (*pg_trgm*). It provides a queryset filter and optional support viewset mixins. 15 | 16 | 17 | ### Requirements 18 | 19 | * Python 3 (tested with 3.6) 20 | * PostgreSQL (tested with 9.6) 21 | * Django (>=1.11, tested with 1.11) 22 | * Django REST Framework (tested with 3.6) 23 | 24 | 25 | ### Basic Usage 26 | 27 | 1. Install the package: 28 | ``` 29 | pip install django-rest-fuzzysearch 30 | ``` 31 | 32 | 2. Add a database migration to enable the trigram extension: 33 | ``` 34 | from django.db import migrations 35 | from django.contrib.postgres import operations 36 | 37 | 38 | class Migration(migrations.Migration): 39 | 40 | dependencies = [ 41 | ('api', '0001_initial'), 42 | ] 43 | 44 | operations = [ 45 | operations.TrigramExtension(), 46 | ] 47 | ``` 48 | Note that running this migration would require superuser privileges of your database user. To avoid that, you could instead enable the extension manually: 49 | ``` 50 | psql -U postgres -c "create extension pg_trgm;" 51 | ``` 52 | 53 | 3. Add the fuzzy search filter to your viewset's filter backends: 54 | ``` 55 | filter_backends = (search.RankedFuzzySearchFilter,) 56 | ``` 57 | 58 | 4. Configure the viewset's list of model fields that will be included in the search: 59 | ``` 60 | search_fields = ('username', 'first_name', 'last_name', 'email') 61 | ``` 62 | 63 | 5. Configure the minimum rank of the returned results (optional): 64 | ``` 65 | min_rank = 0.25 66 | ``` 67 | The rank of results varies between 0 and 1. Numerical values specify inclusive boundaries (rank >= min_rank). Setting to None produces results with any non-zero rank (rank > 0). The default is None. 68 | 69 | #### Performance 70 | 71 | Fuzzy searching is a relatively expensive operation. If you expect it to be applied to very large sets of records, it would be beneficial to create a functional index, which matches your search fields. See the [PostgreSQL documentation][fts-index-docs] for details. 72 | 73 | [fts-index-docs]: https://www.postgresql.org/docs/current/static/textsearch-tables.html#TEXTSEARCH-TABLES-INDEX "Creating Indexes" 74 | 75 | 76 | ### Ordering Results by Similarity 77 | 78 | Fuzzy search is most useful when the results are ordered by similarity. To achieve this, you can use REST Framework's ordering filter and configure it the following way: 79 | 1. Enable ordering by rank by adding the latter to the list of allowed fields: 80 | ``` 81 | ordering_fields = ('rank', ...) 82 | ``` 83 | 84 | 2. Configure the default ordering in the viewset: 85 | ``` 86 | ordering = ('-rank', ...) 87 | ``` 88 | If a search is not being performed (no terms were specified), all results will have a rank of 1. Therefore, ordering by rank is still possible, but does nothing. 89 | 90 | 91 | ### Support Classes 92 | 93 | #### Decorating Search Results 94 | 95 | To decorate the results with the used search terms, you can optionally inherit your viewset from *SearchableModelMixin*: 96 | ``` 97 | from rest_fuzzysearch import search 98 | class UserViewSet(search.SearchableModelMixin, 99 | ... 100 | viewsets.ModelViewSet): 101 | ``` 102 | The terms will be returned in the "terms" field of the response body. 103 | 104 | #### Advanced Ordering of Results 105 | 106 | This package also includes a custom ordering filter and mixin to provide the following features: 107 | * translation of ordering fields between the query parameter names and model field names 108 | - this is intended to be used when the internal and exposed names of your model's fields are different 109 | * optional consistent ordering - the model's primary key (which is unique) is appended to the "order by" clause for consistency of results with otherwise equal ordering fields 110 | * reject requests with invalid ordering fields with an http status 400 111 | * decoration of the results with the used ordering; the ordering is returned in the "sort" field of the response body 112 | 113 | Usage: 114 | 1. Add the custom ordering filter to your viewset's filter backends: 115 | ``` 116 | from rest_fuzzysearch import sort 117 | 118 | ... 119 | 120 | filter_backends = (..., sort.OrderingFilter) 121 | ``` 122 | 123 | 2. Inherit your viewset from *SortedModelMixin*: 124 | ``` 125 | class UserViewSet(sort.SortedModelMixin, 126 | ... 127 | viewsets.ModelViewSet): 128 | ``` 129 | 130 | 3. Configure the mixin with the following viewset attributes: 131 | ``` 132 | sort_field_map = {'id': 'ext_id'} # field translation map; keys are external field names, values are internal field names; default is no translation 133 | consistent_sort = True # whether the primary key should be appended to the ordering for consistency; default is True 134 | ``` 135 | 136 | 137 | ### Example Project 138 | 139 | For a working example project that integrates this package, see the */example* directory. To run it: 140 | ``` 141 | cd example 142 | pip install -r requirements.txt 143 | # configure the database connection in example/settings.py 144 | python manage.py migrate 145 | python manage.py createsuperuser 146 | python manage.py runserver 147 | ``` 148 | -------------------------------------------------------------------------------- /example/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsemionov/django-rest-fuzzysearch/7148ecd3850c5ac813f2065e9846192eaf95055b/example/api/__init__.py -------------------------------------------------------------------------------- /example/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /example/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = 'api' 6 | -------------------------------------------------------------------------------- /example/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.contrib.postgres import operations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | operations = [ 10 | operations.TrigramExtension(), 11 | ] 12 | -------------------------------------------------------------------------------- /example/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsemionov/django-rest-fuzzysearch/7148ecd3850c5ac813f2065e9846192eaf95055b/example/api/migrations/__init__.py -------------------------------------------------------------------------------- /example/api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /example/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import serializers 3 | 4 | 5 | class UserSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = User 8 | fields = ('username', 'date_joined', 'last_login', 'first_name', 'last_name', 'email') 9 | -------------------------------------------------------------------------------- /example/api/tests.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.contrib.auth.models import User 3 | from rest_framework.test import APITestCase 4 | from rest_framework import status 5 | 6 | 7 | class TestUsers(APITestCase): 8 | 9 | def test_list_success(self): 10 | user = User.objects.create(username='test', password='test') 11 | 12 | base_url = reverse('user-list') 13 | 14 | response = self.client.get(base_url + '?search=' + user.username) 15 | self.assertEqual(response.status_code, status.HTTP_200_OK) 16 | -------------------------------------------------------------------------------- /example/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from rest_framework import routers 3 | 4 | from . import views 5 | 6 | 7 | router = routers.DefaultRouter() 8 | router.include_format_suffixes = False 9 | router.register(r'users', views.UserViewSet) 10 | 11 | urlpatterns = [ 12 | url(r'^', include(router.urls)), 13 | ] 14 | -------------------------------------------------------------------------------- /example/api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from rest_framework import viewsets 3 | from rest_fuzzysearch import search, sort 4 | 5 | from .serializers import UserSerializer 6 | 7 | 8 | class UserViewSet(sort.SortedModelMixin, 9 | search.SearchableModelMixin, 10 | viewsets.ReadOnlyModelViewSet): 11 | lookup_field = 'username' 12 | lookup_value_regex = '[^/]+' 13 | queryset = User.objects.all() 14 | serializer_class = UserSerializer 15 | 16 | filter_backends = (search.RankedFuzzySearchFilter, sort.OrderingFilter) 17 | search_fields = ('username', 'first_name', 'last_name', 'email') 18 | ordering_fields = ('rank', 'username', 'date_joined', 'last_login', 'first_name', 'last_name', 'email') 19 | ordering = ('-rank', 'date_joined',) 20 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsemionov/django-rest-fuzzysearch/7148ecd3850c5ac813f2065e9846192eaf95055b/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'ubx$3%f+lpx0y!edcb6hv+kf*x6dd0xn@uuu$go9b!t1@b28ju' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'rest_framework', 41 | 'rest_fuzzysearch', 42 | 'api', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'example.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'example.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 82 | 'HOST': 'localhost', 83 | 'NAME': 'rest_fuzzysearch', 84 | 'USER': 'postgres', 85 | 'PASSWORD': '', 86 | } 87 | } 88 | 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | USE_I18N = True 117 | 118 | USE_L10N = True 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 125 | 126 | STATIC_URL = '/static/' 127 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | url(r'^api/', include('api.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example 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.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/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", "example.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.11 2 | djangorestframework 3 | django-rest-fuzzysearch 4 | psycopg2 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.11 2 | djangorestframework 3 | -------------------------------------------------------------------------------- /rest_fuzzysearch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsemionov/django-rest-fuzzysearch/7148ecd3850c5ac813f2065e9846192eaf95055b/rest_fuzzysearch/__init__.py -------------------------------------------------------------------------------- /rest_fuzzysearch/mixin.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | 4 | class ViewSetMixin(object): 5 | 6 | def decorated_list(self, cls, context, request, *args, **kwargs): 7 | response = super(cls, self).list(request, *args, **kwargs) 8 | 9 | if isinstance(response.data, dict): 10 | base_data = response.data 11 | else: 12 | base_data = OrderedDict(results=response.data) 13 | 14 | data = context.copy() 15 | data.update(base_data) 16 | 17 | response.data = data 18 | 19 | return response 20 | -------------------------------------------------------------------------------- /rest_fuzzysearch/search.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from collections import OrderedDict 3 | 4 | from django.db.models import Value, TextField, FloatField 5 | from django.db.models.functions import Concat 6 | from django.contrib.postgres.search import TrigramSimilarity 7 | from rest_framework import filters 8 | 9 | from .mixin import ViewSetMixin 10 | 11 | 12 | class SearchFilter(filters.SearchFilter): 13 | 14 | def get_search_terms(self, request): 15 | params = ' '.join(request.query_params.getlist(self.search_param)) 16 | return params.replace(',', ' ').split() 17 | 18 | 19 | class RankedFuzzySearchFilter(SearchFilter): 20 | 21 | @staticmethod 22 | def search_queryset(queryset, search_fields, search_terms, min_rank): 23 | full_text_vector = sum(itertools.zip_longest(search_fields, (), fillvalue=Value(' ')), ()) 24 | if len(search_fields) > 1: 25 | full_text_vector = full_text_vector[:-1] 26 | 27 | full_text_expr = Concat(*full_text_vector, output_field=TextField()) 28 | 29 | similarity = TrigramSimilarity(full_text_expr, search_terms) 30 | 31 | queryset = queryset.annotate(rank=similarity) 32 | 33 | if min_rank is None: 34 | queryset = queryset.filter(rank__gt=0.0) 35 | elif min_rank > 0.0: 36 | queryset = queryset.filter(rank__gte=min_rank) 37 | 38 | return queryset 39 | 40 | def filter_queryset(self, request, queryset, view): 41 | search_fields = getattr(view, 'search_fields', None) 42 | search_terms = ' '.join(self.get_search_terms(request)) 43 | 44 | if search_fields and search_terms: 45 | min_rank = getattr(view, 'min_rank', None) 46 | 47 | queryset = self.search_queryset(queryset, search_fields, search_terms, min_rank) 48 | 49 | else: 50 | queryset = queryset.annotate(rank=Value(1.0, output_field=FloatField())) 51 | 52 | return queryset 53 | 54 | 55 | class SearchableModelMixin(ViewSetMixin): 56 | search_fields = () 57 | 58 | min_rank = None 59 | 60 | def list(self, request, *args, **kwargs): 61 | query_terms = request.query_params.getlist(SearchFilter.search_param) 62 | 63 | if query_terms: 64 | words = ' '.join(query_terms).replace(',', ' ').split() 65 | terms = ' '.join(words) 66 | else: 67 | terms = None 68 | 69 | context = OrderedDict(terms=terms) 70 | 71 | return self.decorated_list(SearchableModelMixin, context, request, *args, **kwargs) 72 | -------------------------------------------------------------------------------- /rest_fuzzysearch/sort.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from collections import OrderedDict 3 | 4 | from rest_framework import filters, exceptions 5 | 6 | from .mixin import ViewSetMixin 7 | 8 | 9 | def get_sort_order(request, param): 10 | args = request.query_params.getlist(param) 11 | fields = itertools.chain(*(arg.split(',') for arg in args)) 12 | order = tuple(field.strip() for field in fields if field) 13 | return order 14 | 15 | 16 | class OrderingFilter(filters.OrderingFilter): 17 | 18 | @staticmethod 19 | def get_translated_sort_order(fields, field_map): 20 | return tuple(field_map.get(field, field) for field in fields) 21 | 22 | @staticmethod 23 | def get_reverse_translated_sort_order(fields, field_map): 24 | sort_field_reverse_map = {value: key for (key, value) in field_map.items()} 25 | return tuple(sort_field_reverse_map.get(field, field) for field in fields) 26 | 27 | @staticmethod 28 | def get_consistent_sort_order(fields): 29 | return fields + type(fields)(('pk',)) 30 | 31 | def get_ordering(self, request, queryset, view): 32 | fields = get_sort_order(request, self.ordering_param) 33 | 34 | if fields: 35 | field_map = getattr(view, 'sort_field_map', {}) 36 | 37 | fields = self.get_translated_sort_order(fields, field_map) 38 | ordering = self.remove_invalid_fields(queryset, fields, view, request) 39 | 40 | if len(ordering) != len(fields): 41 | ext_fields = self.get_reverse_translated_sort_order(fields, field_map) 42 | ext_ordering = self.get_reverse_translated_sort_order(ordering, field_map) 43 | 44 | errors = {} 45 | 46 | for ext_field in ext_fields: 47 | if ext_field not in ext_ordering: 48 | errors[ext_field] = 'invalid field' 49 | 50 | raise exceptions.ValidationError(errors) 51 | 52 | ordering = self.get_consistent_sort_order(ordering) 53 | 54 | else: 55 | ordering = self.get_default_ordering(view) 56 | 57 | consistent_sort = getattr(view, 'consistent_sort', True) 58 | if consistent_sort: 59 | ordering = self.get_consistent_sort_order(ordering) 60 | 61 | return ordering 62 | 63 | 64 | class SortedModelMixin(ViewSetMixin): 65 | ordering = () 66 | 67 | sort_field_map = {} 68 | consistent_sort = True 69 | 70 | def list(self, request, *args, **kwargs): 71 | sort = get_sort_order(request, OrderingFilter.ordering_param) or self.ordering 72 | 73 | context = OrderedDict(sort=','.join(sort)) 74 | 75 | return self.decorated_list(SortedModelMixin, context, request, *args, **kwargs) 76 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-rest-fuzzysearch', 5 | 6 | version='0.5.1', 7 | 8 | description='Fuzzy Search for Django REST Framework', 9 | 10 | url='https://github.com/vsemionov/django-rest-fuzzysearch', 11 | 12 | author='Victor Semionov', 13 | author_email='vsemionov@gmail.com', 14 | 15 | license='MIT', 16 | 17 | classifiers=[ 18 | 'Development Status :: 4 - Beta', 19 | 'Environment :: Web Environment', 20 | 'Framework :: Django', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Operating System :: OS Independent', 24 | 'Programming Language :: Python :: 3', 25 | 'Topic :: Internet :: WWW/HTTP :: Indexing/Search', 26 | 'Topic :: Software Development :: Libraries', 27 | ], 28 | 29 | keywords='fuzzy search django rest python', 30 | 31 | packages=find_packages(), 32 | 33 | install_requires=[ 34 | 'Django>=1.11', 35 | 'djangorestframework', 36 | ], 37 | ) 38 | --------------------------------------------------------------------------------