├── .github └── workflows │ └── django.yml ├── .gitignore ├── .travis.yml ├── Jenkinsfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── oscar_elasticsearch ├── __init__.py ├── exceptions.py └── search │ ├── __init__.py │ ├── api │ ├── __init__.py │ ├── autocomplete.py │ ├── base.py │ ├── category.py │ ├── lookup.py │ ├── pagination.py │ ├── product.py │ └── search.py │ ├── apps.py │ ├── backend.py │ ├── config.py │ ├── constants.py │ ├── facets.py │ ├── fixtures │ ├── catalogue │ │ └── catalogue.json │ └── search │ │ └── auth.json │ ├── format.py │ ├── forms.py │ ├── helpers.py │ ├── indexing │ ├── __init__.py │ ├── indexer.py │ └── settings.py │ ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── nl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── determine_facets.py │ │ ├── update_index_categories.py │ │ ├── update_index_products.py │ │ ├── update_index_registered.py │ │ └── update_oscar_index.py │ ├── mappings │ ├── __init__.py │ ├── categories.py │ ├── mixins.py │ └── products │ │ ├── __init__.py │ │ ├── mappings.py │ │ └── resources.py │ ├── registry.py │ ├── settings.py │ ├── signal_handlers.py │ ├── signals.py │ ├── static │ └── oscar │ │ └── js │ │ └── search │ │ ├── autocomplete.js │ │ └── bootstrap3-typeahead.js │ ├── suggestions.py │ ├── templates │ └── oscar │ │ ├── catalogue │ │ └── browse.html │ │ ├── partials │ │ └── extrascripts.html │ │ └── search │ │ ├── partials │ │ └── facet.html │ │ └── results.html │ ├── tests.py │ ├── update.py │ ├── utils.py │ └── views │ ├── __init__.py │ ├── base.py │ ├── catalogue.py │ └── search.py ├── pylintrc ├── sandbox ├── __init__.py ├── assortment │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── index.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ └── models.py ├── manage.py ├── settings.py ├── urls.py └── wsgi.py └── setup.py /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: django-oscar-elasticsearch 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: ['3.11'] 17 | django-version: ['4.2'] 18 | steps: 19 | - name: Configure sysctl limits 20 | run: | 21 | sudo swapoff -a 22 | sudo sysctl -w vm.swappiness=1 23 | sudo sysctl -w fs.file-max=262144 24 | sudo sysctl -w vm.max_map_count=262144 25 | - name: Runs Elasticsearch 26 | uses: elastic/elastic-github-actions/elasticsearch@master 27 | with: 28 | stack-version: 8.15.0 29 | security-enabled: false 30 | - uses: actions/checkout@v3 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v3 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install Dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -e .[test] 39 | pip install -e .[dev] 40 | - name: Run linters 41 | run: make lint 42 | - name: Run Tests 43 | run: make test 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | django_oscar_elasticsearch.egg-info/ 2 | __pycache__/ 3 | *.DS_Store 4 | db.sqlite3 5 | 6 | 7 | sandbox/media/ 8 | sandbox/static/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | python: 3.7 4 | 5 | install: 6 | - pip install -e .[dev] 7 | - pip install sorl-thumbnail 8 | 9 | script: 10 | - python sandbox/manage.py test 11 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | 3 | pipeline { 4 | agent any 5 | options { disableConcurrentBuilds() } 6 | 7 | stages { 8 | stage('Build') { 9 | steps { 10 | withEnv(['PIP_INDEX_URL=https://pypi.uwkm.nl/voxyan/oscar/+simple/']) { 11 | withPythonEnv('System-CPython-3.10') { 12 | pysh "make" 13 | } 14 | } 15 | } 16 | } 17 | stage('Lint') { 18 | steps { 19 | withPythonEnv('System-CPython-3.10') { 20 | pysh "make lint" 21 | } 22 | } 23 | } 24 | stage('Test') { 25 | steps { 26 | withPythonEnv('System-CPython-3.10') { 27 | withEnv(['OSCAR_ELASTICSEARCH_SERVER_URLS=https://eden.highbiza.nl:9200/', 'PIP_INDEX_URL=https://pypi.uwkm.nl/voxyan/oscar/+simple/']) { 28 | pysh "make test" 29 | } 30 | } 31 | } 32 | post { 33 | always { 34 | junit allowEmptyResults: true, testResults: '**/nosetests.xml' 35 | } 36 | success { 37 | echo "kek!" 38 | // step([ 39 | // $class: 'CoberturaPublisher', 40 | // coberturaReportFile: '**/coverage.xml', 41 | // ]) 42 | } 43 | } 44 | } 45 | } 46 | post { 47 | always { 48 | echo 'This will always run' 49 | } 50 | success { 51 | echo 'This will run only if successful' 52 | withPythonEnv('System-CPython-3.10') { 53 | echo 'This will run only if successful' 54 | pysh "version --plugin=wheel -B${env.BUILD_NUMBER} --skip-build" 55 | sh "which git" 56 | sh "git push --tags" 57 | } 58 | } 59 | failure { 60 | emailext subject: "JENKINS-NOTIFICATION: ${currentBuild.currentResult}: Job '${env.JOB_NAME} #${env.BUILD_NUMBER}'", 61 | body: '${SCRIPT, template="groovy-text.template"}', 62 | recipientProviders: [culprits(), brokenBuildSuspects(), brokenTestsSuspects()] 63 | 64 | } 65 | unstable { 66 | echo 'This will run only if the run was marked as unstable' 67 | } 68 | changed { 69 | echo 'This will run only if the state of the Pipeline has changed' 70 | echo 'For example, if the Pipeline was previously failing but is now successful' 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-present Tangent Communications PLC and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Tangent Communications PLC nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft oscar_elasticsearch 2 | global-exclude *.py[co] 3 | global-exclude __pycache__ 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fail-if-no-virtualenv all install loaddata test 2 | 3 | all: install migrate loaddata collectstatic 4 | 5 | 6 | install: fail-if-no-virtualenv 7 | pip install --pre --editable .[dev,test] --upgrade --upgrade-strategy=eager 8 | 9 | migrate: 10 | sandbox/manage.py migrate --no-input 11 | 12 | collectstatic: 13 | sandbox/manage.py collectstatic --no-input 14 | 15 | lint: 16 | black --check --exclude "migrations/*" setup.py oscar_elasticsearch 17 | pylint setup.py oscar_elasticsearch/ 18 | 19 | test: 20 | pip install --pre --editable .[test] --upgrade --upgrade-strategy=eager 21 | sandbox/manage.py test 22 | 23 | black: 24 | black --exclude "migrations/*" setup.py oscar_elasticsearch 25 | 26 | clean: ## Remove files not in source control 27 | find . -type f -name "*.pyc" -delete 28 | rm -rf nosetests.xml coverage.xml htmlcov *.egg-info *.pdf dist violations.txt 29 | 30 | package: clean 31 | pip install --upgrade pip twine wheel 32 | rm -rf dist/ 33 | rm -rf build/ 34 | python setup.py clean --all 35 | python setup.py sdist bdist_wheel 36 | 37 | release: package ## Creates release 38 | twine upload -s dist/* 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Oscar Elasticsearch 2 | 3 | ![PyPI - Version](https://img.shields.io/pypi/v/django-oscar-elasticsearch) 4 | 5 | ## 🚀 Major Overhaul Update! 6 | 7 | The latest version (3.0.0) includes a complete overhaul with significant updates to the codebase, new features, and performance enhancements. 8 | 9 | ## 📖 About 10 | 11 | Django Oscar Elasticsearch is a search app that integrates Elasticsearch with the Django Oscar framework for improved search functionality. 12 | 13 | ## 🆕 What's New 14 | 15 | - **New Elasticsearch Integration**: Enhanced search capabilities using Elasticsearch. We removed wagtail from the dependencies and created our own API. 16 | - **Improved Performance**: Faster and more efficient search operations. 17 | - **Breaking Changes**: Configuration changes may require updates to existing Oscar settings. Also search handlers are removed in djang-oscar==3.2.5 18 | 19 | ## 📦 Installation 20 | 21 | Follow these steps to set up the project: 22 | 23 | 1. Install the package: 24 | ```bash 25 | pip install django-oscar-elasticsearch 26 | ``` 27 | 2. Update your `INSTALLED_APPS` in Django settings: 28 | ```python 29 | INSTALLED_APPS = [ 30 | ... 31 | "oscar_elasticsearch.search.apps.OscarElasticSearchConfig", 32 | "oscar_odin", 33 | "widget_tweaks", 34 | ] 35 | ``` 36 | 3. Configure the necessary settings as outlined in the project documentation. 37 | 38 | ## 🛠 Configuration 39 | 40 | - **`OSCAR_ELASTICSEARCH_HANDLE_STOCKRECORD_CHANGES`**: Enables handling of stock record changes automatically. Default is `True`. 41 | - **`OSCAR_ELASTICSEARCH_MIN_NUM_BUCKETS`**: Minimum number of buckets for search facets. Default is `2`. 42 | - **`OSCAR_ELASTICSEARCH_FILTER_AVAILABLE`**: Filters products based on availability status. Default is `False`. 43 | - **`OSCAR_ELASTICSEARCH_DEFAULT_ITEMS_PER_PAGE`**: Number of items displayed per page. Defaults to `OSCAR_PRODUCTS_PER_PAGE`. 44 | - **`OSCAR_ELASTICSEARCH_ITEMS_PER_PAGE_CHOICES`**: Options for items per page settings. Default is `[DEFAULT_ITEMS_PER_PAGE]`. 45 | - **`OSCAR_ELASTICSEARCH_MONTHS_TO_RUN_ANALYTICS`**: Defines months to run analytics queries. Default is `3`. 46 | - **`OSCAR_ELASTICSEARCH_FACETS`**: Customizable search facets for filtering. 47 | - **`OSCAR_ELASTICSEARCH_SUGGESTION_FIELD_NAME`**: Field name used for suggestions. Default is `"search_title"`. 48 | - **`OSCAR_ELASTICSEARCH_AUTOCOMPLETE_STATUS_FILTER`**: Status filter for search autocomplete, default depends on availability settings. 49 | - **`OSCAR_ELASTICSEARCH_AUTOCOMPLETE_CONTEXTS`**: Contexts for autocomplete suggestions. 50 | - **`OSCAR_ELASTICSEARCH_AUTOCOMPLETE_SEARCH_FIELDS`**: Fields used in autocomplete search. Default is `["title", "upc"]`. 51 | - **`OSCAR_ELASTICSEARCH_SEARCH_FIELDS`**: Specifies fields used for general search queries. 52 | - **`OSCAR_ELASTICSEARCH_SEARCH_QUERY_TYPE`**: Type of query used in search; default is `"most_fields"`. 53 | - **`OSCAR_ELASTICSEARCH_SEARCH_QUERY_OPERATOR`**: Logical operator for search queries. Default is `"or"`. 54 | - **`OSCAR_ELASTICSEARCH_NUM_SUGGESTIONS`**: Maximum number of suggestions returned. Default is `20`. 55 | - **`OSCAR_ELASTICSEARCH_SERVER_URLS`**: Elasticsearch server URLs. Default is `["http://127.0.0.1:9200"]`. 56 | - **`OSCAR_ELASTICSEARCH_INDEX_PREFIX`**: Prefix used for Elasticsearch indices. Default is `"django-oscar-elasticsearch"`. 57 | - **`OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_SEARCH`**: Sorting options for search results. 58 | - **`OSCAR_ELASTICSEARCH_SORT_BY_MAP_SEARCH`**: Maps sort options to actual query parameters. 59 | - **`OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_CATALOGUE`**: Sorting options specific to the catalog view. 60 | - **`OSCAR_ELASTICSEARCH_SORT_BY_MAP_CATALOGUE`**: Maps catalog sort options to query parameters. 61 | - **`OSCAR_ELASTICSEARCH_DEFAULT_ORDERING`**: Default ordering setting for searches. 62 | - **`OSCAR_ELASTICSEARCH_FACET_BUCKET_SIZE`**: Sets the size of facet buckets. Default is `10`. 63 | - **`OSCAR_ELASTICSEARCH_INDEXING_CHUNK_SIZE`**: Defines chunk size for batch indexing operations. Default is `400`. 64 | - **`OSCAR_ELASTICSEARCH_PRIORITIZE_AVAILABLE_PRODUCTS`**: Prioritizes available products in search results. Default is `True`. 65 | 66 | 67 | ## 📜 Usage 68 | 69 | Django Oscar Elasticsearch is designed primarily to index products and categories from Django Oscar, but it can also index any Django model or external data types, such as CSV or Excel files. 70 | 71 | ### Indexing Django Models 72 | You can configure custom search handlers to index any Django model. Define a search document, map the fields you want to index, and create a corresponding search handler. 73 | 74 | Create the index definition 75 | ```python 76 | from django.contrib.auth import get_user_model 77 | 78 | from oscar.core.loading import get_class, get_model 79 | 80 | get_oscar_index_settings = get_class( 81 | "search.indexing.settings", "get_oscar_index_settings" 82 | ) 83 | 84 | OSCAR_INDEX_SETTINGS = get_oscar_index_settings() 85 | 86 | BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi") 87 | ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer") 88 | 89 | 90 | class UserElasticsearchIndex(BaseElasticSearchApi, ESModelIndexer): 91 | INDEX_NAME = "users" 92 | INDEX_MAPPING = { 93 | "properties": { 94 | "id": {"type": "integer", "store": True}, 95 | "full_name": {"type": "text"}, 96 | "is_active": {"type": "boolean"} 97 | } 98 | } 99 | INDEX_SETTINGS = OSCAR_INDEX_SETTINGS 100 | Model = get_user_model() 101 | 102 | def make_documents(self, objects): 103 | for user in objects: 104 | yield {"_id": user.id, "_source": {"id": user.id, "full_name": user.get_full_name(), "is_active": user.is_active}} 105 | ``` 106 | 107 | Indexing users into elasticsearch, this can be done in a management command for example 108 | ```python 109 | from django.contrib.auth import get_user_model 110 | from oscar_elasticsearch.search import settings 111 | 112 | from myprojects.usersearch import UserElasticsearchIndex 113 | 114 | User = get_user_model() 115 | 116 | 117 | with UserElasticsearchIndex().reindex() as index: 118 | for chunk in chunked(users, settings.INDEXING_CHUNK_SIZE): 119 | index.reindex_objects(chunk) 120 | ``` 121 | 122 | Searching for users on full_name 123 | ```python 124 | from myprojects.usersearch import UserElasticsearchIndex 125 | 126 | # non paginated, returns a queryset of selected model 127 | users = UserElasticsearchIndex().search( 128 | from_=0, 129 | to=10, 130 | query_string="henk", 131 | search_fields=["full_name^1.5"], 132 | filters={"term": {"is_active_": True}}, # only active users 133 | ) 134 | 135 | # paginated, returns a paginator object that extends from the django paginator 136 | paginator = UserElasticsearchIndex().paginated_search( 137 | from_=0, 138 | to=10, 139 | query_string="henk", 140 | search_fields=["full_name^1.5"], 141 | filters={"term": {"is_active_": True}}, # only active users 142 | ) 143 | ``` 144 | 145 | ## 🤝 Contributing 146 | 147 | Contributions are welcome! Please submit issues and pull requests to the repository. 148 | 149 | ## 📄 License 150 | 151 | Oscar is released under the permissive [New BSD license](https://github.com/django-oscar/django-oscar-elasticsearch/blob/master/LICENSE) ([see summary](https://tldrlegal.com/license/bsd-3-clause-license-(revised))). 152 | 153 | ## 📫 Contact 154 | 155 | For questions or support, please contact the maintainers via GitHub issues. 156 | -------------------------------------------------------------------------------- /oscar_elasticsearch/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | OSCAR_ES_MAIN_TEMPLATE_DIR = os.path.join( 5 | os.path.dirname(os.path.abspath(__file__)), "search/templates/oscar" 6 | ) 7 | -------------------------------------------------------------------------------- /oscar_elasticsearch/exceptions.py: -------------------------------------------------------------------------------- 1 | class ElasticSearchQueryException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "oscar_elasticsearch.search.config.SearchConfig" 2 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/api/__init__.py -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/autocomplete.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_class 2 | 3 | from oscar_elasticsearch.search.settings import NUM_SUGGESTIONS 4 | 5 | es = get_class("search.backend", "es") 6 | 7 | 8 | def get_option_results(results): 9 | for suggestion in results["suggest"]["autocompletion"]: 10 | for option in suggestion["options"]: 11 | yield option["text"] 12 | 13 | 14 | def autocomplete_suggestions( 15 | index, 16 | search_string, 17 | suggest_field_name, 18 | skip_duplicates=True, 19 | contexts=None, 20 | num_suggestions=NUM_SUGGESTIONS, 21 | ): 22 | body = { 23 | "suggest": { 24 | "autocompletion": { 25 | "prefix": search_string, 26 | "completion": { 27 | "field": suggest_field_name, 28 | "skip_duplicates": skip_duplicates, 29 | }, 30 | } 31 | }, 32 | "_source": False, 33 | } 34 | 35 | if contexts is not None: 36 | body["suggest"]["autocompletion"]["completion"]["contexts"] = contexts 37 | 38 | results = es.search(index=index, body=body) 39 | 40 | return list(get_option_results(results))[:num_suggestions] 41 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/base.py: -------------------------------------------------------------------------------- 1 | class BaseModelIndex: 2 | INDEX_NAME = None 3 | INDEX_MAPPING = None 4 | INDEX_SETTINGS = None 5 | Model = None 6 | 7 | def get_index_name(self): 8 | return self.INDEX_NAME 9 | 10 | def get_index_mapping(self): 11 | return self.INDEX_MAPPING 12 | 13 | def get_index_settings(self): 14 | return self.INDEX_SETTINGS 15 | 16 | def get_model(self): 17 | return self.Model 18 | 19 | def get_queryset(self): 20 | return self.Model.objects.all() 21 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/category.py: -------------------------------------------------------------------------------- 1 | from odin.codecs import dict_codec 2 | 3 | from oscar.core.loading import get_class, get_model 4 | 5 | # this index name is retrived with get_class because of i18n but it might be removed later 6 | OSCAR_CATEGORIES_INDEX_NAME = get_class( 7 | "search.indexing.settings", "OSCAR_CATEGORIES_INDEX_NAME" 8 | ) 9 | get_categories_index_mapping = get_class( 10 | "search.indexing.settings", "get_categories_index_mapping" 11 | ) 12 | get_oscar_index_settings = get_class( 13 | "search.indexing.settings", "get_oscar_index_settings" 14 | ) 15 | 16 | 17 | OSCAR_INDEX_SETTINGS = get_oscar_index_settings() 18 | 19 | BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi") 20 | ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer") 21 | 22 | Category = get_model("catalogue", "Category") 23 | 24 | 25 | class CategoryElasticsearchIndex(BaseElasticSearchApi, ESModelIndexer): 26 | INDEX_NAME = OSCAR_CATEGORIES_INDEX_NAME 27 | INDEX_MAPPING = get_categories_index_mapping() 28 | INDEX_SETTINGS = OSCAR_INDEX_SETTINGS 29 | Model = Category 30 | 31 | def make_documents(self, objects): 32 | CategoryToResource = get_class( 33 | "oscar_odin.mappings.catalogue", "CategoryToResource" 34 | ) 35 | 36 | CategoryElasticSearchMapping = get_class( 37 | "search.mappings.categories", "CategoryElasticSearchMapping" 38 | ) 39 | 40 | category_resources = CategoryToResource.apply(objects) 41 | category_document_resources = CategoryElasticSearchMapping.apply( 42 | category_resources 43 | ) 44 | 45 | return dict_codec.dump(category_document_resources, include_type_field=False) 46 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/lookup.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_class 2 | 3 | BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi") 4 | ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer") 5 | 6 | 7 | class BaseLookupIndex(BaseElasticSearchApi, ESModelIndexer): 8 | """ 9 | Subclass this class to create a custom lookup for your index. 10 | https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html#query-dsl-terms-lookup 11 | 12 | set LOOKUP_PATH as the field you want to save on the lookup index, that you can use to filter stuff on the main index you're using 13 | 14 | Example for make_documents: 15 | def make_documents(self, objects): 16 | documents = [] 17 | 18 | for user in objects: 19 | documents.append([ 20 | { 21 | "_id": user.id, 22 | self.LOOKUP_PATH: [p.id, for p in user.products] 23 | } 24 | ] 25 | ) 26 | 27 | return documents 28 | 29 | """ 30 | 31 | LOOKUP_PATH = None 32 | INDEX_SETTINGS = {} 33 | 34 | def get_index_mapping(self): 35 | if self.LOOKUP_PATH is None: 36 | raise NotImplementedError("Please set LOOKUP_PATH on your lookup index") 37 | 38 | return {"properties": {self.LOOKUP_PATH: {"type": "keyword"}}} 39 | 40 | def get_lookup_id(self, field_to_filter, **kwargs): 41 | raise NotImplementedError( 42 | """ 43 | Please implement 'get_lookup_id' on your lookup index. 44 | Return None to not apply this lookup filter, return the actual id to filter on that id, 45 | Id's should always be strings (elasticsearch), never integers 46 | """ 47 | ) 48 | 49 | def get_lookup_query(self, field_to_filter, **kwargs): 50 | lookup_id = self.get_lookup_id(field_to_filter, **kwargs) 51 | 52 | if lookup_id is not None: 53 | return { 54 | "terms": { 55 | field_to_filter: { 56 | "index": self.get_index_name(), 57 | "id": lookup_id, 58 | "path": self.LOOKUP_PATH, 59 | } 60 | } 61 | } 62 | 63 | return None 64 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/pagination.py: -------------------------------------------------------------------------------- 1 | from django.core.paginator import Paginator 2 | 3 | 4 | class ElasticSearchPaginator(Paginator): 5 | def __init__(self, instances, *args, **kwargs): 6 | self.instances = instances 7 | super().__init__(*args, **kwargs) 8 | 9 | def page(self, number): 10 | """Return a Page object for the given 1-based page number.""" 11 | number = self.validate_number(number) 12 | bottom = (number - 1) * self.per_page 13 | top = bottom + self.per_page 14 | if top + self.orphans >= self.count: 15 | top = self.count 16 | return self._get_page(self.instances, number, self) 17 | 18 | 19 | def paginate_result(instances, total_hits, size): 20 | return ElasticSearchPaginator(instances, range(0, total_hits), size) 21 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/product.py: -------------------------------------------------------------------------------- 1 | from odin.codecs import dict_codec 2 | 3 | from django.db.models import QuerySet, Count, Subquery, OuterRef, IntegerField 4 | from django.utils import timezone 5 | 6 | from dateutil.relativedelta import relativedelta 7 | 8 | from oscar.core.loading import get_class, get_model, get_classes 9 | from oscar_elasticsearch.search import settings 10 | 11 | from oscar_elasticsearch.search.utils import get_category_ancestors 12 | 13 | # this index name is retrived with get_class because of i18n but it might be removed later 14 | ( 15 | OSCAR_PRODUCTS_INDEX_NAME, 16 | OSCAR_PRODUCT_SEARCH_FIELDS, 17 | get_products_index_mapping, 18 | get_oscar_index_settings, 19 | ) = get_classes( 20 | "search.indexing.settings", 21 | [ 22 | "OSCAR_PRODUCTS_INDEX_NAME", 23 | "OSCAR_PRODUCT_SEARCH_FIELDS", 24 | "get_products_index_mapping", 25 | "get_oscar_index_settings", 26 | ], 27 | ) 28 | BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi") 29 | ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer") 30 | Product = get_model("catalogue", "Product") 31 | Category = get_model("catalogue", "Category") 32 | Line = get_model("order", "Line") 33 | 34 | 35 | class ProductElasticsearchIndex(BaseElasticSearchApi, ESModelIndexer): 36 | Model = Product 37 | INDEX_NAME = OSCAR_PRODUCTS_INDEX_NAME 38 | INDEX_MAPPING = get_products_index_mapping() 39 | INDEX_SETTINGS = get_oscar_index_settings() 40 | SEARCH_FIELDS = OSCAR_PRODUCT_SEARCH_FIELDS 41 | SUGGESTION_FIELD_NAME = settings.SUGGESTION_FIELD_NAME 42 | context = {} 43 | 44 | def get_filters(self, filters): 45 | if filters is not None: 46 | return filters 47 | 48 | return [{"term": {"is_public": True}}] 49 | 50 | def make_documents(self, objects): 51 | if "category_titles" not in self.context: 52 | self.context["category_titles"] = dict( 53 | Category.objects.values_list("id", "name") 54 | ) 55 | if "category_ancestors" not in self.context: 56 | self.context["category_ancestors"] = get_category_ancestors() 57 | 58 | if not isinstance(objects, QuerySet): 59 | try: 60 | objects = Product.objects.filter(id__in=[o.id for o in objects]) 61 | except: 62 | # pylint: disable=raise-missing-from 63 | raise ValueError( 64 | "Argument 'objects' must be a QuerySet, as product_queryset_to_resources requires a QuerySet, got %s" 65 | % type(objects) 66 | ) 67 | 68 | product_queryset_to_resources = get_class( 69 | "oscar_odin.mappings.helpers", "product_queryset_to_resources" 70 | ) 71 | 72 | ProductElasticSearchMapping = get_class( 73 | "search.mappings.products", "ProductElasticSearchMapping" 74 | ) 75 | 76 | # Annotate the queryset with popularity to avoid the need of n+1 queries 77 | objects = objects.annotate( 78 | popularity=Subquery( 79 | Line.objects.filter( 80 | product=OuterRef("pk"), 81 | order__date_placed__gte=timezone.now() 82 | - relativedelta(months=settings.MONTHS_TO_RUN_ANALYTICS), 83 | ) 84 | .values("product") 85 | .annotate(count=Count("id")) 86 | .values("count"), 87 | output_field=IntegerField(), 88 | ) 89 | ) 90 | 91 | product_resources = product_queryset_to_resources( 92 | objects, include_children=True 93 | ) 94 | product_document_resources = ProductElasticSearchMapping.apply( 95 | product_resources, self.context 96 | ) 97 | 98 | return dict_codec.dump(product_document_resources, include_type_field=False) 99 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/api/search.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0102 2 | from oscar.core.loading import get_class 3 | from django.conf import settings 4 | from oscar_elasticsearch.exceptions import ElasticSearchQueryException 5 | from oscar_elasticsearch.search import settings as es_settings 6 | from oscar_elasticsearch.search.api.base import BaseModelIndex 7 | from oscar_elasticsearch.search.utils import search_result_to_queryset 8 | 9 | paginate_result = get_class("search.api.pagination", "paginate_result") 10 | es = get_class("search.backend", "es") 11 | 12 | 13 | def get_search_query( 14 | search_fields=None, query_string=None, search_type=None, search_operator=None 15 | ): 16 | if query_string: 17 | return [ 18 | { 19 | "multi_match": { 20 | "query": query_string, 21 | "type": search_type, 22 | "operator": search_operator, 23 | "fields": search_fields, 24 | } 25 | } 26 | ] 27 | 28 | else: 29 | return {"match_all": {}} 30 | 31 | 32 | def get_search_body( 33 | from_=None, 34 | size=None, 35 | search_fields=None, 36 | query_string=None, 37 | filters=None, 38 | sort_by=None, 39 | suggestion_field_name=None, 40 | search_type=None, 41 | search_operator=None, 42 | scoring_functions=None, 43 | aggs=None, 44 | explain=True, 45 | ): 46 | body = { 47 | "track_total_hits": True, 48 | "query": { 49 | "function_score": { 50 | "query": { 51 | "bool": { 52 | "must": get_search_query( 53 | search_fields if search_fields is not None else [], 54 | query_string, 55 | search_type, 56 | search_operator, 57 | ), 58 | "filter": filters, 59 | } 60 | }, 61 | "functions": scoring_functions if scoring_functions is not None else [], 62 | } 63 | }, 64 | } 65 | 66 | if explain: 67 | body["explain"] = settings.DEBUG 68 | 69 | if from_: 70 | body["from"] = from_ 71 | 72 | if size: 73 | body["size"] = size 74 | 75 | if sort_by: 76 | body["sort"] = sort_by 77 | 78 | if aggs: 79 | body["aggs"] = aggs 80 | 81 | if suggestion_field_name and query_string: 82 | body["suggest"] = { 83 | suggestion_field_name: { 84 | "prefix": query_string, 85 | "term": {"field": suggestion_field_name}, 86 | } 87 | } 88 | 89 | return body 90 | 91 | 92 | def get_elasticsearch_aggs(aggs_definitions): 93 | aggs = {} 94 | 95 | for facet_definition in aggs_definitions: 96 | name = facet_definition["name"] 97 | facet_type = facet_definition["type"] 98 | if facet_type == "term": 99 | terms = {"terms": {"field": name, "size": es_settings.FACET_BUCKET_SIZE}} 100 | 101 | if "order" in facet_definition: 102 | terms["terms"]["order"] = {"_key": facet_definition.get("order", "asc")} 103 | 104 | aggs[name] = terms 105 | elif facet_type == "range": 106 | ranges_definition = facet_definition["ranges"] 107 | if ranges_definition: 108 | ranges = [ 109 | ( 110 | {"to": ranges_definition[i]} 111 | if i == 0 112 | else { 113 | "from": ranges_definition[i - 1], 114 | "to": ranges_definition[i], 115 | } 116 | ) 117 | for i in range(len(ranges_definition)) 118 | ] 119 | 120 | ranges.append({"from": ranges_definition[-1]}) 121 | 122 | aggs[name] = { 123 | "range": { 124 | "field": name, 125 | "ranges": ranges, 126 | } 127 | } 128 | 129 | elif facet_type == "date_histogram": 130 | date_histogram = {"date_histogram": {"field": name}} 131 | 132 | if "order" in facet_definition: 133 | date_histogram["date_histogram"]["order"] = { 134 | "_key": facet_definition.get("order", "asc") 135 | } 136 | 137 | if "date_format" in facet_definition: 138 | date_histogram["date_histogram"]["format"] = facet_definition.get( 139 | "date_format" 140 | ) 141 | 142 | if "calendar_interval" in facet_definition: 143 | date_histogram["date_histogram"]["calendar_interval"] = ( 144 | facet_definition.get("calendar_interval") 145 | ) 146 | 147 | aggs[name] = date_histogram 148 | 149 | return aggs 150 | 151 | 152 | def search( 153 | index, 154 | from_, 155 | size, 156 | search_fields=None, 157 | query_string=None, 158 | filters=None, 159 | sort_by=None, 160 | suggestion_field_name=None, 161 | search_type=es_settings.SEARCH_QUERY_TYPE, 162 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 163 | scoring_functions=None, 164 | ): 165 | body = get_search_body( 166 | from_, 167 | size, 168 | search_fields=search_fields, 169 | query_string=query_string, 170 | filters=filters, 171 | sort_by=sort_by, 172 | suggestion_field_name=suggestion_field_name, 173 | search_type=search_type, 174 | search_operator=search_operator, 175 | scoring_functions=scoring_functions, 176 | ) 177 | return es.search(index=index, body=body) 178 | 179 | 180 | def facet_search( 181 | index, 182 | from_, 183 | size, 184 | query_string=None, 185 | search_fields=None, 186 | default_filters=None, 187 | sort_by=None, 188 | suggestion_field_name=None, 189 | search_type=es_settings.SEARCH_QUERY_TYPE, 190 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 191 | scoring_functions=None, 192 | facet_filters=None, 193 | aggs_definitions=None, 194 | ): 195 | 196 | aggs = get_elasticsearch_aggs(aggs_definitions) if aggs_definitions else {} 197 | index_body = {"index": index} 198 | 199 | result_body = get_search_body( 200 | from_, 201 | size, 202 | search_fields=search_fields, 203 | query_string=query_string, 204 | filters=default_filters + facet_filters, 205 | sort_by=sort_by, 206 | suggestion_field_name=suggestion_field_name, 207 | search_type=search_type, 208 | search_operator=search_operator, 209 | scoring_functions=scoring_functions, 210 | aggs=aggs, 211 | ) 212 | 213 | unfiltered_body = get_search_body( 214 | 0, 215 | 0, 216 | search_fields=search_fields, 217 | query_string=query_string, 218 | filters=default_filters, 219 | sort_by=sort_by, 220 | suggestion_field_name=suggestion_field_name, 221 | search_type=search_type, 222 | search_operator=search_operator, 223 | scoring_functions=scoring_functions, 224 | aggs=aggs, 225 | ) 226 | 227 | multi_body = [ 228 | index_body, 229 | result_body, 230 | index_body, 231 | unfiltered_body, 232 | ] 233 | search_results, unfiltered_result = es.msearch(body=multi_body)["responses"] 234 | 235 | search_result_status = search_results["status"] 236 | unfiltered_result_status = unfiltered_result["status"] 237 | 238 | if search_result_status > 200: 239 | raise ElasticSearchQueryException( 240 | "Something went wrong during elasticsearch query", search_results 241 | ) 242 | elif unfiltered_result_status > 200: 243 | raise ElasticSearchQueryException( 244 | "Something went wrong during elasticsearch query", unfiltered_result_status 245 | ) 246 | 247 | return ( 248 | search_results, 249 | unfiltered_result, 250 | ) 251 | 252 | 253 | class BaseElasticSearchApi(BaseModelIndex): 254 | Model = None 255 | SEARCH_FIELDS = [] 256 | SUGGESTION_FIELD_NAME = None 257 | 258 | def get_search_fields(self, search_fields): 259 | if search_fields: 260 | return search_fields 261 | 262 | return self.SEARCH_FIELDS 263 | 264 | def get_filters(self, filters): 265 | if filters is not None: 266 | return filters 267 | 268 | return [] 269 | 270 | def format_filters(self, filters): 271 | return [{"match": {key: value}} for key, value in filters.items()] 272 | 273 | def format_order_by(self, order_by): 274 | orderings = [] 275 | for ordering in filter(None, order_by): 276 | if ordering.startswith("-"): 277 | orderings.append({ordering[1:]: "desc"}) 278 | else: 279 | orderings.append(ordering) 280 | return orderings 281 | 282 | def get_suggestion_field_name(self, suggestion_field_name): 283 | if suggestion_field_name is not None: 284 | return suggestion_field_name 285 | 286 | return self.SUGGESTION_FIELD_NAME 287 | 288 | def make_queryset(self, search_result): 289 | return search_result_to_queryset(search_result, self.get_model()) 290 | 291 | def search( 292 | self, 293 | from_=0, 294 | to=es_settings.DEFAULT_ITEMS_PER_PAGE, 295 | query_string=None, 296 | search_fields=None, 297 | filters=None, 298 | sort_by=None, 299 | suggestion_field_name=None, 300 | search_type=es_settings.SEARCH_QUERY_TYPE, 301 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 302 | scoring_functions=None, 303 | raw_results=False, 304 | ): 305 | search_results = search( 306 | self.get_index_name(), 307 | from_, 308 | to, 309 | query_string=query_string, 310 | search_fields=self.get_search_fields(search_fields), 311 | filters=self.get_filters(filters), 312 | sort_by=sort_by, 313 | suggestion_field_name=self.get_suggestion_field_name(suggestion_field_name), 314 | search_type=search_type, 315 | search_operator=search_operator, 316 | scoring_functions=scoring_functions, 317 | ) 318 | 319 | total_hits = search_results["hits"]["total"]["value"] 320 | 321 | if raw_results: 322 | return search_results, total_hits 323 | return self.make_queryset(search_results), total_hits 324 | 325 | def facet_search( 326 | self, 327 | from_=0, 328 | to=es_settings.DEFAULT_ITEMS_PER_PAGE, 329 | query_string=None, 330 | search_fields=None, 331 | filters=None, 332 | sort_by=None, 333 | suggestion_field_name=None, 334 | search_type=es_settings.SEARCH_QUERY_TYPE, 335 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 336 | scoring_functions=None, 337 | facet_filters=None, 338 | aggs_definitions=None, 339 | ): 340 | search_results, unfiltered_result = facet_search( 341 | self.get_index_name(), 342 | from_, 343 | to, 344 | query_string=query_string, 345 | search_fields=self.get_search_fields(search_fields), 346 | facet_filters=facet_filters, 347 | sort_by=sort_by, 348 | suggestion_field_name=self.get_suggestion_field_name(suggestion_field_name), 349 | search_type=search_type, 350 | search_operator=search_operator, 351 | scoring_functions=scoring_functions, 352 | default_filters=self.get_filters(filters), 353 | aggs_definitions=aggs_definitions, 354 | ) 355 | 356 | return ( 357 | self.make_queryset(search_results), 358 | search_results, 359 | unfiltered_result, 360 | ) 361 | 362 | def paginated_search( 363 | self, 364 | from_=0, 365 | to=es_settings.DEFAULT_ITEMS_PER_PAGE, 366 | query_string=None, 367 | search_fields=None, 368 | filters=None, 369 | sort_by=None, 370 | suggestion_field_name=None, 371 | search_type=es_settings.SEARCH_QUERY_TYPE, 372 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 373 | scoring_functions=None, 374 | ): 375 | instances, total_hits = self.search( 376 | from_=from_, 377 | to=to, 378 | query_string=query_string, 379 | search_fields=search_fields, 380 | filters=filters, 381 | sort_by=sort_by, 382 | suggestion_field_name=suggestion_field_name, 383 | search_type=search_type, 384 | search_operator=search_operator, 385 | scoring_functions=scoring_functions, 386 | ) 387 | 388 | return paginate_result(instances, total_hits, to) 389 | 390 | def paginated_facet_search( 391 | self, 392 | from_=0, 393 | to=es_settings.DEFAULT_ITEMS_PER_PAGE, 394 | query_string=None, 395 | search_fields=None, 396 | filters=None, 397 | sort_by=None, 398 | suggestion_field_name=None, 399 | search_type=es_settings.SEARCH_QUERY_TYPE, 400 | search_operator=es_settings.SEARCH_QUERY_OPERATOR, 401 | scoring_functions=None, 402 | facet_filters=None, 403 | aggs_definitions=None, 404 | ): 405 | instances, search_results, unfiltered_result = self.facet_search( 406 | from_=from_, 407 | to=to, 408 | query_string=query_string, 409 | search_fields=search_fields, 410 | filters=filters, 411 | sort_by=sort_by, 412 | suggestion_field_name=suggestion_field_name, 413 | search_type=search_type, 414 | search_operator=search_operator, 415 | scoring_functions=scoring_functions, 416 | facet_filters=facet_filters, 417 | aggs_definitions=aggs_definitions, 418 | ) 419 | 420 | total_hits = search_results["hits"]["total"]["value"] 421 | 422 | return ( 423 | paginate_result(instances, total_hits, to), 424 | search_results, 425 | unfiltered_result, 426 | ) 427 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/apps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0201 2 | from django.urls import path 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from oscar.apps.search.apps import SearchConfig 6 | from oscar.core.loading import get_class 7 | 8 | from .constants import APP_LABEL 9 | 10 | 11 | class OscarElasticSearchConfig(SearchConfig): 12 | label = APP_LABEL 13 | name = "oscar_elasticsearch.search" 14 | verbose_name = _("Elasticsearch") 15 | 16 | namespace = "search" 17 | 18 | # pylint: disable=W0201 19 | def ready(self): 20 | super().ready() 21 | self.autocomplete_view = get_class( 22 | "search.views.search", "CatalogueAutoCompleteView" 23 | ) 24 | 25 | from .signal_handlers import register_signal_handlers 26 | 27 | register_signal_handlers() 28 | 29 | def get_urls(self): 30 | urls = super().get_urls() 31 | urls += [ 32 | path( 33 | "autocomplete/", self.autocomplete_view.as_view(), name="autocomplete" 34 | ), 35 | ] 36 | return self.post_process_urls(urls) 37 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/backend.py: -------------------------------------------------------------------------------- 1 | from elasticsearch import Elasticsearch 2 | 3 | from oscar_elasticsearch.search.settings import ELASTICSEARCH_SERVER_URLS 4 | 5 | es = Elasticsearch( 6 | hosts=ELASTICSEARCH_SERVER_URLS, 7 | verify_certs=False, 8 | ) 9 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/config.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SearchConfig(AppConfig): 6 | label = "search" 7 | name = "oscar_elasticsearch.search" 8 | verbose_name = _("Search") 9 | 10 | def ready(self): 11 | from .signal_handlers import register_signal_handlers 12 | 13 | register_signal_handlers() 14 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/constants.py: -------------------------------------------------------------------------------- 1 | APP_LABEL = "search" 2 | 3 | ES_CTX_PUBLIC = "p" 4 | ES_CTX_AVAILABLE = "a" 5 | ES_CTX_BROWSABLE = "b" 6 | ES_CTX_NOT_PUBLIC = "n" 7 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/facets.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | from django.utils.translation import gettext 3 | 4 | from . import settings 5 | 6 | from purl import URL 7 | 8 | 9 | def bucket_key(bucket): 10 | if "key_as_string" in bucket: 11 | return bucket["key_as_string"] 12 | else: 13 | return bucket["key"] 14 | 15 | 16 | def bucket_to_lookup(buckets): 17 | return {bucket_key(item): item["doc_count"] for item in buckets} 18 | 19 | 20 | def strip_pagination(url): 21 | if url.has_query_param("page"): 22 | url = url.remove_query_param("page") 23 | return url.as_string() 24 | 25 | 26 | def process_facets(request_full_path, form, facets, facet_definitions=None): 27 | unfiltered_facets, filtered_facets = facets 28 | selected_multi_facets = form.selected_multi_facets 29 | if not facet_definitions: 30 | facet_definitions = [] 31 | processed_facets = {} 32 | 33 | for facet_definition in facet_definitions: 34 | facet_name = facet_definition["name"] 35 | selected_facets = selected_multi_facets[facet_name] 36 | unfiltered_facet = unfiltered_facets["aggregations"].get(facet_name) 37 | filtered_facet = filtered_facets["aggregations"].get(facet_name, {}) 38 | if unfiltered_facet is None: 39 | continue 40 | 41 | unfiltered_buckets = unfiltered_facet.get("buckets", []) 42 | filtered_buckets = filtered_facet.get("buckets", []) 43 | if len(unfiltered_buckets) >= settings.MIN_NUM_BUCKETS: 44 | # range facet buckets are always filled so we need to check if the 45 | # doc_counts are non-zero to know if they are useful. 46 | if facet_definition.get("type") == "range" and not any( 47 | (bucket.get("doc_count", 0) > 0 for bucket in unfiltered_buckets) 48 | ): 49 | continue 50 | 51 | # filter out empty buckets 52 | if facet_definition.get("type") == "date_histogram": 53 | unfiltered_buckets = filter( 54 | lambda bucket: bucket["doc_count"] > 0, unfiltered_buckets 55 | ) 56 | filtered_buckets = filter( 57 | lambda bucket: bucket["doc_count"] > 0, filtered_buckets 58 | ) 59 | 60 | facet = Facet( 61 | facet_definition, 62 | unfiltered_buckets, 63 | filtered_buckets, 64 | request_full_path, 65 | selected_facets, 66 | ) 67 | processed_facets[facet_name] = facet 68 | 69 | return processed_facets 70 | 71 | 72 | class Facet(object): 73 | def __init__( 74 | self, 75 | facet_definition, 76 | unfiltered_buckets, 77 | filtered_buckets, 78 | request_url, 79 | selected_facets=None, 80 | ): 81 | self.facet = facet_definition["name"] 82 | self.label = facet_definition["label"] 83 | self.typ = facet_definition["type"] 84 | self.unfiltered_buckets = unfiltered_buckets 85 | self.filtered_buckets = filtered_buckets 86 | self.request_url = request_url 87 | self.selected_facets = set(selected_facets) 88 | self.formatter = None 89 | formatter = (facet_definition.get("formatter") or "").strip() 90 | if formatter: 91 | self.formatter = import_string(formatter) 92 | 93 | def name(self): 94 | return gettext(str(self.label or "")) 95 | 96 | def has_selection(self): 97 | return bool(self.selected_facets) 98 | 99 | def results(self): 100 | lookup = bucket_to_lookup(self.filtered_buckets) 101 | if lookup: 102 | max_bucket_count = max(lookup.values()) 103 | else: 104 | max_bucket_count = 0 105 | 106 | for bucket in self.unfiltered_buckets: 107 | key = bucket_key(bucket) 108 | 109 | if str(key) in self.selected_facets: 110 | selected = True 111 | else: 112 | selected = False 113 | 114 | if self.has_selection() and not selected: 115 | doc_count = min( # I like to explain why this is a great formula 116 | lookup.get(key, 0) or max_bucket_count, bucket["doc_count"] 117 | ) 118 | else: 119 | doc_count = lookup.get(key, 0) 120 | 121 | yield FacetBucketItem( 122 | self.facet, key, doc_count, self.request_url, selected, self.formatter 123 | ) 124 | 125 | 126 | class FacetBucketItem(object): 127 | def __init__(self, facet, key, doc_count, request_url, selected, formatter=None): 128 | self.facet = facet 129 | self.key = key 130 | self.doc_count = doc_count 131 | self.request_url = URL(request_url) 132 | self.selected = selected 133 | self.show_count = True 134 | self.formatter = formatter 135 | 136 | def name(self): 137 | return self.key 138 | 139 | @property 140 | def count(self): 141 | return self.doc_count 142 | 143 | def __str__(self): 144 | if self.formatter is not None: 145 | return f"{self.formatter(self.key)!s}" 146 | return f"{self.key!s}" 147 | 148 | def select_url(self): 149 | url = self.request_url.append_query_param( 150 | "selected_facets", "%s:%s" % (self.facet, self.key) 151 | ) 152 | return strip_pagination(url) 153 | 154 | def deselect_url(self): 155 | url = self.request_url.remove_query_param( 156 | "selected_facets", "%s:%s" % (self.facet, self.key) 157 | ) 158 | return strip_pagination(url) 159 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/fixtures/catalogue/catalogue.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "catalogue.productclass", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Spullen", 7 | "requires_shipping": true, 8 | "track_stock": false, 9 | "slug": "dik-products", 10 | "options": [] 11 | } 12 | }, 13 | { 14 | "model": "catalogue.productclass", 15 | "pk": 2, 16 | "fields": { 17 | "name": "serious", 18 | "requires_shipping": true, 19 | "track_stock": true, 20 | "slug": "serious", 21 | "options": [] 22 | } 23 | }, 24 | { 25 | "model": "catalogue.product", 26 | "pk": 2, 27 | "fields": { 28 | "structure": "standalone", 29 | "upc": "673873", 30 | "parent": null, 31 | "title": "Hermes Bikini", 32 | "slug": "dikke-shit-ouwe", 33 | "description": "

Vet ding dit hoor

", 34 | "product_class": 1, 35 | "rating": null, 36 | "date_created": "2018-06-27T09:11:35.813Z", 37 | "date_updated": "2018-08-22T13:25:11.724Z", 38 | "is_discountable": true 39 | } 40 | }, 41 | { 42 | "model": "catalogue.product", 43 | "pk": 3, 44 | "fields": { 45 | "structure": "standalone", 46 | "upc": "piet", 47 | "parent": null, 48 | "title": "Hubble Photo", 49 | "slug": "henk", 50 | "description": "

30cm x 55cm

", 51 | "product_class": 1, 52 | "rating": null, 53 | "date_created": "2018-07-10T13:06:53.822Z", 54 | "date_updated": "2018-08-22T13:25:58.500Z", 55 | "is_discountable": true 56 | } 57 | }, 58 | { 59 | "model": "catalogue.product", 60 | "pk": 4, 61 | "fields": { 62 | "structure": "standalone", 63 | "upc": "1234tyser", 64 | "parent": null, 65 | "title": "serious product", 66 | "slug": "serious-product", 67 | "description": "

Nulla dui purus, eleifend vel, consequat non, dictum porta, nulla. Duis ante mi, laoreet ut, commodo eleifend, cursus nec, lorem. Aenean eu est. Etiam imperdiet turpis. Praesent nec augue. Curabitur ligula quam, rutrum id, tempor sed, consequat ac, dui. Vestibulum accumsan eros nec magna. Vestibulum vitae dui. Vestibulum nec ligula et lorem consequat ullamcorper. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos hymenaeos. Phasellus eget nisl ut elit porta ullamcorper. Maecenas tincidunt velit quis orci. Sed in dui. Nullam ut mauris eu mi mollis luctus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos hymenaeos. Sed cursus cursus velit. Sed a massa. Duis dignissim euismod quam. Nullam euismod metus ut orci. Vestibulum erat libero, scelerisque et, porttitor et, varius a, leo.

", 68 | "product_class": 2, 69 | "rating": null, 70 | "date_created": "2018-11-16T08:24:18.704Z", 71 | "date_updated": "2018-11-16T08:24:18.704Z", 72 | "is_discountable": true 73 | } 74 | }, 75 | { 76 | "model": "catalogue.product", 77 | "pk": 5, 78 | "fields": { 79 | "structure": "standalone", 80 | "upc": "368yhfjkf", 81 | "parent": null, 82 | "title": "second", 83 | "slug": "second", 84 | "description": "

Nullam sapien eros, facilisis vel, eleifend non, auctor dapibus, pede.

", 85 | "product_class": 2, 86 | "rating": null, 87 | "date_created": "2018-11-16T08:28:11.730Z", 88 | "date_updated": "2018-11-16T08:37:39.853Z", 89 | "is_discountable": true 90 | } 91 | }, 92 | { 93 | "model": "catalogue.product", 94 | "pk": 6, 95 | "fields": { 96 | "structure": "parent", 97 | "upc": "jk4000", 98 | "parent": null, 99 | "title": "sixty eight", 100 | "slug": "sixty-eight", 101 | "description": "

Nullam sapien eros, facilisis vel, eleifend non, auctor dapibus, pede.

", 102 | "product_class": 2, 103 | "rating": null, 104 | "date_created": "2018-11-16T08:28:11.730Z", 105 | "date_updated": "2018-11-16T08:37:39.853Z", 106 | "is_discountable": true 107 | } 108 | }, 109 | { 110 | "model": "catalogue.product", 111 | "pk": 7, 112 | "fields": { 113 | "structure": "child", 114 | "upc": "kq8000", 115 | "parent": 6, 116 | "title": "forty five", 117 | "slug": "forty-five", 118 | "description": "

Nullam sapien eros, facilisis vel, eleifend non, auctor dapibus, pede.

", 119 | "product_class": 2, 120 | "rating": null, 121 | "date_created": "2018-11-16T08:28:11.730Z", 122 | "date_updated": "2018-11-16T08:37:39.853Z", 123 | "is_discountable": true 124 | } 125 | }, 126 | { 127 | "model": "catalogue.category", 128 | "pk": 1, 129 | "fields": { 130 | "path": "0001", 131 | "depth": 1, 132 | "numchild": 0, 133 | "name": "Ambulant goods", 134 | "description": "

Some ambulant goods

", 135 | "image": "", 136 | "slug": "root" 137 | } 138 | }, 139 | { 140 | "model": "catalogue.category", 141 | "pk": 2, 142 | "fields": { 143 | "path": "00010001", 144 | "depth": 2, 145 | "numchild": 0, 146 | "name": "bridge", 147 | "description": "

Some bridge's

", 148 | "image": "", 149 | "slug": "bridge" 150 | } 151 | }, 152 | { 153 | "model": "catalogue.productcategory", 154 | "pk": 1, 155 | "fields": { 156 | "product": 2, 157 | "category": 1 158 | } 159 | }, 160 | { 161 | "model": "catalogue.productcategory", 162 | "pk": 2, 163 | "fields": { 164 | "product": 3, 165 | "category": 1 166 | } 167 | }, 168 | { 169 | "model": "catalogue.productcategory", 170 | "pk": 3, 171 | "fields": { 172 | "product": 4, 173 | "category": 2 174 | } 175 | }, 176 | { 177 | "model": "catalogue.productcategory", 178 | "pk": 4, 179 | "fields": { 180 | "product": 5, 181 | "category": 2 182 | } 183 | }, 184 | { 185 | "model": "catalogue.productcategory", 186 | "pk": 5, 187 | "fields": { 188 | "product": 6, 189 | "category": 2 190 | } 191 | }, 192 | { 193 | "model": "catalogue.productattribute", 194 | "pk": 1, 195 | "fields": { 196 | "product_class": 1, 197 | "name": "henk", 198 | "code": "henkie", 199 | "type": "text", 200 | "option_group": null, 201 | "required": true 202 | } 203 | }, 204 | { 205 | "model": "catalogue.productattribute", 206 | "pk": 2, 207 | "fields": { 208 | "product_class": 2, 209 | "name": "subtitle", 210 | "code": "subtitle", 211 | "type": "text", 212 | "option_group": null, 213 | "required": true 214 | } 215 | }, 216 | { 217 | "model": "catalogue.productattribute", 218 | "pk": 3, 219 | "fields": { 220 | "product_class": 2, 221 | "name": "facets", 222 | "code": "facets", 223 | "type": "integer", 224 | "option_group": null, 225 | "required": true 226 | } 227 | }, 228 | { 229 | "model": "catalogue.productattribute", 230 | "pk": 4, 231 | "fields": { 232 | "product_class": 2, 233 | "name": "available", 234 | "code": "available", 235 | "type": "boolean", 236 | "option_group": null, 237 | "required": false 238 | } 239 | }, 240 | { 241 | "model": "catalogue.productattribute", 242 | "pk": 5, 243 | "fields": { 244 | "product_class": 2, 245 | "name": "hypothenusa", 246 | "code": "hypothenusa", 247 | "type": "float", 248 | "option_group": null, 249 | "required": true 250 | } 251 | }, 252 | { 253 | "model": "catalogue.productattribute", 254 | "pk": 6, 255 | "fields": { 256 | "product_class": 2, 257 | "name": "additional info", 258 | "code": "additional_info", 259 | "type": "richtext", 260 | "option_group": null, 261 | "required": true 262 | } 263 | }, 264 | { 265 | "model": "catalogue.productattribute", 266 | "pk": 7, 267 | "fields": { 268 | "product_class": 2, 269 | "name": "releasedate", 270 | "code": "releasedate", 271 | "type": "date", 272 | "option_group": null, 273 | "required": true 274 | } 275 | }, 276 | { 277 | "model": "catalogue.productattribute", 278 | "pk": 8, 279 | "fields": { 280 | "product_class": 2, 281 | "name": "starttime", 282 | "code": "starttime", 283 | "type": "datetime", 284 | "option_group": null, 285 | "required": true 286 | } 287 | }, 288 | { 289 | "model": "catalogue.productattribute", 290 | "pk": 9, 291 | "fields": { 292 | "product_class": 2, 293 | "name": "kind", 294 | "code": "kind", 295 | "type": "option", 296 | "option_group": 1, 297 | "required": true 298 | } 299 | }, 300 | { 301 | "model": "catalogue.productattribute", 302 | "pk": 10, 303 | "fields": { 304 | "product_class": 2, 305 | "name": "subkinds", 306 | "code": "subkinds", 307 | "type": "multi_option", 308 | "option_group": 1, 309 | "required": false 310 | } 311 | }, 312 | { 313 | "model": "catalogue.productattribute", 314 | "pk": 11, 315 | "fields": { 316 | "product_class": 2, 317 | "name": "henk", 318 | "code": "henkie", 319 | "type": "text", 320 | "option_group": null, 321 | "required": false 322 | } 323 | }, 324 | { 325 | "model": "catalogue.productattributevalue", 326 | "pk": 2, 327 | "fields": { 328 | "attribute": 1, 329 | "product": 2, 330 | "value_text": "bka", 331 | "value_integer": null, 332 | "value_boolean": null, 333 | "value_float": null, 334 | "value_richtext": null, 335 | "value_date": null, 336 | "value_datetime": null, 337 | "value_option": null, 338 | "value_file": "", 339 | "value_image": "", 340 | "entity_content_type": null, 341 | "entity_object_id": null, 342 | "value_multi_option": [] 343 | } 344 | }, 345 | { 346 | "model": "catalogue.productattributevalue", 347 | "pk": 3, 348 | "fields": { 349 | "attribute": 1, 350 | "product": 3, 351 | "value_text": "bah bah", 352 | "value_integer": null, 353 | "value_boolean": null, 354 | "value_float": null, 355 | "value_richtext": null, 356 | "value_date": null, 357 | "value_datetime": null, 358 | "value_option": null, 359 | "value_file": "", 360 | "value_image": "", 361 | "entity_content_type": null, 362 | "entity_object_id": null, 363 | "value_multi_option": [] 364 | } 365 | }, 366 | { 367 | "model": "catalogue.productattributevalue", 368 | "pk": 4, 369 | "fields": { 370 | "attribute": 6, 371 | "product": 4, 372 | "value_text": null, 373 | "value_integer": null, 374 | "value_boolean": null, 375 | "value_float": null, 376 | "value_richtext": "

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi commodo, ipsum sed pharetra gravida, orci magna rhoncus neque, id pulvinar odio lorem non turpis. Nullam sit amet enim. Suspendisse id velit vitae ligula volutpat condimentum. Aliquam erat volutpat.

\r\n

\u00a0

\r\n

\u00a0

", 377 | "value_date": null, 378 | "value_datetime": null, 379 | "value_option": null, 380 | "value_file": "", 381 | "value_image": "", 382 | "entity_content_type": null, 383 | "entity_object_id": null, 384 | "value_multi_option": [] 385 | } 386 | }, 387 | { 388 | "model": "catalogue.productattributevalue", 389 | "pk": 5, 390 | "fields": { 391 | "attribute": 4, 392 | "product": 4, 393 | "value_text": null, 394 | "value_integer": null, 395 | "value_boolean": true, 396 | "value_float": null, 397 | "value_richtext": null, 398 | "value_date": null, 399 | "value_datetime": null, 400 | "value_option": null, 401 | "value_file": "", 402 | "value_image": "", 403 | "entity_content_type": null, 404 | "entity_object_id": null, 405 | "value_multi_option": [] 406 | } 407 | }, 408 | { 409 | "model": "catalogue.productattributevalue", 410 | "pk": 6, 411 | "fields": { 412 | "attribute": 3, 413 | "product": 4, 414 | "value_text": null, 415 | "value_integer": 4, 416 | "value_boolean": null, 417 | "value_float": null, 418 | "value_richtext": null, 419 | "value_date": null, 420 | "value_datetime": null, 421 | "value_option": null, 422 | "value_file": "", 423 | "value_image": "", 424 | "entity_content_type": null, 425 | "entity_object_id": null, 426 | "value_multi_option": [] 427 | } 428 | }, 429 | { 430 | "model": "catalogue.productattributevalue", 431 | "pk": 7, 432 | "fields": { 433 | "attribute": 5, 434 | "product": 4, 435 | "value_text": null, 436 | "value_integer": null, 437 | "value_boolean": null, 438 | "value_float": 2.4567, 439 | "value_richtext": null, 440 | "value_date": null, 441 | "value_datetime": null, 442 | "value_option": null, 443 | "value_file": "", 444 | "value_image": "", 445 | "entity_content_type": null, 446 | "entity_object_id": null, 447 | "value_multi_option": [] 448 | } 449 | }, 450 | { 451 | "model": "catalogue.productattributevalue", 452 | "pk": 8, 453 | "fields": { 454 | "attribute": 9, 455 | "product": 4, 456 | "value_text": null, 457 | "value_integer": null, 458 | "value_boolean": null, 459 | "value_float": null, 460 | "value_richtext": null, 461 | "value_date": null, 462 | "value_datetime": null, 463 | "value_option": 6, 464 | "value_file": "", 465 | "value_image": "", 466 | "entity_content_type": null, 467 | "entity_object_id": null, 468 | "value_multi_option": [] 469 | } 470 | }, 471 | { 472 | "model": "catalogue.productattributevalue", 473 | "pk": 9, 474 | "fields": { 475 | "attribute": 7, 476 | "product": 4, 477 | "value_text": null, 478 | "value_integer": null, 479 | "value_boolean": null, 480 | "value_float": null, 481 | "value_richtext": null, 482 | "value_date": "2018-11-16", 483 | "value_datetime": null, 484 | "value_option": null, 485 | "value_file": "", 486 | "value_image": "", 487 | "entity_content_type": null, 488 | "entity_object_id": null, 489 | "value_multi_option": [] 490 | } 491 | }, 492 | { 493 | "model": "catalogue.productattributevalue", 494 | "pk": 10, 495 | "fields": { 496 | "attribute": 8, 497 | "product": 4, 498 | "value_text": null, 499 | "value_integer": null, 500 | "value_boolean": null, 501 | "value_float": null, 502 | "value_richtext": null, 503 | "value_date": null, 504 | "value_datetime": "2018-11-16T09:15:00Z", 505 | "value_option": null, 506 | "value_file": "", 507 | "value_image": "", 508 | "entity_content_type": null, 509 | "entity_object_id": null, 510 | "value_multi_option": [] 511 | } 512 | }, 513 | { 514 | "model": "catalogue.productattributevalue", 515 | "pk": 11, 516 | "fields": { 517 | "attribute": 10, 518 | "product": 4, 519 | "value_text": null, 520 | "value_integer": null, 521 | "value_boolean": null, 522 | "value_float": null, 523 | "value_richtext": null, 524 | "value_date": null, 525 | "value_datetime": null, 526 | "value_option": null, 527 | "value_file": "", 528 | "value_image": "", 529 | "entity_content_type": null, 530 | "entity_object_id": null, 531 | "value_multi_option": [ 532 | 1, 533 | 3, 534 | 5 535 | ] 536 | } 537 | }, 538 | { 539 | "model": "catalogue.productattributevalue", 540 | "pk": 12, 541 | "fields": { 542 | "attribute": 2, 543 | "product": 4, 544 | "value_text": "kekjo", 545 | "value_integer": null, 546 | "value_boolean": null, 547 | "value_float": null, 548 | "value_richtext": null, 549 | "value_date": null, 550 | "value_datetime": null, 551 | "value_option": null, 552 | "value_file": "", 553 | "value_image": "", 554 | "entity_content_type": null, 555 | "entity_object_id": null, 556 | "value_multi_option": [] 557 | } 558 | }, 559 | { 560 | "model": "catalogue.productattributevalue", 561 | "pk": 13, 562 | "fields": { 563 | "attribute": 6, 564 | "product": 5, 565 | "value_text": null, 566 | "value_integer": null, 567 | "value_boolean": null, 568 | "value_float": null, 569 | "value_richtext": "

Vivamus auctor leo vel dui. Aliquam erat volutpat. Phasellus nibh. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Cras tempor. Morbi egestas, urna non consequat tempus, nunc arcu mollis enim, eu aliquam erat nulla non nibh. Duis consectetuer malesuada velit. Nam ante nulla, interdum vel, tristique ac, condimentum non, tellus. Proin ornare feugiat nisl. Suspendisse dolor nisl, ultrices at, eleifend vel, consequat at, dolor.

", 570 | "value_date": null, 571 | "value_datetime": null, 572 | "value_option": null, 573 | "value_file": "", 574 | "value_image": "", 575 | "entity_content_type": null, 576 | "entity_object_id": null, 577 | "value_multi_option": [] 578 | } 579 | }, 580 | { 581 | "model": "catalogue.productattributevalue", 582 | "pk": 14, 583 | "fields": { 584 | "attribute": 4, 585 | "product": 5, 586 | "value_text": null, 587 | "value_integer": null, 588 | "value_boolean": false, 589 | "value_float": null, 590 | "value_richtext": null, 591 | "value_date": null, 592 | "value_datetime": null, 593 | "value_option": null, 594 | "value_file": "", 595 | "value_image": "", 596 | "entity_content_type": null, 597 | "entity_object_id": null, 598 | "value_multi_option": [] 599 | } 600 | }, 601 | { 602 | "model": "catalogue.productattributevalue", 603 | "pk": 15, 604 | "fields": { 605 | "attribute": 3, 606 | "product": 5, 607 | "value_text": null, 608 | "value_integer": 8, 609 | "value_boolean": null, 610 | "value_float": null, 611 | "value_richtext": null, 612 | "value_date": null, 613 | "value_datetime": null, 614 | "value_option": null, 615 | "value_file": "", 616 | "value_image": "", 617 | "entity_content_type": null, 618 | "entity_object_id": null, 619 | "value_multi_option": [] 620 | } 621 | }, 622 | { 623 | "model": "catalogue.productattributevalue", 624 | "pk": 16, 625 | "fields": { 626 | "attribute": 5, 627 | "product": 5, 628 | "value_text": null, 629 | "value_integer": null, 630 | "value_boolean": null, 631 | "value_float": 1.25, 632 | "value_richtext": null, 633 | "value_date": null, 634 | "value_datetime": null, 635 | "value_option": null, 636 | "value_file": "", 637 | "value_image": "", 638 | "entity_content_type": null, 639 | "entity_object_id": null, 640 | "value_multi_option": [] 641 | } 642 | }, 643 | { 644 | "model": "catalogue.productattributevalue", 645 | "pk": 17, 646 | "fields": { 647 | "attribute": 9, 648 | "product": 5, 649 | "value_text": null, 650 | "value_integer": null, 651 | "value_boolean": null, 652 | "value_float": null, 653 | "value_richtext": null, 654 | "value_date": null, 655 | "value_datetime": null, 656 | "value_option": 4, 657 | "value_file": "", 658 | "value_image": "", 659 | "entity_content_type": null, 660 | "entity_object_id": null, 661 | "value_multi_option": [] 662 | } 663 | }, 664 | { 665 | "model": "catalogue.productattributevalue", 666 | "pk": 18, 667 | "fields": { 668 | "attribute": 7, 669 | "product": 5, 670 | "value_text": null, 671 | "value_integer": null, 672 | "value_boolean": null, 673 | "value_float": null, 674 | "value_richtext": null, 675 | "value_date": "1999-11-11", 676 | "value_datetime": null, 677 | "value_option": null, 678 | "value_file": "", 679 | "value_image": "", 680 | "entity_content_type": null, 681 | "entity_object_id": null, 682 | "value_multi_option": [] 683 | } 684 | }, 685 | { 686 | "model": "catalogue.productattributevalue", 687 | "pk": 19, 688 | "fields": { 689 | "attribute": 8, 690 | "product": 5, 691 | "value_text": null, 692 | "value_integer": null, 693 | "value_boolean": null, 694 | "value_float": null, 695 | "value_richtext": null, 696 | "value_date": null, 697 | "value_datetime": "2018-11-16T09:15:00Z", 698 | "value_option": null, 699 | "value_file": "", 700 | "value_image": "", 701 | "entity_content_type": null, 702 | "entity_object_id": null, 703 | "value_multi_option": [] 704 | } 705 | }, 706 | { 707 | "model": "catalogue.productattributevalue", 708 | "pk": 20, 709 | "fields": { 710 | "attribute": 10, 711 | "product": 5, 712 | "value_text": null, 713 | "value_integer": null, 714 | "value_boolean": null, 715 | "value_float": null, 716 | "value_richtext": null, 717 | "value_date": null, 718 | "value_datetime": null, 719 | "value_option": null, 720 | "value_file": "", 721 | "value_image": "", 722 | "entity_content_type": null, 723 | "entity_object_id": null, 724 | "value_multi_option": [ 725 | 3, 726 | 5 727 | ] 728 | } 729 | }, 730 | { 731 | "model": "catalogue.productattributevalue", 732 | "pk": 21, 733 | "fields": { 734 | "attribute": 2, 735 | "product": 5, 736 | "value_text": "superhenk", 737 | "value_integer": null, 738 | "value_boolean": null, 739 | "value_float": null, 740 | "value_richtext": null, 741 | "value_date": null, 742 | "value_datetime": null, 743 | "value_option": null, 744 | "value_file": "", 745 | "value_image": "", 746 | "entity_content_type": null, 747 | "entity_object_id": null, 748 | "value_multi_option": [] 749 | } 750 | }, 751 | { 752 | "model": "catalogue.productattributevalue", 753 | "pk": 22, 754 | "fields": { 755 | "attribute": 11, 756 | "product": 5, 757 | "value_text": "bah bah", 758 | "value_integer": null, 759 | "value_boolean": null, 760 | "value_float": null, 761 | "value_richtext": null, 762 | "value_date": null, 763 | "value_datetime": null, 764 | "value_option": null, 765 | "value_file": "", 766 | "value_image": "", 767 | "entity_content_type": null, 768 | "entity_object_id": null, 769 | "value_multi_option": [] 770 | } 771 | }, 772 | { 773 | "model": "catalogue.attributeoptiongroup", 774 | "pk": 1, 775 | "fields": { 776 | "name": "kind" 777 | } 778 | }, 779 | { 780 | "model": "catalogue.attributeoption", 781 | "pk": 1, 782 | "fields": { 783 | "group": 1, 784 | "option": "grand" 785 | } 786 | }, 787 | { 788 | "model": "catalogue.attributeoption", 789 | "pk": 2, 790 | "fields": { 791 | "group": 1, 792 | "option": "omnimous" 793 | } 794 | }, 795 | { 796 | "model": "catalogue.attributeoption", 797 | "pk": 3, 798 | "fields": { 799 | "group": 1, 800 | "option": "verocious" 801 | } 802 | }, 803 | { 804 | "model": "catalogue.attributeoption", 805 | "pk": 4, 806 | "fields": { 807 | "group": 1, 808 | "option": "totalitarian" 809 | } 810 | }, 811 | { 812 | "model": "catalogue.attributeoption", 813 | "pk": 5, 814 | "fields": { 815 | "group": 1, 816 | "option": "megalomane" 817 | } 818 | }, 819 | { 820 | "model": "catalogue.attributeoption", 821 | "pk": 6, 822 | "fields": { 823 | "group": 1, 824 | "option": "bombastic" 825 | } 826 | }, 827 | { 828 | "model": "partner.stockrecord", 829 | "pk": 1, 830 | "fields": { 831 | "product": 2, 832 | "partner": 1, 833 | "partner_sku": "henk", 834 | "price_currency": "EUR", 835 | "price": "400.00", 836 | "num_in_stock": 23, 837 | "num_allocated": null, 838 | "low_stock_threshold": null, 839 | "date_created": "2018-06-27T14:29:31.007Z", 840 | "date_updated": "2018-07-09T10:54:12.295Z" 841 | } 842 | }, 843 | { 844 | "model": "partner.stockrecord", 845 | "pk": 2, 846 | "fields": { 847 | "product": 3, 848 | "partner": 1, 849 | "partner_sku": "asdasd", 850 | "price_currency": "EUR", 851 | "price": "23.00", 852 | "num_in_stock": null, 853 | "num_allocated": null, 854 | "low_stock_threshold": null, 855 | "date_created": "2018-07-10T13:06:53.849Z", 856 | "date_updated": "2018-08-10T12:34:05.460Z" 857 | } 858 | }, 859 | { 860 | "model": "partner.stockrecord", 861 | "pk": 3, 862 | "fields": { 863 | "product": 4, 864 | "partner": 1, 865 | "partner_sku": "err56", 866 | "price_currency": "EUR", 867 | "price": "234.00", 868 | "num_in_stock": 345, 869 | "num_allocated": null, 870 | "low_stock_threshold": 12, 871 | "date_created": "2018-11-16T08:24:18.773Z", 872 | "date_updated": "2018-11-16T08:24:18.773Z" 873 | } 874 | }, 875 | { 876 | "model": "partner.stockrecord", 877 | "pk": 4, 878 | "fields": { 879 | "product": 5, 880 | "partner": 1, 881 | "partner_sku": "23478", 882 | "price_currency": "EUR", 883 | "price": "12.00", 884 | "num_in_stock": 34, 885 | "num_allocated": null, 886 | "low_stock_threshold": 8, 887 | "date_created": "2018-11-16T08:28:11.797Z", 888 | "date_updated": "2018-11-16T08:28:11.797Z" 889 | } 890 | }, 891 | { 892 | "model": "partner.stockrecord", 893 | "pk": 5, 894 | "fields": { 895 | "product": 7, 896 | "partner": 1, 897 | "partner_sku": "jik090", 898 | "price_currency": "EUR", 899 | "price": "12.00", 900 | "num_in_stock": 34, 901 | "num_allocated": null, 902 | "low_stock_threshold": 8, 903 | "date_created": "2018-11-16T08:28:11.797Z", 904 | "date_updated": "2018-11-16T08:28:11.797Z" 905 | } 906 | }, 907 | { 908 | "model": "partner.partner", 909 | "pk": 1, 910 | "fields": { 911 | "code": "mezelf", 912 | "name": "mezelf", 913 | "users": [] 914 | } 915 | } 916 | ] 917 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/fixtures/search/auth.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 1, 5 | "fields": { 6 | "password": "pbkdf2_sha256$100000$MHsdHUeOhHQK$6dUKOhYwm1iF+w1H7ixsBf5qkf3/QkU0xu+w7uMKmBY=", 7 | "last_login": "2018-08-17T13:40:31.929Z", 8 | "is_superuser": true, 9 | "username": "admin", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "admin@admin.admin", 13 | "is_staff": true, 14 | "is_active": true, 15 | "date_joined": "2018-06-26T07:27:51.349Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | }, 20 | { 21 | "model": "auth.user", 22 | "pk": 2, 23 | "fields": { 24 | "password": "pbkdf2_sha256$100000$bZPiNHKtnDcY$ifXtjF0HMxyeVAFsHfIDgfYHAqbrnXCHJ9fEywHFHwM=", 25 | "last_login": null, 26 | "is_superuser": false, 27 | "username": "regularuser", 28 | "first_name": "", 29 | "last_name": "", 30 | "email": "", 31 | "is_staff": false, 32 | "is_active": true, 33 | "date_joined": "2018-08-16T07:39:19.148Z", 34 | "groups": [], 35 | "user_permissions": [] 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/format.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import re 3 | import decimal 4 | from django.utils.translation import gettext_lazy as _ 5 | from oscar.templatetags.currency_filters import currency as currency_filter 6 | 7 | RANGE_REGEX = re.compile(r"(?P[\d\*\.]+)-(?P[\d\*\.]+)") 8 | 9 | 10 | def ranged( 11 | format_full=_("%(first)s - %(second)s"), 12 | format_first=_("%(first)s or more"), 13 | format_second=_("Up to %(second)s"), 14 | ): 15 | """Decorator for formatter functions to handle ranged data""" 16 | 17 | if callable(format_full): 18 | raise RuntimeError('Factory-only decorator. Use "@ranged()".') 19 | 20 | def wrap(func): 21 | @functools.wraps(func) 22 | def inner(key): 23 | parsed_key = RANGE_REGEX.match(str(key)) 24 | if parsed_key is None: 25 | return func(key) 26 | 27 | first = parsed_key.group("first") 28 | second = parsed_key.group("second") 29 | 30 | if first == "*": 31 | return format_second % {"second": func(second)} 32 | elif second == "*": 33 | return format_first % {"first": func(first)} 34 | else: 35 | return format_full % {"first": func(first), "second": func(second)} 36 | 37 | return inner 38 | 39 | return wrap 40 | 41 | 42 | @ranged() 43 | def currency(key): 44 | """ 45 | Formatter for facet keys as currency 46 | 47 | >>> currency("*-25.0") 48 | 'Up to €25.00' 49 | >>> currency("25.0-*") 50 | '€25.00 or more' 51 | >>> currency("25.0-23") 52 | '€25.00 - €23.00' 53 | 54 | """ 55 | return currency_filter(key) 56 | 57 | 58 | def to_int(value): 59 | try: 60 | return int(decimal.Decimal(value).to_integral_value(decimal.ROUND_HALF_UP)) 61 | except decimal.InvalidOperation: 62 | return 0 63 | 64 | 65 | @ranged() 66 | def integer(key): 67 | """ 68 | >>> integer("687987.8778.8978") 69 | 0 70 | >>> integer("henk") 71 | 0 72 | >>> integer("-5656.89889") 73 | -5657 74 | >>> integer("345.876867") 75 | 346 76 | """ 77 | return to_int(key) 78 | 79 | 80 | @ranged() 81 | def integer_ml(key): 82 | """ 83 | >>> integer_ml("687987.8778.8978") 84 | '0 ml' 85 | >>> integer_ml("henk") 86 | '0 ml' 87 | >>> integer_ml("-5656.89889") 88 | '-5657 ml' 89 | >>> integer_ml("345.876867") 90 | '346 ml' 91 | """ 92 | value = to_int(key) 93 | return f"{value} ml" 94 | 95 | 96 | @ranged() 97 | def decimal1(key): 98 | """ 99 | >>> str(decimal1(0.876876876)) 100 | '0.9' 101 | """ 102 | return decimal.Decimal(key).quantize(decimal.Decimal("0.1"), decimal.ROUND_HALF_UP) 103 | 104 | 105 | @ranged() 106 | def decimal2(key): 107 | """ 108 | >>> str(decimal2(0.876876876)) 109 | '0.88' 110 | """ 111 | return decimal.Decimal(key).quantize(decimal.Decimal("0.01"), decimal.ROUND_HALF_UP) 112 | 113 | 114 | @ranged() 115 | def decimal3(key): 116 | """ 117 | >>> str(decimal3(0.876876876)) 118 | '0.877' 119 | """ 120 | return decimal.Decimal(key).quantize( 121 | decimal.Decimal("0.001"), decimal.ROUND_HALF_UP 122 | ) 123 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/forms.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.forms.widgets import Input 6 | from django.utils.translation import gettext_lazy as _ 7 | from oscar.core.loading import feature_hidden 8 | 9 | from . import settings 10 | 11 | 12 | class SearchInput(Input): 13 | """ 14 | Defining a search type widget 15 | """ 16 | 17 | input_type = "search" 18 | 19 | 20 | class BaseSearchForm(forms.Form): 21 | """ 22 | Base form used for searches 23 | """ 24 | 25 | # Use a tabindex of 1 so that users can hit tab on any page and it will 26 | # focus on the search widget. 27 | q = forms.CharField( 28 | label=_("Search"), 29 | widget=SearchInput( 30 | attrs={ 31 | "placeholder": _("Search"), 32 | "tabindex": "1", 33 | "class": "form-control", 34 | "autocomplete": "off", 35 | } 36 | ), 37 | ) 38 | 39 | items_per_page = forms.TypedChoiceField( 40 | required=False, 41 | choices=[(x, x) for x in settings.ITEMS_PER_PAGE_CHOICES], 42 | coerce=int, 43 | empty_value=settings.DEFAULT_ITEMS_PER_PAGE, 44 | widget=forms.HiddenInput(), 45 | ) 46 | 47 | SORT_BY_CHOICES = settings.SORT_BY_CHOICES_SEARCH 48 | SORT_BY_MAP = settings.SORT_BY_MAP_SEARCH 49 | 50 | sort_by = forms.ChoiceField( 51 | label=_("Sort by"), choices=[], widget=forms.Select(), required=False 52 | ) 53 | 54 | class Media: 55 | js = ( 56 | "oscar/js/search/bootstrap3-typeahead.js", 57 | "oscar/js/search/autocomplete.js", 58 | ) 59 | 60 | def __init__(self, *args, **kwargs): 61 | self.selected_facets = kwargs.pop("selected_facets", []) 62 | super().__init__(*args, **kwargs) 63 | 64 | self.fields["sort_by"].choices = self.get_sort_choices() 65 | 66 | def has_items_per_page_choices(self): 67 | return len(self.get_items_per_page_choices()) > 1 68 | 69 | def get_items_per_page_choices(self): 70 | return self.fields["items_per_page"].choices 71 | 72 | def get_sort_params(self, clean_data): 73 | """ 74 | Return the parameters passed to es for sorting. 75 | :param clean_data: 76 | :return: 77 | """ 78 | return self.SORT_BY_MAP.get(clean_data.get("sort_by"), None) 79 | 80 | def get_sort_choices(self): 81 | return self.SORT_BY_CHOICES 82 | 83 | @property 84 | def selected_multi_facets(self): 85 | """ 86 | Validate and return the selected facets 87 | """ 88 | # Process selected facets into a dict(field->[*values]) to handle 89 | # multi-faceting 90 | selected_multi_facets = defaultdict(list) 91 | 92 | for facet_kv in self.selected_facets: 93 | if ":" not in facet_kv: 94 | continue 95 | 96 | # maxsplit on 1 to prevent error when there is another : in the value 97 | field_name, value = facet_kv.split(":", 1) 98 | selected_multi_facets[field_name].append(value) 99 | 100 | return selected_multi_facets 101 | 102 | 103 | class AutoCompleteForm(forms.Form): 104 | q = forms.CharField( 105 | widget=SearchInput( 106 | attrs={ 107 | "placeholder": _("Search"), 108 | "tabindex": "1", 109 | "class": "form-control", 110 | "autocomplete": "off", 111 | } 112 | ) 113 | ) 114 | 115 | class Media: 116 | js = ( 117 | "oscar/js/search/bootstrap3-typeahead.js", 118 | "oscar/js/search/autocomplete.js", 119 | ) 120 | 121 | 122 | class CatalogueSearchForm(BaseSearchForm): 123 | SORT_BY_CHOICES = settings.SORT_BY_CHOICES_CATALOGUE 124 | SORT_BY_MAP = settings.SORT_BY_MAP_CATALOGUE 125 | 126 | category = forms.IntegerField( 127 | required=False, label=_("Category"), widget=forms.HiddenInput() 128 | ) 129 | price_min = forms.FloatField(required=False, min_value=0) 130 | price_max = forms.FloatField(required=False, min_value=0) 131 | 132 | should_be_rendered_on_category = True 133 | 134 | def __init__(self, *args, **kwargs): 135 | super().__init__(*args, **kwargs) 136 | self.fields["q"].required = False 137 | 138 | def get_sort_choices(self): 139 | choices = super().get_sort_choices() 140 | if feature_hidden("reviews"): 141 | return [ 142 | (key, value) for (key, value) in choices if key != settings.TOP_RATED 143 | ] 144 | else: 145 | return choices 146 | 147 | def clean(self): 148 | cleaned_data = super().clean() 149 | # Validate price ranges 150 | price_min = cleaned_data.get("price_min") 151 | price_max = cleaned_data.get("price_max") 152 | if price_min and price_max and price_min > price_max: 153 | raise forms.ValidationError("Minimum price must exceed maximum price.") 154 | 155 | 156 | class BrowseCategoryForm(CatalogueSearchForm): 157 | pass 158 | 159 | 160 | class CategoryForm(CatalogueSearchForm): 161 | pass 162 | 163 | 164 | class SearchForm(BaseSearchForm): 165 | category = forms.IntegerField(required=False) 166 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/helpers.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_model, get_class 2 | 3 | from oscar_elasticsearch.search import settings 4 | 5 | chunked = get_class("search.utils", "chunked") 6 | Product = get_model("catalogue", "Product") 7 | Category = get_model("catalogue", "Category") 8 | 9 | ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex") 10 | CategoryElasticsearchIndex = get_class( 11 | "search.api.category", "CategoryElasticsearchIndex" 12 | ) 13 | 14 | 15 | def update_index_category(category_id, update_products=True): 16 | update_index_categories([category_id]) 17 | 18 | if update_products: 19 | product_ids = set( 20 | Product.objects.filter(categories__id=category_id).values_list( 21 | "pk", flat=True 22 | ) 23 | ) 24 | update_index_products(list(product_ids)) 25 | 26 | 27 | def update_index_categories(category_ids, update_products=True): 28 | for chunk in chunked(category_ids, settings.INDEXING_CHUNK_SIZE): 29 | categories = Category.objects.filter(id__in=chunk) 30 | CategoryElasticsearchIndex().update_or_create(categories) 31 | 32 | if update_products: 33 | product_ids = set( 34 | Product.objects.filter(categories__id__in=category_ids).values_list( 35 | "pk", flat=True 36 | ) 37 | ) 38 | update_index_products(list(product_ids)) 39 | 40 | 41 | def update_index_product(product_id): 42 | update_index_products([product_id]) 43 | 44 | 45 | def update_index_products(product_ids): 46 | for chunk in chunked(product_ids, settings.INDEXING_CHUNK_SIZE): 47 | products = Product.objects.filter(id__in=chunk) 48 | ProductElasticsearchIndex().update_or_create(products) 49 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/indexing/__init__.py: -------------------------------------------------------------------------------- 1 | from .indexer import * 2 | from .settings import * 3 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/indexing/indexer.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from django.utils.crypto import get_random_string 4 | from django.utils.text import format_lazy 5 | from django.utils.encoding import force_str 6 | 7 | from oscar.core.loading import get_class 8 | 9 | from elasticsearch.helpers import bulk 10 | from elasticsearch.exceptions import NotFoundError 11 | 12 | from oscar_elasticsearch.search.api.base import BaseModelIndex 13 | 14 | es = get_class("search.backend", "es") 15 | 16 | 17 | class Indexer(object): 18 | def __init__(self, name, mappings, settings): 19 | self.name = name 20 | self.alias_name = format_lazy( 21 | "{name}_{suffix}", name=name, suffix=get_random_string(7).lower() 22 | ) 23 | self.mappings = mappings 24 | self.settings = settings 25 | 26 | def execute(self, documents): 27 | self.bulk_index(documents, self.alias_name) 28 | 29 | def start(self): 30 | # Create alias 31 | self.create(self.alias_name) 32 | 33 | def index(self, _id, document, current_alias=None): 34 | if current_alias is None: 35 | current_alias = self.get_current_alias() 36 | 37 | _index = force_str(current_alias) 38 | 39 | es.index(index=_index, id=_id, document=document, ignore=[400]) 40 | 41 | def bulk_index(self, documents, current_alias=None): 42 | if current_alias is None: 43 | current_alias = self.get_current_alias() 44 | 45 | _index = force_str(current_alias) 46 | 47 | docs = [] 48 | for doc in documents: 49 | doc["_index"] = _index 50 | docs.append(doc) 51 | 52 | bulk(es, docs, ignore=[400]) 53 | 54 | def get_current_alias(self): 55 | aliasses = list( 56 | es.indices.get_alias(name=self.name, ignore_unavailable=True).keys() 57 | ) 58 | if aliasses: 59 | return aliasses[0] 60 | 61 | return self.alias_name 62 | 63 | def finish(self): 64 | es.indices.refresh(index=self.alias_name) 65 | 66 | # Check if alias exists for indice 67 | if es.indices.exists_alias(name=self.name): 68 | # Get alisases 69 | aliased_indices = es.indices.get_alias( 70 | name=self.name, ignore_unavailable=True 71 | ).keys() 72 | 73 | # Link the new alias to the old indice 74 | es.indices.put_alias(name=self.name, index=self.alias_name) 75 | 76 | # Cleanup old aliased 77 | for index in aliased_indices: 78 | if index != self.alias_name: 79 | self.delete(index) 80 | else: 81 | self.delete(self.name) 82 | 83 | # No indices yet, make alias from original name to alias name 84 | es.indices.put_alias(name=self.name, index=self.alias_name) 85 | 86 | def create(self, name): 87 | return es.indices.create( 88 | index=name, body={"settings": self.settings, "mappings": self.mappings} 89 | ) 90 | 91 | def delete(self, name): 92 | try: 93 | es.indices.delete(index=name) 94 | except NotFoundError: 95 | pass 96 | 97 | def delete_doc(self, _id): 98 | try: 99 | return es.delete(index=self.get_current_alias(), id=_id) 100 | except NotFoundError: 101 | pass 102 | 103 | 104 | class ESModelIndexer(BaseModelIndex): 105 | def __init__(self): 106 | super().__init__() 107 | self.indexer = Indexer( 108 | self.get_index_name(), self.get_index_mapping(), self.get_index_settings() 109 | ) 110 | 111 | def make_documents(self, objects): 112 | raise NotImplementedError( 113 | "Please implement `make_documents` on your indexer class" 114 | ) 115 | 116 | def update_or_create(self, objects): 117 | es_data = self.make_documents(objects) 118 | return self.indexer.bulk_index(es_data) 119 | 120 | def index(self, obj): 121 | (es_data,) = self.make_documents([obj]) 122 | self.indexer.index(obj.id, es_data["_source"]) 123 | 124 | @contextmanager 125 | def reindex(self): 126 | """ 127 | Example usage: 128 | with CategoryElasticsearchIndex().reindex() as index: 129 | for chunk in chunked(categories, settings.INDEXING_CHUNK_SIZE): 130 | index.reindex_objects(chunk) 131 | """ 132 | self.indexer.start() 133 | yield self 134 | self.indexer.finish() 135 | 136 | def reindex_objects(self, objects): 137 | es_data = self.make_documents(objects) 138 | return self.indexer.execute(es_data) 139 | 140 | def delete(self, _id): 141 | return self.indexer.delete_doc(_id) 142 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/indexing/settings.py: -------------------------------------------------------------------------------- 1 | from oscar_elasticsearch.search.utils import get_index_settings 2 | from oscar_elasticsearch.search.settings import ( 3 | INDEX_PREFIX, 4 | FACETS, 5 | AUTOCOMPLETE_CONTEXTS, 6 | MAX_GRAM, 7 | SEARCH_FIELDS, 8 | ) 9 | 10 | 11 | def get_oscar_index_settings(): 12 | return get_index_settings(MAX_GRAM) 13 | 14 | 15 | OSCAR_INDEX_MAPPING = { 16 | "properties": { 17 | "id": {"type": "integer", "store": True}, 18 | "content_type": {"type": "keyword", "store": True}, 19 | "title": { 20 | "type": "text", 21 | "analyzer": "lowercasewhitespace", 22 | "fielddata": True, 23 | "copy_to": "_all_text", 24 | "fields": {"raw": {"type": "keyword"}}, 25 | }, 26 | "search_title": { 27 | "type": "text", 28 | "analyzer": "title_analyzer", 29 | "search_analyzer": "standard", 30 | "fields": { 31 | "reversed": { 32 | "type": "text", 33 | "analyzer": "reversed_title_analyzer", 34 | "search_analyzer": "standard", 35 | } 36 | }, 37 | }, 38 | "is_public": {"type": "boolean"}, 39 | "code": {"type": "keyword", "copy_to": "_all_text"}, 40 | "slug": {"type": "keyword", "copy_to": "_all_text"}, 41 | "description": { 42 | "type": "text", 43 | "analyzer": "standard", 44 | "copy_to": "_all_text", 45 | }, 46 | "absolute_url": {"type": "keyword"}, 47 | "_all_text": {"type": "text", "analyzer": "standard"}, 48 | } 49 | } 50 | 51 | 52 | def get_attributes_to_index(): 53 | attrs_properties = {} 54 | 55 | for facet in FACETS: 56 | if "attrs." in facet["name"]: 57 | name = facet["name"].replace("attrs.", "") 58 | facet_type = "keyword" 59 | if facet["type"] == "range": 60 | facet_type = "double" 61 | 62 | attrs_properties[name] = {"type": facet_type, "copy_to": "_all_text"} 63 | 64 | return attrs_properties 65 | 66 | 67 | def get_products_index_mapping(): 68 | OSCAR_PRODUCTS_INDEX_MAPPING = OSCAR_INDEX_MAPPING.copy() 69 | 70 | OSCAR_PRODUCTS_INDEX_MAPPING["properties"].update( 71 | { 72 | "upc": { 73 | "type": "text", 74 | "analyzer": "technical_analyzer", 75 | "fielddata": True, 76 | "search_analyzer": "technical_search_analyzer", 77 | }, 78 | "parent_id": {"type": "integer"}, 79 | "product_class": {"type": "keyword"}, 80 | "structure": {"type": "text", "copy_to": "_all_text"}, 81 | "rating": {"type": "float"}, 82 | "priority": {"type": "integer"}, 83 | "price": {"type": "double"}, 84 | "num_available": {"type": "integer"}, 85 | "is_available": {"type": "boolean"}, 86 | "currency": {"type": "text", "copy_to": "_all_text"}, 87 | "date_created": {"type": "date"}, 88 | "date_updated": {"type": "date"}, 89 | "string_attrs": {"type": "text", "copy_to": "_all_text"}, 90 | "popularity": {"type": "integer"}, 91 | "status": {"type": "text"}, 92 | "categories": { 93 | "type": "nested", 94 | "properties": { 95 | "id": {"type": "integer"}, 96 | "description": { 97 | "type": "text", 98 | "copy_to": "_all_text", 99 | }, 100 | "name": { 101 | "type": "text", 102 | "copy_to": "_all_text", 103 | }, 104 | "ancestor_names": { 105 | "type": "text", 106 | "copy_to": "_all_text", 107 | }, 108 | }, 109 | }, 110 | "attrs": {"type": "object", "properties": get_attributes_to_index()}, 111 | "suggest": {"type": "completion", "contexts": AUTOCOMPLETE_CONTEXTS}, 112 | } 113 | ) 114 | 115 | return OSCAR_PRODUCTS_INDEX_MAPPING 116 | 117 | 118 | def get_categories_index_mapping(): 119 | OSCAR_CATEGORIES_INDEX_MAPPING = OSCAR_INDEX_MAPPING.copy() 120 | OSCAR_CATEGORIES_INDEX_MAPPING.update( 121 | {"properties": {"full_name": {"type": "text"}, "full_slug": {"type": "text"}}} 122 | ) 123 | return OSCAR_CATEGORIES_INDEX_MAPPING 124 | 125 | 126 | OSCAR_PRODUCTS_INDEX_NAME = "%s__catalogue_product" % INDEX_PREFIX 127 | OSCAR_CATEGORIES_INDEX_NAME = "%s__catalogue_category" % INDEX_PREFIX 128 | OSCAR_PRODUCT_SEARCH_FIELDS = SEARCH_FIELDS + ["upc^2"] 129 | OSCAR_CATEGORY_SEARCH_FIELDS = SEARCH_FIELDS 130 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 11 | "PO-Revision-Date: 2020-07-03 11:39+0200\n" 12 | "Language: de\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | "Last-Translator: Hans Ringelberg \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 2.3.1\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "%(first)s - %(second)s" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "%(first)s oder mehr" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "Bis zu %(second)s" 35 | 36 | #: templates/oscar/search/results.html:31 37 | msgid "Found 0 results." 38 | msgstr "0 Ergebnisse gefunden." 39 | 40 | #: templates/oscar/search/results.html:34 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | " Did you mean \"%(suggestion)s\"?\n" 46 | " " 47 | msgstr "" 48 | "\n" 49 | " Meinten Sie \"%(suggestion)s\"?\n" 51 | " " 52 | 53 | # forms.py:51 54 | msgid "Most popular" 55 | msgstr "Am beliebtesten" 56 | 57 | #: dashboard/catalogue/forms.py:6 58 | msgid "All fields" 59 | msgstr "Alle Felder" 60 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "" 35 | 36 | #: templates/oscar/search/results.html:31 37 | msgid "Found 0 results." 38 | msgstr "" 39 | 40 | 41 | #: templates/oscar/search/results.html:34 42 | #, python-format 43 | msgid "" 44 | "\n" 45 | " Did you mean \"%(suggestion)s\"?\n" 47 | " " 48 | msgstr "" 49 | 50 | # forms.py:51 51 | msgid "Most popular" 52 | msgstr "" 53 | 54 | #: dashboard/catalogue/forms.py:6 55 | msgid "All fields" 56 | msgstr "" 57 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: Wagtail ES\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 11 | "PO-Revision-Date: 2020-06-19 13:54+0200\n" 12 | "Language: es_ES\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | "Last-Translator: \n" 18 | "Language-Team: hans@keykeg.com\n" 19 | "X-Generator: Poedit 2.3.1\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "%(first)s - %(second)s" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "%(first)s o más" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "Hasta %(second)s" 35 | 36 | #: templates/oscar/search/results.html:31 37 | msgid "Found 0 results." 38 | msgstr "Se encontró 0 resultados." 39 | 40 | #: templates/oscar/search/results.html:34 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | " Did you mean \"%(suggestion)s\"?\n" 46 | " " 47 | msgstr "" 48 | "\n" 49 | " ¿Quiso decir \"%(suggestion)s\"?\n" 51 | " " 52 | 53 | # forms.py:51 54 | msgid "Most popular" 55 | msgstr "Más popular" 56 | 57 | #: dashboard/catalogue/forms.py:6 58 | msgid "All fields" 59 | msgstr "Todos los campos" 60 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 11 | "PO-Revision-Date: 2020-07-09 08:17+0200\n" 12 | "Language: fr\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 17 | "Last-Translator: Hans Ringelberg \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 2.3.1\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "%(first)s - %(second)s" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "%(first)s ou plus" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "Jusqu'à %(second)s" 35 | 36 | #: templates/oscar/search/results.html:31 37 | msgid "Found 0 results." 38 | msgstr "Trouvé 0 résultats." 39 | 40 | #: templates/oscar/search/results.html:34 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | " Did you mean \"%(suggestion)s\"?\n" 46 | " " 47 | msgstr "" 48 | "\n" 49 | " Vouliez-vous dire \"%(suggestion)s\"?\n" 51 | " " 52 | 53 | # forms.py:51 54 | msgid "Most popular" 55 | msgstr "Plus populaire" 56 | 57 | #: dashboard/catalogue/forms.py:6 58 | msgid "All fields" 59 | msgstr "Tous les champs" 60 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: wagtail IT\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 11 | "PO-Revision-Date: 2020-06-18 16:27+0200\n" 12 | "Language: it_IT\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | "Last-Translator: Hans Ringelberg \n" 18 | "Language-Team: hans@keykeg.com\n" 19 | "X-Generator: Poedit 2.3.1\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "%(first)s - %(second)s" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "%(first)s o più" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "Fino a %(second)s" 35 | 36 | #: templates/oscar/search/results.html:31 37 | msgid "Found 0 results." 38 | msgstr "Trovato 0 risultati." 39 | 40 | #: templates/oscar/search/results.html:34 41 | #, python-format 42 | msgid "" 43 | "\n" 44 | " Did you mean \"%(suggestion)s\"?\n" 46 | " " 47 | msgstr "" 48 | "\n" 49 | " Intendeva \"%(suggestion)s\"?\n" 51 | " " 52 | 53 | # forms.py:51 54 | msgid "Most popular" 55 | msgstr "Più popolari" 56 | 57 | #: dashboard/catalogue/forms.py:6 58 | msgid "All fields" 59 | msgstr "Tutti i campi" 60 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oscar_elasticsearch/search/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2019-07-24 09:02+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: format.py:11 22 | #, python-format 23 | msgid "%(first)s - %(second)s" 24 | msgstr "%(first)s - %(second)s" 25 | 26 | #: format.py:12 27 | #, python-format 28 | msgid "%(first)s or more" 29 | msgstr "%(first)s of meer" 30 | 31 | #: format.py:13 32 | #, python-format 33 | msgid "Up to %(second)s" 34 | msgstr "Tot %(second)s" 35 | 36 | #: templates/oscar/search/results.html:16 37 | #, python-format 38 | msgid "" 39 | "\n" 40 | " Found %(num_results)s results, showing " 41 | "%(start)s to %(end)s.\n" 42 | " " 43 | msgstr "" 44 | "\n" 45 | " %(num_results)s resultaten, " 46 | "%(start)s tot en met %(end)s.\n" 47 | " " 48 | 49 | #: templates/oscar/search/results.html:20 50 | #, python-format 51 | msgid "" 52 | "\n" 53 | " Found %(num_results)s result.\n" 54 | " " 55 | msgid_plural "" 56 | "\n" 57 | " Found %(num_results)s results.\n" 58 | " " 59 | msgstr[0] "" 60 | "\n" 61 | " %(num_results)s resultaten.\n" 62 | " " 63 | msgstr[1] "" 64 | "\n" 65 | " %(num_results)s resultaten.\n" 66 | " " 67 | 68 | #: templates/oscar/search/results.html:31 69 | msgid "Found 0 results." 70 | msgstr "0 resultaten gevonden." 71 | 72 | 73 | #: templates/oscar/search/results.html:34 74 | #, python-format 75 | msgid "" 76 | "\n" 77 | " Did you mean \"%(suggestion)s\"?\n" 79 | " " 80 | msgstr "" 81 | "\n" 82 | " Bedoelde je \"%(suggestion)s\"?\n" 84 | " " 85 | 86 | # forms.py:51 87 | msgid "Most popular" 88 | msgstr "Meest verkocht" 89 | 90 | #: dashboard/catalogue/forms.py:6 91 | msgid "All fields" 92 | msgstr "Alle geïndexeerde velden" 93 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/management/__init__.py -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/management/commands/__init__.py -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/determine_facets.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import defaultdict 3 | from django.core.management.base import BaseCommand 4 | from django.db import models 5 | from oscar.core.loading import get_model 6 | 7 | ProductAttribute = get_model("catalogue", "ProductAttribute") 8 | 9 | 10 | class Command(BaseCommand): 11 | """ 12 | Inspect the product attributes to determine facets. 13 | 14 | Usage: 15 | 16 | first run the command without flags to see the attribute and the numbber of 17 | occurrences. You can now guess which attributes would make good facets. 18 | 19 | next run the command with the --json flag to output facets config, and edit 20 | it to remove the unwanted facets. 21 | 22 | Last determine if some of the facets are better sorted based on count 23 | instead of alfabetically and change it like this: 24 | 25 | { 26 | "name": "afmrugpel", 27 | "label": "afmrugpel", 28 | "type": "term", 29 | "order": { "_count" : "desc" } 30 | } 31 | """ 32 | 33 | help = __doc__ 34 | 35 | def add_arguments(self, parser): 36 | parser.add_argument("--json", action="store_true", help="Show facets as json") 37 | 38 | def handle(self, *args, **options): 39 | facets = defaultdict(int) 40 | facet_labels = {} 41 | for code, label, num_products in ProductAttribute.objects.annotate( 42 | num_products=models.Count("product") 43 | ).values_list("code", "name", "num_products"): 44 | facets[code] += num_products 45 | facet_labels[code] = label 46 | 47 | sorted_facets = sorted(facets.items(), key=lambda x: x[1], reverse=True) 48 | if options["json"]: 49 | self.stdout.write( 50 | json.dumps( 51 | [ 52 | {"name": code, "label": facet_labels[code], "type": "term"} 53 | for code, _ in sorted_facets 54 | ], 55 | indent=4, 56 | ) 57 | ) 58 | else: 59 | for code, num_products in sorted_facets: 60 | self.stdout.write("%s %s\n" % (code, num_products)) 61 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/update_index_categories.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from oscar.core.loading import get_class, get_model 4 | 5 | from oscar_elasticsearch.search import settings 6 | 7 | chunked = get_class("search.utils", "chunked") 8 | CategoryElasticsearchIndex = get_class( 9 | "search.api.category", "CategoryElasticsearchIndex" 10 | ) 11 | 12 | Category = get_model("catalogue", "Category") 13 | 14 | 15 | class Command(BaseCommand): 16 | def handle(self, *args, **options): 17 | categories = Category.objects.all() 18 | 19 | with CategoryElasticsearchIndex().reindex() as index: 20 | for chunk in chunked(categories, settings.INDEXING_CHUNK_SIZE): 21 | index.reindex_objects(chunk) 22 | self.stdout.write(".", ending="") 23 | self.stdout.flush() # Ensure the dots are displayed immediately 24 | 25 | self.stdout.write( 26 | self.style.SUCCESS( 27 | "\n%i categories successfully indexed" % categories.count() 28 | ) 29 | ) 30 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/update_index_products.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.core.management.base import BaseCommand 3 | from oscar.core.loading import get_class, get_model 4 | from oscar_elasticsearch.search import settings 5 | 6 | chunked = get_class("search.utils", "chunked") 7 | ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex") 8 | Product = get_model("catalogue", "Product") 9 | 10 | 11 | class Command(BaseCommand): 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | "--debug", 15 | action="store_true", 16 | help="Run command in debug mode", 17 | ) 18 | 19 | def handle(self, *args, **options): 20 | if options["debug"]: 21 | return self.handle_debug() 22 | 23 | products = Product.objects.all() 24 | products_total = products.count() 25 | 26 | with ProductElasticsearchIndex().reindex() as index: 27 | for chunk in chunked(products, settings.INDEXING_CHUNK_SIZE): 28 | index.reindex_objects(chunk) 29 | self.stdout.write(".", ending="") 30 | self.stdout.flush() # Ensure the dots are displayed immediately 31 | 32 | self.stdout.write( 33 | self.style.SUCCESS("\n%i products successfully indexed" % products_total) 34 | ) 35 | 36 | def handle_debug(self): 37 | """ 38 | Display more detailed information about the indexing process, such as the time it took to index each chunk. 39 | This is useful when debugging the performance of the indexing process. 40 | """ 41 | overall_start_time = time.time() 42 | products = Product.objects.all() 43 | products_total = products.count() 44 | total_chunks = products_total / settings.INDEXING_CHUNK_SIZE 45 | processed_chunks = 0 46 | 47 | with ProductElasticsearchIndex().reindex() as index: 48 | for chunk in chunked(products, settings.INDEXING_CHUNK_SIZE): 49 | chunk_index_time = time.time() 50 | index.reindex_objects(chunk) 51 | processed_chunks += 1 52 | chunk_duration = time.time() - chunk_index_time 53 | 54 | self.stdout.write( 55 | self.style.SUCCESS( 56 | "Processed chunk %i of %i (%i/%s products indexed) in %.2f seconds" 57 | % ( 58 | processed_chunks, 59 | total_chunks, 60 | min( 61 | processed_chunks * settings.INDEXING_CHUNK_SIZE, 62 | products_total, 63 | ), 64 | products_total, 65 | chunk_duration, 66 | ) 67 | ) 68 | ) 69 | 70 | total_duration = time.time() - overall_start_time 71 | self.stdout.write( 72 | self.style.SUCCESS( 73 | "\n%i products successfully indexed in %.2f seconds" 74 | % (products_total, total_duration) 75 | ) 76 | ) 77 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/update_index_registered.py: -------------------------------------------------------------------------------- 1 | from oscar_elasticsearch.search.registry import elasticsearch_registry 2 | from django.core.management.base import BaseCommand 3 | 4 | from oscar.core.loading import get_class 5 | 6 | from oscar_elasticsearch.search import settings 7 | 8 | chunked = get_class("search.utils", "chunked") 9 | 10 | 11 | class Command(BaseCommand): 12 | def handle(self, *args, **options): 13 | for Index in elasticsearch_registry.indexes: 14 | index = Index() 15 | 16 | self.stdout.write( 17 | self.style.SUCCESS( 18 | "\n Start indexing index: %s" % index.get_index_name() 19 | ) 20 | ) 21 | 22 | index_queryset = index.get_queryset() 23 | with index.reindex() as index: 24 | for chunk in chunked(index_queryset, settings.INDEXING_CHUNK_SIZE): 25 | index.reindex_objects(chunk) 26 | self.stdout.write(".", ending="") 27 | self.stdout.flush() # Ensure the dots are displayed immediately 28 | 29 | self.stdout.write( 30 | self.style.SUCCESS( 31 | "\n%i %s successfully indexed" 32 | % (index_queryset.count(), index.get_index_name()) 33 | ) 34 | ) 35 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/management/commands/update_oscar_index.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.core.management import call_command 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **options): 7 | call_command("update_index_products") 8 | call_command("update_index_categories") 9 | call_command("update_index_registered") 10 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/__init__.py: -------------------------------------------------------------------------------- 1 | from .categories import * 2 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/categories.py: -------------------------------------------------------------------------------- 1 | import odin 2 | 3 | from django.utils.html import strip_tags 4 | 5 | from oscar.core.loading import get_class, get_model 6 | 7 | OscarResource = get_class("oscar_odin.resources.base", "OscarResource") 8 | CategoryResource = get_class("oscar_odin.resources.catalogue", "CategoryResource") 9 | 10 | OscarBaseMapping = get_class("oscar_odin.mappings.common", "OscarBaseMapping") 11 | 12 | OscarElasticSearchResourceMixin = get_class( 13 | "search.mappings.mixins", "OscarElasticSearchResourceMixin" 14 | ) 15 | 16 | Category = get_model("catalogue", "Category") 17 | 18 | 19 | class CategoryElasticSearchResource(OscarElasticSearchResourceMixin): 20 | full_name: str 21 | full_slug: str 22 | 23 | 24 | class CategoryMapping(OscarBaseMapping): 25 | from_resource = CategoryResource 26 | to_resource = CategoryElasticSearchResource 27 | 28 | @odin.assign_field 29 | def content_type(self) -> str: 30 | return "catalogue.category" 31 | 32 | @odin.map_field(from_field="name", to_field=["title", "search_title"]) 33 | def title(self, name) -> str: 34 | return name, name 35 | 36 | @odin.assign_field 37 | def description(self) -> str: 38 | return strip_tags(self.source.description) 39 | 40 | @odin.assign_field 41 | def code(self) -> str: 42 | if self.source.code: 43 | return self.source.code 44 | 45 | return "%s-%s" % (self.source.slug, self.source.id) 46 | 47 | 48 | class ElasticSearchResource(OscarResource): 49 | _index: str 50 | _id: str 51 | _source: CategoryElasticSearchResource 52 | 53 | 54 | class CategoryElasticSearchMapping(OscarBaseMapping): 55 | from_resource = CategoryResource 56 | to_resource = ElasticSearchResource 57 | 58 | mappings = (odin.define(from_field="id", to_field="_id"),) 59 | 60 | @odin.assign_field 61 | def _source(self) -> str: 62 | return CategoryMapping.apply(self.source) 63 | 64 | @odin.assign_field 65 | def _index(self) -> str: 66 | return self.context.get("_index") 67 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/mixins.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_class 2 | 3 | OscarResource = get_class("oscar_odin.resources.base", "OscarResource") 4 | 5 | 6 | class OscarElasticSearchResourceMixin(OscarResource): 7 | id: str 8 | content_type: str 9 | title: str 10 | is_public: bool 11 | code: str 12 | description: str 13 | absolute_url: str 14 | slug: str 15 | search_title: str 16 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/products/__init__.py: -------------------------------------------------------------------------------- 1 | import odin 2 | 3 | from oscar.core.loading import get_class 4 | 5 | OscarResource = get_class("oscar_odin.resources.base", "OscarResource") 6 | ProductResource = get_class("oscar_odin.resources.catalogue", "ProductResource") 7 | CategoryResource = get_class("oscar_odin.resources.catalogue", "CategoryResource") 8 | 9 | OscarBaseMapping = get_class("oscar_odin.mappings.common", "OscarBaseMapping") 10 | 11 | ProductElasticSearchResource = get_class( 12 | "search.mappings.products.resources", "ProductElasticSearchResource" 13 | ) 14 | ProductMapping = get_class("search.mappings.products.mappings", "ProductMapping") 15 | 16 | 17 | class ElasticSearchResource(OscarResource): 18 | _index: str 19 | _id: str 20 | _source: ProductElasticSearchResource 21 | _op_type: str = "index" 22 | 23 | 24 | class ProductElasticSearchMapping(OscarBaseMapping): 25 | from_resource = ProductResource 26 | to_resource = ElasticSearchResource 27 | 28 | register_mapping = False 29 | 30 | mappings = (odin.define(from_field="id", to_field="_id"),) 31 | 32 | @odin.assign_field 33 | def _source(self) -> str: 34 | return ProductMapping.apply(self.source, self.context) 35 | 36 | @odin.assign_field 37 | def _index(self) -> str: 38 | return self.context.get("_index") 39 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/products/mappings.py: -------------------------------------------------------------------------------- 1 | import odin 2 | 3 | from django.utils import timezone 4 | from django.utils.html import strip_tags 5 | from django.db.models import QuerySet 6 | 7 | from dateutil.relativedelta import relativedelta 8 | 9 | from oscar.core.loading import get_model, get_class 10 | 11 | from oscar_elasticsearch.search.constants import ( 12 | ES_CTX_PUBLIC, 13 | ES_CTX_AVAILABLE, 14 | ES_CTX_BROWSABLE, 15 | ) 16 | from oscar_elasticsearch.search import settings 17 | 18 | Product = get_model("catalogue", "Product") 19 | Line = get_model("order", "Line") 20 | 21 | ProductResource = get_class("oscar_odin.resources.catalogue", "ProductResource") 22 | CategoryResource = get_class("oscar_odin.resources.catalogue", "CategoryResource") 23 | 24 | OscarBaseMapping = get_class("oscar_odin.mappings.common", "OscarBaseMapping") 25 | 26 | OscarElasticSearchResourceMixin = get_class( 27 | "search.mappings.mixins", "OscarElasticSearchResourceMixin" 28 | ) 29 | get_attributes_to_index = get_class( 30 | "search.indexing.settings", "get_attributes_to_index" 31 | ) 32 | 33 | CategoryElasticSearchRelatedResource = get_class( 34 | "search.mappings.products.resources", "CategoryElasticSearchRelatedResource" 35 | ) 36 | ProductElasticSearchResource = get_class( 37 | "search.mappings.products.resources", "ProductElasticSearchResource" 38 | ) 39 | 40 | ATTRIBUTES_TO_INDEX = get_attributes_to_index().keys() 41 | 42 | 43 | class CategoryRelatedMapping(OscarBaseMapping): 44 | from_resource = CategoryResource 45 | to_resource = CategoryElasticSearchRelatedResource 46 | 47 | @odin.assign_field 48 | def description(self) -> str: 49 | return strip_tags(self.source.description) 50 | 51 | @odin.assign_field 52 | def ancestor_names(self) -> str: 53 | """Map names of all of the category ancestors.""" 54 | names = [] 55 | if self.source.id in self.context["category_ancestors"]: 56 | names = [ 57 | self.context["category_titles"][ancestor_id] 58 | for ancestor_id in self.context["category_ancestors"][self.source.id] 59 | ] 60 | return " | ".join(names) 61 | 62 | 63 | class ProductMapping(OscarBaseMapping): 64 | from_resource = ProductResource 65 | to_resource = ProductElasticSearchResource 66 | 67 | register_mapping = False 68 | 69 | mappings = ( 70 | odin.define(from_field="upc", to_field="code_auto_complete"), 71 | odin.define(from_field="upc", to_field="code"), 72 | odin.define(from_field="is_available_to_buy", to_field="is_available"), 73 | ) 74 | 75 | @odin.map_field(from_field="product_class") 76 | def product_class(self, obj): 77 | return obj.slug 78 | 79 | @odin.map_field(from_field="priority") 80 | def priority(self, priority): 81 | if ( 82 | not self.source.is_available_to_buy 83 | and settings.PRIORITIZE_AVAILABLE_PRODUCTS 84 | ): 85 | return -1 86 | 87 | return priority 88 | 89 | @odin.assign_field 90 | def popularity(self): 91 | # In our search.api.product make_documents method, we annotate the popularity, this way 92 | # we don't have to do N+1 queries to get the popularity of each product. 93 | if hasattr(self.source, "model_instance") and hasattr( 94 | self.source.model_instance, "popularity" 95 | ): 96 | return self.source.model_instance.popularity 97 | 98 | # Fallback to n+1 query, though, try to avoid this. 99 | months_to_run = settings.MONTHS_TO_RUN_ANALYTICS 100 | orders_above_date = timezone.now() - relativedelta(months=months_to_run) 101 | 102 | return Line.objects.filter( 103 | product_id=self.source.id, order__date_placed__gte=orders_above_date 104 | ).count() 105 | 106 | @odin.assign_field 107 | def content_type(self) -> str: 108 | return "catalogue.product" 109 | 110 | @odin.assign_field(to_list=True) 111 | def categories(self) -> str: 112 | return CategoryRelatedMapping.apply(self.source.categories, self.context) 113 | 114 | @odin.map_field(from_field="attributes") 115 | def attrs(self, attributes): 116 | attrs = {} 117 | for code in ATTRIBUTES_TO_INDEX: 118 | if code in attributes: 119 | attribute = attributes[code] 120 | 121 | if isinstance(attribute, QuerySet): 122 | attrs[code] = [str(o) for o in attribute] 123 | else: 124 | attrs[code] = str(attribute) 125 | 126 | return attrs 127 | 128 | @odin.assign_field(to_list=True) 129 | def status(self): 130 | ctx = [] 131 | 132 | if not self.source.is_public: 133 | return ["n"] 134 | 135 | ctx.append(ES_CTX_PUBLIC) 136 | 137 | # non public items are not available or browsable 138 | if self.source.is_available_to_buy: 139 | ctx.append(ES_CTX_AVAILABLE) 140 | 141 | # depending on FILTER_AVAILABLE things are browsable only if 142 | # they are available 143 | is_browsable = ( 144 | self.source.structure == Product.STANDALONE 145 | or self.source.structure == Product.PARENT 146 | ) 147 | if not settings.FILTER_AVAILABLE and is_browsable: 148 | ctx.append(ES_CTX_BROWSABLE) 149 | elif self.source.is_available_to_buy and is_browsable: 150 | ctx.append(ES_CTX_BROWSABLE) 151 | 152 | return ctx 153 | 154 | @odin.assign_field(to_list=True) 155 | def string_attrs(self): 156 | attrs = [str(a) for a in self.source.attributes.values()] 157 | if self.source.structure == Product.PARENT: 158 | for child in self.source.children: 159 | attrs.append(child.title) 160 | attrs.append(child.upc) 161 | attrs.extend([str(a) for a in child.attributes.values()]) 162 | 163 | return attrs 164 | 165 | @odin.map_field( 166 | from_field=settings.AUTOCOMPLETE_SEARCH_FIELDS, to_field="suggest", to_list=True 167 | ) 168 | def suggest(self, *args): 169 | return list(args) 170 | 171 | @odin.map_field( 172 | from_field="title", to_field=["title", "search_title", "title_auto_complete"] 173 | ) 174 | def title(self, title): 175 | return title, title, title 176 | 177 | @odin.assign_field 178 | def parent_id(self): 179 | if self.source.parent: 180 | return self.source.parent.id 181 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/mappings/products/resources.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from typing import Optional, List 4 | 5 | from datetime import datetime 6 | 7 | from oscar.core.loading import get_class 8 | 9 | from oscar_odin.fields import DecimalField 10 | 11 | from oscar_elasticsearch.search.mappings.mixins import OscarElasticSearchResourceMixin 12 | 13 | OscarResource = get_class("oscar_odin.resources.base", "OscarResource") 14 | 15 | 16 | class CategoryElasticSearchRelatedResource(OscarResource): 17 | id: int 18 | description: str 19 | name: str 20 | ancestor_names: str 21 | 22 | 23 | class ProductElasticSearchResource(OscarElasticSearchResourceMixin): 24 | upc: str 25 | title_auto_complete: str 26 | code_auto_complete: str 27 | structure: str 28 | rating: Optional[float] 29 | priority: int 30 | parent_id: Optional[int] 31 | product_class: Optional[int] 32 | price: Decimal = DecimalField() 33 | currency: str 34 | num_available: int 35 | is_available: bool 36 | categories: List[CategoryElasticSearchRelatedResource] 37 | attrs: dict 38 | date_created: datetime 39 | date_updated: datetime 40 | string_attrs: List[str] 41 | popularity: int 42 | status: List[str] 43 | suggest: List[str] 44 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/registry.py: -------------------------------------------------------------------------------- 1 | class ElasticsearchRegistry: 2 | _indexes = [] 3 | 4 | def register(self, cls): 5 | self._indexes.append(cls) 6 | return cls 7 | 8 | @property 9 | def indexes(self): 10 | return self._indexes 11 | 12 | 13 | elasticsearch_registry = ElasticsearchRegistry() 14 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/settings.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wildcard-import,unused-wildcard-import 2 | from django.conf import settings 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | # from extendedsearch.settings import * 6 | from .constants import ES_CTX_AVAILABLE, ES_CTX_PUBLIC 7 | 8 | HANDLE_STOCKRECORD_CHANGES = getattr( 9 | settings, "OSCAR_ELASTICSEARCH_HANDLE_STOCKRECORD_CHANGES", True 10 | ) 11 | MIN_NUM_BUCKETS = getattr(settings, "OSCAR_ELASTICSEARCH_MIN_NUM_BUCKETS", 2) 12 | FILTER_AVAILABLE = getattr(settings, "OSCAR_ELASTICSEARCH_FILTER_AVAILABLE", False) 13 | DEFAULT_ITEMS_PER_PAGE = getattr( 14 | settings, 15 | "OSCAR_ELASTICSEARCH_DEFAULT_ITEMS_PER_PAGE", 16 | settings.OSCAR_PRODUCTS_PER_PAGE, 17 | ) 18 | ITEMS_PER_PAGE_CHOICES = getattr( 19 | settings, "OSCAR_ELASTICSEARCH_ITEMS_PER_PAGE_CHOICES", [DEFAULT_ITEMS_PER_PAGE] 20 | ) 21 | MONTHS_TO_RUN_ANALYTICS = getattr( 22 | settings, "OSCAR_ELASTICSEARCH_MONTHS_TO_RUN_ANALYTICS", 3 23 | ) 24 | FACETS = getattr(settings, "OSCAR_ELASTICSEARCH_FACETS", []) 25 | 26 | SUGGESTION_FIELD_NAME = getattr( 27 | settings, "OSCAR_ELASTICSEARCH_SUGGESTION_FIELD_NAME", "search_title" 28 | ) 29 | 30 | AUTOCOMPLETE_STATUS_FILTER = getattr( 31 | settings, 32 | "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_STATUS_FILTER", 33 | ES_CTX_AVAILABLE if FILTER_AVAILABLE else ES_CTX_PUBLIC, 34 | ) 35 | AUTOCOMPLETE_CONTEXTS = getattr( 36 | settings, 37 | "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_CONTEXTS", 38 | [ 39 | { 40 | "name": "status", 41 | "type": "category", 42 | "path": "status", 43 | } 44 | ], 45 | ) 46 | AUTOCOMPLETE_SEARCH_FIELDS = getattr( 47 | settings, "OSCAR_ELASTICSEARCH_AUTOCOMPLETE_SEARCH_FIELDS", ["title", "upc"] 48 | ) 49 | 50 | MAX_GRAM = 15 51 | 52 | SEARCH_FIELDS = getattr( 53 | settings, 54 | "OSCAR_ELASTICSEARCH_SEARCH_FIELDS", 55 | [ 56 | "_all_text", 57 | "code", 58 | "search_title^1", 59 | "search_title.reversed^0.8", 60 | ], 61 | ) 62 | SEARCH_QUERY_TYPE = getattr( 63 | settings, "OSCAR_ELASTICSEARCH_SEARCH_QUERY_TYPE", "most_fields" 64 | ) 65 | SEARCH_QUERY_OPERATOR = getattr( 66 | settings, "OSCAR_ELASTICSEARCH_SEARCH_QUERY_OPERATOR", "or" 67 | ) 68 | 69 | NUM_SUGGESTIONS = getattr(settings, "OSCAR_ELASTICSEARCH_NUM_SUGGESTIONS", 20) 70 | 71 | ELASTICSEARCH_SERVER_URLS = getattr( 72 | settings, "OSCAR_ELASTICSEARCH_SERVER_URLS", ["http://127.0.0.1:9200"] 73 | ) 74 | 75 | INDEX_PREFIX = getattr( 76 | settings, "OSCAR_ELASTICSEARCH_INDEX_PREFIX", "django-oscar-elasticsearch" 77 | ) 78 | 79 | RELEVANCY = "relevancy" 80 | TOP_RATED = "rating" 81 | NEWEST = "newest" 82 | PRICE_HIGH_TO_LOW = "price-desc" 83 | PRICE_LOW_TO_HIGH = "price-asc" 84 | TITLE_A_TO_Z = "title-asc" 85 | TITLE_Z_TO_A = "title-desc" 86 | POPULARITY = "popularity" 87 | 88 | 89 | SORT_BY_CHOICES_SEARCH = getattr( 90 | settings, 91 | "OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_SEARCH", 92 | [ 93 | (RELEVANCY, _("Relevancy")), 94 | (POPULARITY, _("Most popular")), 95 | (NEWEST, _("Newest")), 96 | ], 97 | ) 98 | 99 | SORT_BY_MAP_SEARCH = getattr( 100 | settings, 101 | "OSCAR_ELASTICSEARCH_SORT_BY_MAP_SEARCH", 102 | { 103 | NEWEST: "-date_created", 104 | POPULARITY: "-popularity", 105 | TITLE_A_TO_Z: "title.raw", 106 | TITLE_Z_TO_A: "-title.raw", 107 | }, 108 | ) 109 | 110 | SORT_BY_CHOICES_CATALOGUE = getattr( 111 | settings, 112 | "OSCAR_ELASTICSEARCH_SORT_BY_CHOICES_CATALOGUE", 113 | [ 114 | (RELEVANCY, _("Relevancy")), 115 | (POPULARITY, _("Most popular")), 116 | (TOP_RATED, _("Customer rating")), 117 | (NEWEST, _("Newest")), 118 | (PRICE_HIGH_TO_LOW, _("Price high to low")), 119 | (PRICE_LOW_TO_HIGH, _("Price low to high")), 120 | (TITLE_A_TO_Z, _("Title A to Z")), 121 | (TITLE_Z_TO_A, _("Title Z to A")), 122 | ], 123 | ) 124 | 125 | SORT_BY_MAP_CATALOGUE = getattr( 126 | settings, 127 | "OSCAR_ELASTICSEARCH_SORT_BY_MAP_CATALOGUE", 128 | { 129 | TOP_RATED: "-rating", 130 | NEWEST: "-date_created", 131 | POPULARITY: "-popularity", 132 | PRICE_HIGH_TO_LOW: "-price", 133 | PRICE_LOW_TO_HIGH: "price", 134 | TITLE_A_TO_Z: "title.raw", 135 | TITLE_Z_TO_A: "-title.raw", 136 | }, 137 | ) 138 | 139 | DEFAULT_ORDERING = getattr(settings, "OSCAR_ELASTICSEARCH_DEFAULT_ORDERING", None) 140 | 141 | FACET_BUCKET_SIZE = getattr(settings, "OSCAR_ELASTICSEARCH_FACET_BUCKET_SIZE", 10) 142 | 143 | INDEXING_CHUNK_SIZE = getattr(settings, "OSCAR_ELASTICSEARCH_INDEXING_CHUNK_SIZE", 400) 144 | 145 | PRIORITIZE_AVAILABLE_PRODUCTS = getattr( 146 | settings, "OSCAR_ELASTICSEARCH_PRIORITIZE_AVAILABLE_PRODUCTS", True 147 | ) 148 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/signal_handlers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | from oscar.core.loading import get_model, get_class 3 | 4 | from django.core.signals import request_finished 5 | from django.db.models.signals import post_delete, post_save, m2m_changed 6 | 7 | from . import settings 8 | 9 | Product = get_model("catalogue", "Product") 10 | Category = get_model("catalogue", "Category") 11 | StockRecord = get_model("partner", "StockRecord") 12 | UpdateIndex = get_class("search.update", "UpdateIndex") 13 | ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex") 14 | 15 | update_index = UpdateIndex() 16 | 17 | 18 | def push_product_update(instance): 19 | # if the child was changed, also update the parent 20 | update_index.push_product(str(instance.pk)) 21 | if instance.is_child: 22 | update_index.push_product(str(instance.parent_id)) 23 | 24 | 25 | def product_post_save_signal_handler(sender, instance, **kwargs): 26 | if kwargs.get("raw", False): 27 | return 28 | 29 | push_product_update(instance) 30 | 31 | 32 | def product_post_delete_signal_handler(sender, instance, **kwargs): 33 | if kwargs.get("raw", False): 34 | return 35 | 36 | ProductElasticsearchIndex().delete(instance.pk) 37 | 38 | 39 | def product_category_m2m_changed_signal_handler( 40 | sender, instance, action, reverse, **kwargs 41 | ): 42 | if kwargs.get("raw", False): 43 | return 44 | 45 | if action.startswith("post"): 46 | if reverse: 47 | update_index.push_category(str(instance.pk)) 48 | else: 49 | push_product_update(instance) 50 | 51 | 52 | def category_change_handler(sender, instance, **kwargs): 53 | if kwargs.get("raw", False): 54 | return 55 | 56 | update_index.push_category(str(instance.pk)) 57 | 58 | 59 | def stockrecord_change_handler(sender, instance, **kwargs): 60 | if kwargs.get("raw", False): 61 | return 62 | 63 | push_product_update(instance.product) 64 | 65 | 66 | def stockrecord_post_delete_handler(sender, instance, **kwargs): 67 | if kwargs.get("raw", False): 68 | return 69 | 70 | push_product_update(instance.product) 71 | 72 | 73 | def register_signal_handlers(): 74 | # we must pass the save signal from the regular model through to the proxy 75 | # model, because the wagtail listener is attached to the proxy model, not 76 | # the regular model. 77 | m2m_changed.connect( 78 | product_category_m2m_changed_signal_handler, sender=Product.categories.through 79 | ) 80 | post_save.connect(product_post_save_signal_handler, sender=Product) 81 | post_delete.connect(product_post_delete_signal_handler, sender=Product) 82 | post_save.connect(category_change_handler, sender=Category) 83 | post_delete.connect(category_change_handler, sender=Category) 84 | if settings.HANDLE_STOCKRECORD_CHANGES: 85 | post_save.connect(stockrecord_change_handler, sender=StockRecord) 86 | post_delete.connect(stockrecord_post_delete_handler, sender=StockRecord) 87 | request_finished.connect(update_index.synchronize_searchindex) 88 | 89 | 90 | def deregister_signal_handlers(): 91 | # Disconnects the signal handlers for easy access in importers 92 | m2m_changed.disconnect( 93 | product_category_m2m_changed_signal_handler, sender=Product.categories.through 94 | ) 95 | post_save.disconnect(product_post_save_signal_handler, sender=Product) 96 | post_delete.disconnect(product_post_delete_signal_handler, sender=Product) 97 | post_save.disconnect(category_change_handler, sender=Category) 98 | post_delete.disconnect(category_change_handler, sender=Category) 99 | if settings.HANDLE_STOCKRECORD_CHANGES: 100 | post_save.disconnect(stockrecord_change_handler, sender=StockRecord) 101 | post_delete.disconnect(stockrecord_post_delete_handler, sender=StockRecord) 102 | request_finished.disconnect(update_index.synchronize_searchindex) 103 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | user_search = Signal() 4 | query_hit = Signal() 5 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/static/oscar/js/search/autocomplete.js: -------------------------------------------------------------------------------- 1 | $.fn.autocomplete = function(url) { 2 | this.typeahead({ 3 | source: function(query, process) { 4 | return $.get(url + '?q=' + query, function(data) { 5 | process(data); 6 | }); 7 | }, 8 | autoSelect: false, 9 | afterSelect: function(arg) { 10 | $('#id_q').closest("form").submit() 11 | } 12 | }); 13 | return this; 14 | }; 15 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/suggestions.py: -------------------------------------------------------------------------------- 1 | def select_suggestion(field, suggestions): 2 | if field in suggestions: 3 | try: 4 | return suggestions[field][0]["options"][0].get("text") 5 | except (IndexError, ValueError): 6 | pass 7 | 8 | return None 9 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/templates/oscar/catalogue/browse.html: -------------------------------------------------------------------------------- 1 | {% extends "oscar/catalogue/browse.html" %} 2 | {% load product_tags %} 3 | 4 | {% block products %} 5 | {% for product in products %} 6 |
  • {% render_product product %}
  • 7 | {% endfor %} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/templates/oscar/partials/extrascripts.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/templates/oscar/search/partials/facet.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
    4 | 5 | {% for item in items %} 6 |
    7 | {% if item.selected %} 8 | 9 | 10 | {{ item }} 11 | {% else %} 12 | {% if item.disabled %} 13 | 14 | {{ item }} 15 | {% else %} 16 | 17 | 18 | {{ item }} 19 | {% endif %} 20 | {% endif %} 21 | {% if item.show_count %} 22 | ({{ item.doc_count }}) 23 | {% endif %} 24 |
    25 | {% endfor %} 26 |
    -------------------------------------------------------------------------------- /oscar_elasticsearch/search/templates/oscar/search/results.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/search/results.html' %} 2 | {% load i18n %} 3 | {% load currency_filters thumbnail product_tags %} 4 | 5 | {% block content %} 6 |
    7 | {# Render other search params as hidden inputs #} 8 | {% for value in search_form.selected_facets %} 9 | 10 | {% endfor %} 11 | 12 | 13 | {% if paginator.count %} 14 | {% if paginator.num_pages > 1 %} 15 | {% blocktrans with start=page_obj.start_index end=page_obj.end_index num_results=paginator.count %} 16 | Found {{ num_results }} results, showing {{ start }} to {{ end }}. 17 | {% endblocktrans %} 18 | {% else %} 19 | {% blocktrans count num_results=paginator.count %} 20 | Found {{ num_results }} result. 21 | {% plural %} 22 | Found {{ num_results }} results. 23 | {% endblocktrans %} 24 | {% endif %} 25 |
    26 | {% include "oscar/partials/form_field.html" with field=search_form.sort_by %} 27 |
    28 | {% else %} 29 |

    30 | {% trans "Found 0 results." %} 31 | {% if suggestion %} 32 | {% url 'search:search' as search_url %} 33 | {% blocktrans %} 34 | Did you mean "{{ suggestion }}"? 35 | {% endblocktrans %} 36 | {% endif %} 37 |

    38 | {% endif %} 39 |
    40 | {% block searchheading %}{% endblock %} 41 | 42 | {% block products %} 43 | {% if page_obj.object_list %} 44 |
    45 |
    46 |
      47 | {% for result in page_obj.object_list %} 48 |
    1. {% render_product result %}
    2. 49 | {% endfor %} 50 |
    51 | {% include "oscar/partials/pagination.html" with page_obj=page_obj %} 52 |
    53 |
    54 | {% endif %} 55 | {% endblock products %} 56 | 57 | {% endblock %} 58 | 59 | {% block column_left %} 60 | {% if has_facets %} 61 |

    {% trans "Refine by" %} 62 | 63 |

    64 |
    65 | {% for field, data in facet_data.items %} 66 | {% if data.results %} 67 | {% include 'oscar/search/partials/facet.html' with name=data.name items=data.results %} 68 | {% endif %} 69 | {% endfor %} 70 |
    71 | {% endif %} 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/tests.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | from unittest.mock import patch 3 | 4 | from time import sleep 5 | from django.core.management import call_command 6 | from django.test import TestCase 7 | from django.urls import reverse 8 | 9 | from oscar.core.loading import get_class, get_model 10 | from oscar.test.factories import ( 11 | ProductFactory, 12 | OrderFactory, 13 | OrderLineFactory, 14 | ) 15 | 16 | import oscar_elasticsearch.search.format 17 | import oscar_elasticsearch.search.utils 18 | 19 | Product = get_model("catalogue", "Product") 20 | Category = get_model("catalogue", "Category") 21 | 22 | update_index_products = get_class("search.helpers", "update_index_products") 23 | update_index_categories = get_class("search.helpers", "update_index_categories") 24 | 25 | ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex") 26 | CategoryElasticsearchIndex = get_class( 27 | "search.api.category", "CategoryElasticsearchIndex" 28 | ) 29 | 30 | 31 | def load_tests(loader, tests, ignore): # pylint: disable=W0613 32 | tests.addTests(doctest.DocTestSuite(oscar_elasticsearch.search.format)) 33 | tests.addTests(doctest.DocTestSuite(oscar_elasticsearch.search.utils)) 34 | return tests 35 | 36 | 37 | class ElasticSearchViewTest(TestCase): 38 | fixtures = [ 39 | "search/auth", 40 | "catalogue/catalogue", 41 | ] 42 | 43 | @classmethod 44 | def setUpClass(cls): 45 | # clear search index 46 | call_command("update_oscar_index") 47 | super().setUpClass() 48 | 49 | def setUp(self): 50 | super().setUp() 51 | update_index_products(Product.objects.values_list("id", flat=True)) 52 | sleep(3) 53 | 54 | def test_search_bikini(self): 55 | url = reverse("search:search") 56 | response = self.client.get("%s?q=bikini" % url) 57 | self.assertContains(response, "Hermes Bikini") 58 | 59 | def test_search_suggestion(self): 60 | url = reverse("search:search") 61 | response = self.client.get("%s?q=prods" % url) 62 | self.assertEqual(response.context["suggestion"], "produ") 63 | 64 | def test_child_upc_search_suggests_parent(self): 65 | url = reverse("search:search") 66 | # searching for child upc 67 | response = self.client.get("%s?q=kq8000" % url) 68 | self.assertEqual(response.context["paginator"].count, 1) 69 | self.assertEqual(response.context["paginator"].instances[0].upc, "jk4000") 70 | # searching for child title 71 | response = self.client.get("%s?q=forty" % url) 72 | self.assertEqual(response.context["paginator"].count, 1) 73 | self.assertEqual(response.context["paginator"].instances[0].upc, "jk4000") 74 | 75 | def test_browse(self): 76 | url = reverse("catalogue:index") 77 | response = self.client.get(url) 78 | self.assertContains(response, "second") 79 | self.assertContains(response, "serious product") 80 | self.assertContains(response, "Hubble Photo") 81 | self.assertContains(response, "Hermes Bikini") 82 | 83 | def test_catagory(self): 84 | url = reverse("catalogue:category", args=("root", 1)) 85 | response = self.client.get(url) 86 | self.assertContains(response, "Hubble Photo") 87 | self.assertContains(response, "Hermes Bikini") 88 | 89 | def test_no_stockrecord_and_not_public(self): 90 | self.test_catagory() 91 | 92 | hubble = Product.objects.get(pk=3) 93 | hubble.is_public = False 94 | hubble.save() 95 | 96 | update_index_products([hubble.pk]) 97 | sleep(2) 98 | 99 | url = reverse("catalogue:category", args=("root", 1)) 100 | response = self.client.get(url) 101 | self.assertNotContains(response, "Hubble Photo") 102 | 103 | 104 | class TestSearchApi(TestCase): 105 | fixtures = [ 106 | "search/auth", 107 | "catalogue/catalogue", 108 | ] 109 | 110 | @classmethod 111 | def setUpClass(cls): 112 | # clear search index 113 | call_command("update_oscar_index") 114 | super().setUpClass() 115 | 116 | def setUp(self): 117 | super().setUp() 118 | update_index_products(Product.objects.values_list("id", flat=True)) 119 | update_index_categories(Category.objects.values_list("id", flat=True)) 120 | sleep(3) 121 | 122 | product_search_api = ProductElasticsearchIndex() 123 | category_search_api = CategoryElasticsearchIndex() 124 | 125 | def test_product_search(self): 126 | results, total_hits = self.product_search_api.search() 127 | 128 | self.assertEqual(results.count(), 6) 129 | self.assertEqual(total_hits, 6) 130 | 131 | results, total_hits = self.product_search_api.search(query_string="bikini") 132 | 133 | self.assertEqual(results.count(), 1) 134 | self.assertEqual(total_hits, 1) 135 | 136 | def test_category_search(self): 137 | results, total_hits = self.category_search_api.search() 138 | 139 | self.assertEqual(results.count(), 2) 140 | self.assertEqual(total_hits, 2) 141 | 142 | results, total_hits = self.category_search_api.search(query_string="bridge") 143 | 144 | self.assertEqual(results.count(), 1) 145 | self.assertEqual(total_hits, 1) 146 | 147 | def test_product_search_with_category_name(self): 148 | results, total_hits = self.product_search_api.search(query_string="Bridge") 149 | 150 | self.assertEqual(results.count(), 4) 151 | self.assertEqual(total_hits, 4) 152 | 153 | results, total_hits = self.product_search_api.search(query_string="Ambulant") 154 | 155 | self.assertEqual(results.count(), 6) 156 | self.assertEqual(total_hits, 6) 157 | 158 | 159 | class ManagementCommandsTestCase(TestCase): 160 | fixtures = [ 161 | "search/auth", 162 | "catalogue/catalogue", 163 | ] 164 | 165 | def setUp(self): 166 | # Clear index before each test 167 | with ProductElasticsearchIndex().reindex() as index: 168 | self.product_index = index 169 | index.reindex_objects(Product.objects.none()) 170 | 171 | with CategoryElasticsearchIndex().reindex() as index: 172 | self.category_index = index 173 | index.reindex_objects([]) 174 | 175 | super().setUp() 176 | 177 | def test_update_index_products(self): 178 | results, total_hits = self.product_index.search() 179 | self.assertEqual(results.count(), 0) 180 | self.assertEqual(total_hits, 0) 181 | 182 | call_command("update_index_products") 183 | sleep(3) 184 | 185 | results, total_hits = self.product_index.search() 186 | self.assertEqual(results.count(), 6) 187 | self.assertEqual(total_hits, 6) 188 | 189 | @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 2) 190 | def test_update_index_products_multiple_chunks(self): 191 | results, total_hits = self.product_index.search() 192 | self.assertEqual(results.count(), 0) 193 | self.assertEqual(total_hits, 0) 194 | 195 | call_command("update_index_products") 196 | sleep(3) 197 | 198 | results, total_hits = self.product_index.search() 199 | self.assertEqual(results.count(), 6) 200 | self.assertEqual(total_hits, 6) 201 | 202 | def test_update_index_categories(self): 203 | results, total_hits = self.category_index.search() 204 | self.assertEqual(results.count(), 0) 205 | self.assertEqual(total_hits, 0) 206 | 207 | call_command("update_index_categories") 208 | sleep(3) 209 | 210 | results, total_hits = self.category_index.search() 211 | self.assertEqual(results.count(), 2) 212 | self.assertEqual(total_hits, 2) 213 | 214 | @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 1) 215 | def test_update_index_categories_multiple_chunks(self): 216 | results, total_hits = self.category_index.search() 217 | self.assertEqual(results.count(), 0) 218 | self.assertEqual(total_hits, 0) 219 | 220 | call_command("update_index_categories") 221 | sleep(3) 222 | 223 | results, total_hits = self.category_index.search() 224 | self.assertEqual(results.count(), 2) 225 | self.assertEqual(total_hits, 2) 226 | 227 | @patch("oscar_elasticsearch.search.settings.INDEXING_CHUNK_SIZE", 1000) 228 | def test_update_index_products_num_queries(self): 229 | def create_parent_child_products(): 230 | for _ in range(10): 231 | parent = ProductFactory( 232 | structure="parent", 233 | stockrecords=[], 234 | categories=Category.objects.all(), 235 | ) 236 | for _ in range(5): 237 | ProductFactory(structure="child", parent=parent, categories=[]) 238 | 239 | create_parent_child_products() 240 | self.assertEqual(Product.objects.count(), 66) # 6 inside the fixtures 241 | 242 | with self.assertNumQueries(23): 243 | call_command("update_index_products") 244 | 245 | # create 10 extra product with each 5 childs 246 | create_parent_child_products() 247 | 248 | # The amount of queries should not change. 249 | with self.assertNumQueries(23): 250 | call_command("update_index_products") 251 | 252 | def test_popularity_based_on_order_lines(self): 253 | products = [] 254 | for i in range(5): 255 | product = ProductFactory( 256 | title=f"VERY UNIQUE TITLE - {i + 1}", categories=[] 257 | ) 258 | products.append(product) 259 | 260 | call_command("update_index_products") 261 | 262 | results, total_hits = ProductElasticsearchIndex().search( 263 | query_string="VERY UNIQUE TITLE", raw_results=True 264 | ) 265 | self.assertEqual(total_hits, 5) 266 | for hit in results["hits"]["hits"]: 267 | self.assertEqual( 268 | hit["_source"]["popularity"], 269 | None, 270 | "no orders place yet, so popularity should be None", 271 | ) 272 | 273 | order = OrderFactory() 274 | for i, product in enumerate(products): 275 | quantity = int(product.title.split("-")[-1].strip()) 276 | for _ in range(quantity): 277 | OrderLineFactory(order=order, product=product) 278 | 279 | call_command("update_index_products") 280 | 281 | results, total_hits = ProductElasticsearchIndex().search( 282 | query_string="VERY UNIQUE TITLE", raw_results=True 283 | ) 284 | self.assertEqual(total_hits, 5) 285 | for hit in results["hits"]["hits"]: 286 | title = hit["_source"]["title"] 287 | quantity = int(title.split("-")[-1].strip()) 288 | self.assertEqual( 289 | hit["_source"]["popularity"], 290 | quantity, 291 | ) 292 | 293 | def test_exception_does_not_delete_index(self): 294 | call_command("update_index_products") 295 | sleep(3) 296 | 297 | results, total_hits = self.product_index.search() 298 | self.assertEqual(results.count(), 6) 299 | self.assertEqual(total_hits, 6) 300 | 301 | with self.assertRaises(Exception): 302 | with ProductElasticsearchIndex().reindex() as index: 303 | # Trigger an error by not passing products 304 | index.reindex_objects(Category.objects.all()) 305 | sleep(3) 306 | 307 | # It should still have the same amount of products. 308 | results, total_hits = self.product_index.search() 309 | self.assertEqual(results.count(), 6) 310 | self.assertEqual(total_hits, 6) 311 | 312 | 313 | class TestBrowsableItems(TestCase): 314 | 315 | def test_child_products_hidden_in_category_view(self): 316 | for _ in range(2): 317 | parent = ProductFactory(structure="parent", stockrecords=[]) 318 | for _ in range(5): 319 | ProductFactory(structure="child", parent=parent, categories=[]) 320 | 321 | call_command("update_index_products") 322 | sleep(3) 323 | 324 | url = reverse("catalogue:index") 325 | response = self.client.get(url) 326 | products = response.context_data["page_obj"].object_list 327 | 328 | self.assertEqual(len(products), 2) 329 | self.assertFalse(any([product.structure == "child" for product in products])) 330 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/update.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from oscar.core.loading import get_classes 3 | 4 | update_index_products, update_index_categories = get_classes( 5 | "search.helpers", ["update_index_products", "update_index_categories"] 6 | ) 7 | 8 | 9 | class UpdateIndex(threading.local): 10 | def __init__(self): 11 | super().__init__() 12 | self._products = set() 13 | self._categories = set() 14 | 15 | def push_category(self, *categories): 16 | self._categories.update(categories) 17 | 18 | def push_product(self, *products): 19 | self._products.update(products) 20 | 21 | # pylint: disable=unused-argument 22 | def synchronize_searchindex(self, **kwargs): 23 | categories = list(self._categories) 24 | self._categories = set() 25 | products = list(self._products) 26 | self._products = set() 27 | update_index_products(products) 28 | update_index_categories(categories) 29 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/utils.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from django.db import connection 4 | from django.db.models import Case, When 5 | 6 | 7 | def chunked(iterable, size, startindex=0): 8 | """ 9 | Divide an interable into chunks of ``size`` 10 | 11 | >>> list(chunked("hahahaha", 2)) 12 | ['ha', 'ha', 'ha', 'ha'] 13 | >>> list(chunked([1,2,3,4,5,6,7], 3)) 14 | [[1, 2, 3], [4, 5, 6], [7]] 15 | """ 16 | while True: 17 | chunk = iterable[startindex : startindex + size] 18 | chunklen = len(chunk) 19 | if chunklen: 20 | yield chunk 21 | if chunklen < size: 22 | break 23 | startindex += size 24 | 25 | 26 | def search_result_to_queryset(search_results, Model): 27 | instance_ids = [hit["_source"]["id"] for hit in search_results["hits"]["hits"]] 28 | 29 | preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(instance_ids)]) 30 | return Model.objects.filter(pk__in=instance_ids).order_by(preserved) 31 | 32 | 33 | def get_index_settings(MAX_GRAM): 34 | return { 35 | "analysis": { 36 | "analyzer": { 37 | # the simplest analyzer most useful for normalizing and splitting a sentence into words 38 | # this is most likely only used as a search analyzer 39 | "lowercasewhitespace": { 40 | "tokenizer": "whitespace", 41 | "filter": ["lowercase", "asciifolding"], 42 | "char_filter": ["non_ascii_character_filter_mapping"], 43 | }, 44 | # this analyzer will keep all punctuation and numbers and make ngrams 45 | # as small as a single character. Only usefull for upcs and techincal terms 46 | "technical_analyzer": { 47 | "tokenizer": "whitespace", 48 | "filter": [ 49 | "shallow_edgengram", 50 | "lowercase", 51 | "asciifolding", 52 | "max_gram_truncate", 53 | ], 54 | "char_filter": ["non_ascii_character_filter_mapping"], 55 | }, 56 | # should be used as the search analyzer for terms analyzed with the 57 | # technical_analyzer. Will just split the input into words and normalize 58 | # but keeping in mind the max ngram size. 59 | "technical_search_analyzer": { 60 | "tokenizer": "whitespace", 61 | "filter": [ 62 | "lowercase", 63 | "asciifolding", 64 | "max_gram_truncate", 65 | ], 66 | "char_filter": ["non_ascii_character_filter_mapping"], 67 | }, 68 | # this analyzer is usefull for important textual data like titles, 69 | # that contain a lot of search terms. 70 | "title_analyzer": { 71 | "tokenizer": "standard", 72 | "filter": [ 73 | "edgengram", 74 | "lowercase", 75 | "asciifolding", 76 | "max_gram_truncate", 77 | ], 78 | }, 79 | # should be used as the search analyzer for terms analyzed with title_analyzer 80 | "reversed_title_analyzer": { 81 | "tokenizer": "standard", 82 | "filter": [ 83 | "lowercase", 84 | "asciifolding", 85 | "reversed_edgengram", 86 | "max_gram_truncate", 87 | ], 88 | }, 89 | # this analyzer is most usefull for long textual data. punctuation and numbers 90 | # WILL BE STRIPPED 91 | "standard": { 92 | "tokenizer": "standard", 93 | "filter": ["lowercase", "asciifolding"], 94 | }, 95 | # This analyzer is usefull for when you need to find really specific data inside some text, 96 | # for example you have a 'Volvo Penta TAD163532E' code inside your model type and you want it to be found with 'Penta D16' 97 | # Also use the 'technical_search_analyzer' for this one. 98 | "technical_title_analyzer": { 99 | "tokenizer": "whitespace", 100 | "filter": [ 101 | "ngram", 102 | "lowercase", 103 | "asciifolding", 104 | "max_gram_truncate", 105 | ], 106 | }, 107 | }, 108 | "tokenizer": { 109 | "ngram_tokenizer": {"type": "ngram", "min_gram": 3, "max_gram": 15}, 110 | "edgengram_tokenizer": { 111 | "type": "edge_ngram", 112 | "min_gram": 2, 113 | "max_gram": MAX_GRAM, 114 | }, 115 | }, 116 | "filter": { 117 | "ngram": {"type": "ngram", "min_gram": 3, "max_gram": MAX_GRAM}, 118 | "edgengram": { 119 | "type": "edge_ngram", 120 | "min_gram": 2, 121 | "max_gram": MAX_GRAM, 122 | }, 123 | "shallow_edgengram": { 124 | "type": "edge_ngram", 125 | "min_gram": 1, 126 | "max_gram": MAX_GRAM, 127 | }, 128 | "reversed_edgengram": { 129 | "type": "edge_ngram", 130 | "min_gram": 3, 131 | "max_gram": MAX_GRAM, 132 | "side": "back", 133 | }, 134 | "max_gram_truncate": {"type": "truncate", "length": MAX_GRAM}, 135 | }, 136 | "char_filter": { 137 | "non_ascii_character_filter_mapping": { 138 | "type": "mapping", 139 | "mappings": ["’ => '"], 140 | } 141 | }, 142 | }, 143 | "index": {"number_of_shards": 1, "max_ngram_diff": MAX_GRAM}, 144 | } 145 | 146 | 147 | def get_category_ancestors(): 148 | """ 149 | Get a mapping of all child categories with all of its ancestor categories. 150 | {child_id: [ancestor1_id, ancestor2_id]}} 151 | """ 152 | query = """ 153 | SELECT 154 | child.id AS child_id, 155 | parent.id AS up_id 156 | FROM catalogue_category AS child 157 | JOIN catalogue_category AS parent 158 | ON child.path LIKE (parent.path || '%') AND child.id > parent.id; 159 | """ 160 | with connection.cursor() as cursor: 161 | cursor.execute(query) 162 | rows = cursor.fetchall() 163 | 164 | category_ancestors = defaultdict(list) 165 | for child_id, ancestor_id in rows: 166 | category_ancestors[child_id].append(ancestor_id) 167 | 168 | return category_ancestors 169 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/oscar_elasticsearch/search/views/__init__.py -------------------------------------------------------------------------------- /oscar_elasticsearch/search/views/base.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal as D 2 | 3 | from django.views.generic.list import ListView 4 | from django.utils.translation import gettext 5 | 6 | from oscar.core.loading import get_class, get_model 7 | 8 | from oscar_elasticsearch.search import settings 9 | from oscar_elasticsearch.search.facets import process_facets 10 | from oscar_elasticsearch.search.signals import query_hit 11 | 12 | OSCAR_PRODUCTS_INDEX_NAME = get_class( 13 | "search.indexing.settings", "OSCAR_PRODUCTS_INDEX_NAME" 14 | ) 15 | select_suggestion = get_class("search.suggestions", "select_suggestion") 16 | es = get_class("search.backend", "es") 17 | ProductElasticsearchIndex = get_class("search.api.product", "ProductElasticsearchIndex") 18 | 19 | Product = get_model("catalogue", "Product") 20 | 21 | 22 | product_search_api = ProductElasticsearchIndex() 23 | 24 | 25 | class BaseSearchView(ListView): 26 | model = Product 27 | paginate_by = settings.DEFAULT_ITEMS_PER_PAGE 28 | form_class = None 29 | aggs_definitions = settings.FACETS 30 | scoring_functions = [ 31 | { 32 | "field_value_factor": { 33 | "field": "priority", 34 | "modifier": "ln2p", 35 | "factor": 1, 36 | "missing": 0, 37 | }, 38 | }, 39 | ] 40 | 41 | def get_aggs_definitions(self): 42 | return self.aggs_definitions 43 | 44 | def get_scoring_functions(self): 45 | return self.scoring_functions if self.scoring_functions else None 46 | 47 | def get_default_filters(self): 48 | filters = [ 49 | {"term": {"is_public": True}}, 50 | {"terms": {"structure": ["parent", "standalone"]}}, 51 | ] 52 | 53 | if settings.FILTER_AVAILABLE: 54 | filters.append({"term": {"is_available": True}}) 55 | 56 | filters.append( 57 | { 58 | "nested": { 59 | "path": "categories", 60 | "query": {"exists": {"field": "categories"}}, 61 | } 62 | } 63 | ) 64 | 65 | return filters 66 | 67 | def get_facet_filters(self): 68 | filters = [] 69 | 70 | for name, value in self.form.selected_multi_facets.items(): 71 | # pylint: disable=W0640 72 | definition = list( 73 | filter(lambda x: x["name"] == name, self.get_aggs_definitions()) 74 | )[0] 75 | if definition["type"] == "range": 76 | ranges = [] 77 | for val in value: 78 | if val.startswith("*-"): 79 | ranges.append( 80 | {"range": {name: {"to": D(val.replace("*-", ""))}}} 81 | ) 82 | elif val.endswith("-*"): 83 | ranges.append( 84 | {"range": {name: {"from": D(val.replace("-*", ""))}}} 85 | ) 86 | else: 87 | from_, to = val.split("-") 88 | ranges.append( 89 | {"range": {name: {"from": D(from_), "to": D(to)}}} 90 | ) 91 | 92 | filters.append({"bool": {"should": ranges}}) 93 | else: 94 | filters.append({"terms": {name: value}}) 95 | 96 | return filters 97 | 98 | def get_sort_by(self): 99 | sort_by = [] 100 | ordering = self.form.get_sort_params(self.form.cleaned_data) 101 | 102 | if not ordering and not self.request.GET.get("q"): 103 | ordering = settings.DEFAULT_ORDERING 104 | 105 | if ordering: 106 | if ordering.startswith("-"): 107 | sort_by.insert(0, {"%s" % ordering.replace("-", ""): {"order": "desc"}}) 108 | else: 109 | sort_by.insert(0, {"%s" % ordering: {"order": "asc"}}) 110 | 111 | else: 112 | sort_by.append("_score") 113 | 114 | return sort_by 115 | 116 | def get_form(self, request): 117 | # pylint: disable=E1102 118 | return self.form_class( 119 | data=request.GET or {}, 120 | selected_facets=request.GET.getlist("selected_facets", []), 121 | ) 122 | 123 | def get_context_data(self, *args, **kwargs): 124 | context = super().get_context_data(*args, **kwargs) 125 | 126 | # pylint: disable=W0201 127 | self.form = self.get_form(self.request) 128 | self.form.is_valid() 129 | 130 | items_per_page = self.form.cleaned_data.get("items_per_page", self.paginate_by) 131 | elasticsearch_from = ( 132 | int(self.request.GET.get("page", 1)) * items_per_page 133 | ) - items_per_page 134 | 135 | query_string = self.request.GET.get("q", "") 136 | if query_string: 137 | query_hit.send(sender=self, querystring=query_string) 138 | 139 | paginator, search_results, unfiltered_result = ( 140 | product_search_api.paginated_facet_search( 141 | from_=elasticsearch_from, 142 | to=items_per_page, 143 | query_string=query_string, 144 | filters=self.get_default_filters(), 145 | sort_by=self.get_sort_by(), 146 | scoring_functions=self.get_scoring_functions(), 147 | facet_filters=self.get_facet_filters(), 148 | aggs_definitions=self.get_aggs_definitions(), 149 | ) 150 | ) 151 | 152 | if "aggregations" in unfiltered_result: 153 | processed_facets = process_facets( 154 | self.request.get_full_path(), 155 | self.form, 156 | (unfiltered_result, search_results), 157 | facet_definitions=self.get_aggs_definitions(), 158 | ) 159 | else: 160 | processed_facets = None 161 | 162 | context["paginator"] = paginator 163 | page_obj = paginator.get_page(self.request.GET.get("page", 1)) 164 | context["page_obj"] = page_obj 165 | context["suggestion"] = select_suggestion( 166 | product_search_api.get_suggestion_field_name(None), 167 | search_results.get("suggest", []), 168 | ) 169 | context["page"] = page_obj 170 | context[self.context_object_name] = page_obj 171 | context["facet_data"] = processed_facets 172 | context["selected_facets"] = self.request.GET.getlist("selected_facets", []) 173 | context["has_facets"] = bool(processed_facets) 174 | context["query"] = self.request.GET.get("q") or gettext("Blank") 175 | context["form"] = self.form 176 | 177 | return context 178 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/views/catalogue.py: -------------------------------------------------------------------------------- 1 | from oscar.apps.search.views.catalogue import ( 2 | ProductCategoryView as BaseProductCategoryView, 3 | ) 4 | 5 | 6 | class ProductCategoryView(BaseProductCategoryView): 7 | def get_default_filters(self): 8 | filters = super().get_default_filters() 9 | 10 | category_ids = self.category.get_descendants_and_self().values_list( 11 | "pk", flat=True 12 | ) 13 | 14 | filters.append( 15 | { 16 | "nested": { 17 | "path": "categories", 18 | "query": {"terms": {"categories.id": list(category_ids)}}, 19 | "inner_hits": {"size": 0}, 20 | } 21 | } 22 | ) 23 | 24 | return filters 25 | -------------------------------------------------------------------------------- /oscar_elasticsearch/search/views/search.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from django.views import View 3 | from django.http import JsonResponse 4 | 5 | from oscar.core.loading import get_class 6 | 7 | from oscar_elasticsearch.search.indexing.settings import OSCAR_PRODUCTS_INDEX_NAME 8 | from oscar_elasticsearch.search.settings import ( 9 | AUTOCOMPLETE_STATUS_FILTER, 10 | ) 11 | 12 | es = get_class("search.backend", "es") 13 | autocomplete_suggestions = get_class( 14 | "search.api.autocomplete", "autocomplete_suggestions" 15 | ) 16 | 17 | 18 | class CatalogueAutoCompleteView(View): 19 | def get_suggestion_context(self): 20 | return {"status": AUTOCOMPLETE_STATUS_FILTER} 21 | 22 | def get_suggestions(self): 23 | search_string = self.request.GET.get("q", "") 24 | 25 | return autocomplete_suggestions( 26 | OSCAR_PRODUCTS_INDEX_NAME, 27 | search_string, 28 | "suggest", 29 | skip_duplicates=True, 30 | contexts=self.get_suggestion_context(), 31 | ) 32 | 33 | # pylint: disable=W0613 34 | def get(self, request, *args, **kwargs): 35 | results = self.get_suggestions() 36 | if results: 37 | return JsonResponse(results, safe=False) 38 | else: 39 | return JsonResponse(results, safe=False, status=400) 40 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | jobs=1 3 | load-plugins=pylint_django 4 | score=n 5 | ignore = migrations 6 | django-settings-module = sandbox.settings 7 | init-hook='import sys; sys.path.append("./sandbox/")' 8 | 9 | [MESSAGES CONTROL] 10 | disable=R,C 11 | -------------------------------------------------------------------------------- /sandbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/sandbox/__init__.py -------------------------------------------------------------------------------- /sandbox/assortment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/sandbox/assortment/__init__.py -------------------------------------------------------------------------------- /sandbox/assortment/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import AssortmentProduct, AssortmentUser 4 | 5 | 6 | class AssortmentProductAdmin(admin.TabularInline): 7 | model = AssortmentProduct 8 | list_display = search_fields = ["product"] 9 | 10 | 11 | class AssortmentUserAdmin(admin.ModelAdmin): 12 | list_display = search_fields = ["user"] 13 | inlines = [ 14 | AssortmentProductAdmin 15 | ] 16 | 17 | 18 | admin.site.register(AssortmentUser, AssortmentUserAdmin) 19 | -------------------------------------------------------------------------------- /sandbox/assortment/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AssortmentConfig(AppConfig): 5 | name = "assortment" 6 | verbose_name = "Assortment" 7 | 8 | def ready(self): 9 | from . import index 10 | -------------------------------------------------------------------------------- /sandbox/assortment/index.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_class 2 | 3 | from oscar_elasticsearch.search.api.lookup import BaseLookupIndex 4 | from oscar_elasticsearch.search.registry import elasticsearch_registry 5 | 6 | from django.contrib.auth.models import User 7 | 8 | BaseElasticSearchApi = get_class("search.api.search", "BaseElasticSearchApi") 9 | ESModelIndexer = get_class("search.indexing.indexer", "ESModelIndexer") 10 | 11 | from .models import AssortmentUser 12 | 13 | 14 | @elasticsearch_registry.register 15 | class UserProductAssortmentIndex(BaseLookupIndex): 16 | INDEX_NAME = "user_product_assortment" 17 | LOOKUP_PATH = "product_ids" 18 | Model = AssortmentUser 19 | 20 | def make_documents(self, objects): 21 | documents = [] 22 | 23 | for obj in objects: 24 | documents.append( 25 | { 26 | "_id": obj.user.id, 27 | self.LOOKUP_PATH: list(obj.products.all().values_list("product_id", flat=True)) 28 | } 29 | ) 30 | 31 | return documents 32 | 33 | def get_lookup_id(self, field_to_filter, **kwargs): 34 | request = kwargs.get("request", None) 35 | if request and request.user.is_authenticated: 36 | return str(request.user.id) 37 | 38 | return None # Returning None will result in not logged in users to see the entire catalogue 39 | 40 | 41 | @elasticsearch_registry.register 42 | class UserIndex(BaseElasticSearchApi, ESModelIndexer): 43 | INDEX_NAME = "users" 44 | Model = User 45 | INDEX_MAPPING = { 46 | "properties": { 47 | "name": {"type": "text"}, 48 | "email": {"type": "text"}, 49 | } 50 | } 51 | INDEX_SETTINGS = {} 52 | 53 | def make_documents(self, objects): 54 | return [ 55 | { 56 | "name": user.get_full_name(), 57 | "email": user.email, 58 | } for user in objects 59 | ] 60 | -------------------------------------------------------------------------------- /sandbox/assortment/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-01-29 10:56 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("catalogue", "0028_product_priority"), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="AssortmentUser", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "user", 32 | models.OneToOneField( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | related_name="assortment_user", 35 | to=settings.AUTH_USER_MODEL, 36 | ), 37 | ), 38 | ], 39 | ), 40 | migrations.CreateModel( 41 | name="AssortmentProduct", 42 | fields=[ 43 | ( 44 | "id", 45 | models.AutoField( 46 | auto_created=True, 47 | primary_key=True, 48 | serialize=False, 49 | verbose_name="ID", 50 | ), 51 | ), 52 | ( 53 | "product", 54 | models.ForeignKey( 55 | on_delete=django.db.models.deletion.CASCADE, 56 | to="catalogue.product", 57 | ), 58 | ), 59 | ( 60 | "assortment_user", 61 | models.ForeignKey( 62 | on_delete=django.db.models.deletion.CASCADE, 63 | related_name="products", 64 | to="assortment.assortmentuser", 65 | ), 66 | ), 67 | ], 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /sandbox/assortment/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-oscar/django-oscar-elasticsearch/55ac8eb0ff3efa168bc9cbf5c15f74a922009345/sandbox/assortment/migrations/__init__.py -------------------------------------------------------------------------------- /sandbox/assortment/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class AssortmentUser(models.Model): 5 | user = models.OneToOneField("auth.User", on_delete=models.CASCADE, related_name="assortment_user") 6 | 7 | 8 | class AssortmentProduct(models.Model): 9 | assortment_user = models.ForeignKey(AssortmentUser, on_delete=models.CASCADE, related_name="products") 10 | product = models.ForeignKey("catalogue.Product", on_delete=models.CASCADE) 11 | -------------------------------------------------------------------------------- /sandbox/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", "settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oscar.defaults import * 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | # Quick-start development settings - unsuitable for production 9 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 10 | 11 | # SECURITY WARNING: keep the secret key used in production secret! 12 | SECRET_KEY = 's29@v&d_l)a5j_!do+m3e6t(&orgh24=k71(lb6%q1ybf9ebeb' 13 | 14 | # SECURITY WARNING: don't run with debug turned on in production! 15 | DEBUG = True 16 | 17 | ALLOWED_HOSTS = [] 18 | 19 | 20 | # Application definition 21 | 22 | INSTALLED_APPS = [ 23 | 'django.contrib.admin', 24 | 'django.contrib.auth', 25 | 'django.contrib.contenttypes', 26 | 'django.contrib.sessions', 27 | 'django.contrib.messages', 28 | 'django.contrib.staticfiles', 29 | 'django.contrib.sites', 30 | 'django.contrib.flatpages', 31 | 32 | 'oscar.config.Shop', 33 | 'oscar.apps.analytics.apps.AnalyticsConfig', 34 | 'oscar.apps.checkout.apps.CheckoutConfig', 35 | 'oscar.apps.address.apps.AddressConfig', 36 | 'oscar.apps.shipping.apps.ShippingConfig', 37 | 'oscar.apps.catalogue.apps.CatalogueConfig', 38 | 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig', 39 | 'oscar.apps.communication.apps.CommunicationConfig', 40 | 'oscar.apps.partner.apps.PartnerConfig', 41 | 'oscar.apps.basket.apps.BasketConfig', 42 | 'oscar.apps.payment.apps.PaymentConfig', 43 | 'oscar.apps.offer.apps.OfferConfig', 44 | 'oscar.apps.order.apps.OrderConfig', 45 | 'oscar.apps.customer.apps.CustomerConfig', 46 | 'oscar.apps.voucher.apps.VoucherConfig', 47 | 'oscar.apps.wishlists.apps.WishlistsConfig', 48 | 'oscar.apps.dashboard.apps.DashboardConfig', 49 | 'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig', 50 | 'oscar.apps.dashboard.users.apps.UsersDashboardConfig', 51 | 'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig', 52 | 'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig', 53 | 'oscar.apps.dashboard.offers.apps.OffersDashboardConfig', 54 | 'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig', 55 | 'oscar.apps.dashboard.pages.apps.PagesDashboardConfig', 56 | 'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig', 57 | 'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig', 58 | 'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig', 59 | 'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig', 60 | 'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig', 61 | 62 | # 3rd-party apps that oscar depends on 63 | 'widget_tweaks', 64 | 'haystack', 65 | 'treebeard', 66 | 'sorl.thumbnail', 67 | 'django_tables2', 68 | 'oscar_elasticsearch.search.apps.OscarElasticSearchConfig', 69 | "oscar_odin.apps.OscarOdinAppConfig", 70 | "assortment" 71 | ] 72 | 73 | MIDDLEWARE = [ 74 | 'django.middleware.security.SecurityMiddleware', 75 | 'django.contrib.sessions.middleware.SessionMiddleware', 76 | 'django.middleware.common.CommonMiddleware', 77 | 'django.middleware.csrf.CsrfViewMiddleware', 78 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 79 | 'django.contrib.messages.middleware.MessageMiddleware', 80 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 81 | 'oscar.apps.basket.middleware.BasketMiddleware', 82 | ] 83 | 84 | ROOT_URLCONF = 'urls' 85 | 86 | TEMPLATES = [ 87 | { 88 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 89 | 'DIRS': [ 90 | os.path.join(BASE_DIR, 'oscar_elasticsearch/search/templates'), 91 | ], 92 | 'APP_DIRS': True, 93 | 'OPTIONS': { 94 | 'context_processors': [ 95 | 'django.template.context_processors.debug', 96 | 'django.template.context_processors.request', 97 | 'django.contrib.auth.context_processors.auth', 98 | 'django.template.context_processors.i18n', 99 | 'django.contrib.messages.context_processors.messages', 100 | 101 | 'oscar.apps.search.context_processors.search_form', 102 | 'oscar.apps.checkout.context_processors.checkout', 103 | 'oscar.apps.communication.notifications.context_processors.notifications', 104 | 'oscar.core.context_processors.metadata', 105 | ], 106 | }, 107 | }, 108 | ] 109 | 110 | LOGGING = { 111 | 'version': 1, 112 | 'disable_existing_loggers': True, 113 | 'formatters': { 114 | 'verbose': { 115 | 'format': '%(levelname)s %(asctime)s %(module)s %(message)s', 116 | }, 117 | 'simple': { 118 | 'format': '[%(asctime)s] %(message)s' 119 | }, 120 | }, 121 | 'root': { 122 | 'level': 'DEBUG', 123 | 'handlers': ['console'], 124 | }, 125 | 'handlers': { 126 | 'null': { 127 | 'level': 'DEBUG', 128 | 'class': 'logging.NullHandler', 129 | }, 130 | 'console': { 131 | 'level': 'DEBUG', 132 | 'class': 'logging.StreamHandler', 133 | 'formatter': 'simple' 134 | }, 135 | }, 136 | 'loggers': { 137 | 'oscar': { 138 | 'level': 'DEBUG', 139 | 'propagate': True, 140 | }, 141 | 142 | # Django loggers 143 | 'django': { 144 | 'handlers': ['null'], 145 | 'propagate': True, 146 | 'level': 'INFO', 147 | }, 148 | 'django.request': { 149 | 'handlers': ['console'], 150 | 'level': 'ERROR', 151 | 'propagate': True, 152 | }, 153 | 'django.db.backends': { 154 | 'level': 'WARNING', 155 | 'propagate': True, 156 | }, 157 | } 158 | } 159 | 160 | WSGI_APPLICATION = 'wsgi.application' 161 | 162 | 163 | # Database 164 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 165 | 166 | DATABASES = { 167 | 'default': { 168 | 'ENGINE': 'django.db.backends.sqlite3', 169 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 170 | } 171 | } 172 | 173 | 174 | # Password validation 175 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 176 | 177 | AUTH_PASSWORD_VALIDATORS = [ 178 | { 179 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 180 | }, 181 | { 182 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 183 | }, 184 | { 185 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 186 | }, 187 | { 188 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 189 | }, 190 | ] 191 | 192 | 193 | # Internationalization 194 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 195 | 196 | LANGUAGE_CODE = 'en-us' 197 | 198 | TIME_ZONE = 'UTC' 199 | 200 | USE_I18N = True 201 | 202 | USE_L10N = True 203 | 204 | USE_TZ = True 205 | 206 | 207 | # Static files (CSS, JavaScript, Images) 208 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 209 | 210 | STATIC_URL = 'static/' 211 | 212 | MEDIA_URL = 'media/' 213 | 214 | STATIC_ROOT = os.path.join(BASE_DIR, 'sandbox', 'static') 215 | 216 | MEDIA_ROOT = os.path.join(BASE_DIR, 'sandbox', 'media') 217 | 218 | AUTHENTICATION_BACKENDS = ( 219 | 'oscar.apps.customer.auth_backends.EmailBackend', 220 | 'django.contrib.auth.backends.ModelBackend', 221 | ) 222 | 223 | SITE_ID = 1 224 | 225 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 226 | 227 | OSCAR_ELASTICSEARCH_FACETS = [ 228 | { 229 | "name": "price", 230 | "label": "Price", 231 | "type": "range", 232 | "formatter": "oscar_elasticsearch.search.format.currency", 233 | "ranges": [ 234 | 25, 235 | 100, 236 | 500, 237 | 1000 238 | ] 239 | }, 240 | { 241 | "name": "attrs.gewicht", 242 | "label": "Gewicht", 243 | "type": "range", 244 | "ranges": [ 245 | 50, 246 | 100, 247 | 150 248 | ] 249 | }, 250 | { 251 | "name": "attrs.googleshopping", 252 | "label": "Google product", 253 | "type": "term", 254 | "ranges": [] 255 | }, 256 | { 257 | "name": "attrs.size", 258 | "label": "Maat", 259 | "type": "term", 260 | "ranges": [], 261 | "order": "asc" 262 | }, 263 | { 264 | "name": "attrs.height", 265 | "label": "Hoogte", 266 | "type": "term", 267 | "ranges": [] 268 | }, 269 | { 270 | "name": "attrs.zult", 271 | "label": "Datum", 272 | "type": "term", 273 | "ranges": [] 274 | }, 275 | { 276 | "name": "attrs.stroomverbruik", 277 | "label": "Stroomverbruik", 278 | "type": "term", 279 | "ranges": [] 280 | }, 281 | { 282 | "name": "attrs.bijzonderheden", 283 | "label": "Bijzonderheden", 284 | "type": "term", 285 | "ranges": [] 286 | } 287 | ] 288 | 289 | OSCAR_DEFAULT_CURRENCY = 'EUR' 290 | OSCAR_ELASTICSEARCH_PROJECT_NAME = "oscar_elasticsearch" 291 | 292 | OSCAR_ELASTICSEARCH_FILTER_AVAILABLE = False 293 | 294 | OSCAR_ELASTICSEARCH_SERVER_URLS = [os.environ.get("OSCAR_ELASTICSEARCH_SERVER_URLS", "http://127.0.0.1:9200")] 295 | 296 | WAGTAILSEARCH_BACKENDS = { 297 | "default": { 298 | "BACKEND": "oscar_elasticsearch.search.backend", 299 | "URLS": ["http://127.0.0.1:9200"], 300 | "INDEX": "my-index-name", 301 | "TIMEOUT": 120, 302 | "OPTIONS": {}, 303 | "INDEX_SETTINGS": {}, 304 | "ATOMIC_REBUILD": True, 305 | "AUTO_UPDATE": True, 306 | } 307 | } 308 | 309 | HAYSTACK_CONNECTIONS = {"default": {}} 310 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.conf.urls import i18n 4 | from django.conf.urls.static import static 5 | from django.contrib import admin 6 | from django.urls import include, path 7 | 8 | 9 | urlpatterns = [ 10 | path('admin/', admin.site.urls), 11 | path('i18n/', include(i18n)), 12 | path('', include(apps.get_app_config("oscar").urls[0])), 13 | path('search/', include(apps.get_app_config("search").urls[0])), 14 | ] 15 | 16 | if settings.DEBUG: 17 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 18 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 19 | -------------------------------------------------------------------------------- /sandbox/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | __version__ = "3.0.0" 5 | 6 | 7 | setup( 8 | # package name in pypi 9 | name="django-oscar-elasticsearch", 10 | # extract version from module. 11 | version=__version__, 12 | description="Search app for oscar using elasticsearch", 13 | long_description="Search app for oscar using elasticsearch", 14 | classifiers=[], 15 | keywords="", 16 | author="Lars van de Kerkhof", 17 | author_email="specialunderwear@gmail.com", 18 | url="https://github.com/specialunderwear/django-oscar-elasticsearch", 19 | license="GPL", 20 | # include all packages in the egg, except the test package. 21 | packages=find_packages(exclude=["ez_setup", "examples", "tests"]), 22 | namespace_packages=[], 23 | # include non python files 24 | include_package_data=True, 25 | zip_safe=False, 26 | # specify dependencies 27 | install_requires=[ 28 | "django>=3.2", 29 | "setuptools", 30 | "django-oscar>=4.0a1", 31 | "purl", 32 | "elasticsearch>=8.0.0,<9", 33 | "uwsgidecorators-fallback", 34 | "django-oscar-odin>=0.3.0", 35 | "python-dateutil>=2.8.0", 36 | ], 37 | # mark test target to require extras. 38 | extras_require={ 39 | "test": [ 40 | "mock", 41 | "coverage>=5.4", 42 | "sorl-thumbnail>=12.10.0,<13.0.0", 43 | "vdt.versionplugin.wheel", 44 | ], 45 | "dev": ["pylint>=2.17.4", "pylint-django>=2.5.3", "black>=23.3.0"], 46 | }, 47 | ) 48 | --------------------------------------------------------------------------------