├── .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 | 
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 |
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 | {% render_product result %}
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 |
--------------------------------------------------------------------------------