├── .github ├── ISSUE_TEMPLATE │ └── Bug_report.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── main.yml ├── .gitignore ├── CONTRIBUTING.md ├── ChangeLog ├── DOCKER_README.md ├── Dockerfile ├── LICENSE ├── MAINTAINERS.md ├── MANIFEST.in ├── README.md ├── algoliasearch_django ├── __init__.py ├── apps.py ├── decorators.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── algolia_applysettings.py │ │ ├── algolia_clearindex.py │ │ └── algolia_reindex.py ├── models.py ├── registration.py ├── settings.py └── version.py ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── factories.py ├── index.py ├── models.py ├── settings.py ├── test_commands.py ├── test_decorators.py ├── test_engine.py ├── test_index.py ├── test_signal.py └── urls.py └── tox.ini /.github/ISSUE_TEMPLATE/Bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | title: '[bug]: ' 4 | labels: ['bug', 'triage'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## Please help us help you! 10 | 11 | Before filing your issue, ask yourself: 12 | - Is there an issue already opened for this bug? 13 | - Can I reproduce it? 14 | 15 | If you are not sure about the origin of the issue, or if it impacts your customer experience, please contact [our support team](https://alg.li/support). 16 | - type: textarea 17 | attributes: 18 | label: Description 19 | description: A clear and concise description of what the bug is. 20 | validations: 21 | required: true 22 | - type: dropdown 23 | id: python version 24 | attributes: 25 | label: python version 26 | description: What is the Python version you've reproduced the error with 27 | options: 28 | - 3.8 29 | - 3.9 30 | - 3.10 31 | - 3.11 32 | - 3.12 33 | - 3.13 34 | validations: 35 | required: true 36 | - type: dropdown 37 | id: django version 38 | attributes: 39 | label: Django version 40 | description: What is the Django version you've reproduced the error with 41 | options: 42 | - 4.0 43 | - 4.1 44 | - 4.2 45 | - 5.0 46 | - 5.1 47 | validations: 48 | required: true 49 | - type: textarea 50 | attributes: 51 | label: Steps to reproduce 52 | description: Write down the steps to reproduce the bug, please include any information that seems relevant for us to reproduce it properly 53 | placeholder: | 54 | 1. Use method `...` 55 | 2. With parameters `...` 56 | 3. See error 57 | validations: 58 | required: true 59 | - type: textarea 60 | id: logs 61 | attributes: 62 | label: Relevant log output 63 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 64 | render: shell 65 | - type: checkboxes 66 | attributes: 67 | label: Self-service 68 | description: | 69 | If you feel like you could contribute to this issue, please check the box below. This would tell us and other people looking for contributions that someone's working on it. 70 | If you do check this box, please send a pull request within 7 days so we can still delegate this to someone else. 71 | options: 72 | - label: I'd be willing to fix this bug myself. 73 | 74 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 🧭 What and Why 2 | 3 | 🎟 Related Issue: 4 | 5 | ### Changes included: 6 | 7 | 11 | 12 | ## 🧪 Test 13 | 14 | 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: algoliasearch-django 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ${{ matrix.os }} 16 | continue-on-error: true 17 | strategy: 18 | matrix: 19 | include: 20 | # django 4.0 21 | - version: "3.8" 22 | toxenv: py38-django40 23 | os: ubuntu-22.04 24 | - version: "3.9" 25 | toxenv: py39-django40 26 | os: ubuntu-22.04 27 | - version: "3.10" 28 | toxenv: py310-django40 29 | os: ubuntu-22.04 30 | # django 4.1 31 | - version: "3.8" 32 | toxenv: py38-django41 33 | os: ubuntu-22.04 34 | - version: "3.9" 35 | toxenv: py39-django41 36 | os: ubuntu-22.04 37 | - version: "3.10" 38 | toxenv: py310-django41 39 | os: ubuntu-22.04 40 | - version: "3.11" 41 | toxenv: py311-django41 42 | os: ubuntu-22.04 43 | # django 4.2 44 | - version: "3.8" 45 | toxenv: py38-django42 46 | os: ubuntu-22.04 47 | - version: "3.9" 48 | toxenv: py39-django42 49 | os: ubuntu-22.04 50 | - version: "3.10" 51 | toxenv: py310-django42 52 | os: ubuntu-22.04 53 | - version: "3.11" 54 | toxenv: py311-django42 55 | os: ubuntu-22.04 56 | - version: "3.12" 57 | toxenv: py312-django42 58 | os: ubuntu-22.04 59 | # django 5.0 60 | - version: "3.10" 61 | toxenv: py310-django50 62 | os: ubuntu-22.04 63 | - version: "3.11" 64 | toxenv: py311-django50 65 | os: ubuntu-22.04 66 | - version: "3.12" 67 | toxenv: py312-django50 68 | os: ubuntu-22.04 69 | # django 5.1 70 | - version: "3.10" 71 | toxenv: py310-django51 72 | os: ubuntu-22.04 73 | - version: "3.11" 74 | toxenv: py311-django51 75 | os: ubuntu-22.04 76 | - version: "3.12" 77 | toxenv: py312-django51 78 | os: ubuntu-22.04 79 | - version: "3.13" 80 | toxenv: py313-django51 81 | os: ubuntu-22.04 82 | 83 | steps: 84 | - uses: actions/checkout@v3 85 | 86 | - name: Set up Python ${{ matrix.version }} 87 | uses: actions/setup-python@v4 88 | with: 89 | python-version: ${{ matrix.version }} 90 | 91 | - name: Install dependencies and run tests 92 | timeout-minutes: 20 93 | run: | 94 | python -m venv python-ci-run 95 | source python-ci-run/bin/activate 96 | pip3 install --upgrade pip 97 | pip3 install tox 98 | python -m pip install -U build 99 | TOXENV=${{ matrix.toxenv }} ALGOLIA_APPLICATION_ID=${{ secrets.ALGOLIA_APPLICATION_ID }} ALGOLIA_API_KEY=${{ secrets.ALGOLIA_API_KEY }} tox 100 | python -m build 101 | 102 | release: 103 | name: Publish 104 | runs-on: ubuntu-22.04 105 | environment: 106 | name: pypi 107 | url: https://pypi.org/p/algoliasearch-django 108 | permissions: 109 | id-token: write 110 | needs: 111 | - build 112 | if: | 113 | always() && 114 | !contains(needs.*.result, 'cancelled') && 115 | !contains(needs.*.result, 'failure') 116 | steps: 117 | - uses: actions/checkout@v4 118 | 119 | - name: Set up Python 120 | uses: actions/setup-python@v4 121 | with: 122 | python-version: 3.13 123 | 124 | - name: deps and build 125 | run: | 126 | pip3 install --upgrade pip 127 | python -m pip install -U build 128 | python -m build 129 | 130 | - name: Publish algoliasearch package to PyPI 131 | if: | 132 | github.ref == 'refs/heads/master' && 133 | startsWith(github.event.head_commit.message, 'chore: release') 134 | uses: pypa/gh-action-pypi-publish@release/v1 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # IDE Files 27 | *.iml 28 | .idea/ 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # IDE files 64 | .idea/* 65 | *.iml 66 | 67 | # PyENV 68 | .python-version 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Hi there! We're thrilled that you'd like to contribute to this project. 4 | Your help is essential to keeping it great. 5 | 6 | ### Opening an issue 7 | 8 | Each repository provides a template for issues. Please tell us the client and language version, and 9 | provide a clear description of the problem you're facing. Steps to reproduce, or example code 10 | (repository, jsfiddle, and such), are a big help. 11 | 12 | ### Submitting a pull request 13 | 14 | Keep your changes as focused as possible. If there are multiple changes you'd like to make, 15 | please consider submitting them as separate pull requests unless they are related to each other. 16 | 17 | Here are a few tips to increase the likelihood of being merged: 18 | 19 | - [ ] Write tests & run them 20 | ```sh 21 | $ python3 -m venv venv 22 | $ source venv/bin/activate 23 | $ pip install -r requirements.txt 24 | $ ALGOLIA_APPLICATION_ID=*** ALGOLIA_API_KEY=*** tox 25 | ``` 26 | - [ ] Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 27 | - [ ] Allow [edits from maintainers](https://blog.github.com/2016-09-07-improving-collaboration-with-forks/). 28 | 29 | ### Security issues 30 | If you find any security risk in the project, please open an issue. 31 | 32 | ### API Breaking changes 33 | 34 | We care deeply about backward compatibility for our API clients libraries. 35 | If it's necessary, we're ready to break backward compatibility, 36 | but this should be pretty rare. 37 | 38 | If you want to make a change that will break the backward compatibility of the API, 39 | open an issue first to discuss it with the maintainers. 40 | 41 | ### Editing `README.md` and similar files 42 | 43 | Note that some files are managed outside this repository and are committed automatically. 44 | 45 | The `README.md` is generated automatically from our doc. If you'd like us to update this file, 46 | feel free to open an issue. 47 | 48 | `.github` directory is managed in [this repository](https://github.com/algolia/algoliasearch-client-common), 49 | any Pull Request there is welcome. 50 | 51 | ## Label caption 52 | 53 | Labels across all Algolia API clients repositories are normalized. 54 | 55 | 56 | 57 | | Label | Meaning | 58 | |---------------------------------------------------------------------------|----------------------------------------------------------------------------------------| 59 | |  Do not merge | PR should not be merged (decided by maintainers) | 60 | |  WIP | PR is not ready, no need to look at it (decided by contributors) | 61 | |  Ready | The PR is ready, reviewed, tests are green, if you're brave enough: click merge button | 62 | |  Waiting for API | The feature is implemented but the REST API is not live yet | 63 | |  Discussion | We need everyone's opinion on this, please join the conversation and share your ideas | 64 | |  Support | A user needs help but it's not really a bug | 65 | |  API feature | New API feature added to every client (like query rules) | 66 | |  Chore | CI, docs, and everything around the code | 67 | |  Bug | It's a bug, fix it! | 68 | |  Breaking change | RED ALERT! This means we need a new major version | 69 | |  Good First Issue | If you want to contribute, this one is _easy_ to tackle! | 70 | 71 | 72 | 73 | 74 | ## Resources 75 | 76 | - [Contributing to Open Source on GitHub](https://guides.github.com/activities/contributing-to-open-source/) 77 | - [Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 78 | - [GitHub Help](https://help.github.com) 79 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | 2 | CHANGELOG 3 | 4 | 2024-12-26 4.0.0 5 | * [MAJOR] drop support for python <3.8 [#336](https://github.com/algolia/algoliasearch-django/pull/336) 6 | * [MAJOR] drop support for django <4.0 [#336](https://github.com/algolia/algoliasearch-django/pull/336) 7 | * [MAJOR] remove method `clear_index` in favor of `clear_objects` [#336](https://github.com/algolia/algoliasearch-django/pull/336) 8 | 9 | 2023-04-27 3.0.0 10 | * [MAJOR] drop python 2.7, update tox and CI run env (#331) 11 | * [FEAT] Add support for Python 3.9, 3.10 and 3.11 (#329) 12 | 13 | 2021-04-08 2.0.0 14 | * [MAJOR] Updates the python client used to the latest major (>= 2.0) (#306) 15 | 16 | 2021-01-19 1.7.3 17 | * [CHORE] adds tests for django 2.2LTS and django 3.x (#303) 18 | * [CHORE] introduces the migration changes required to move from 1.x to 2.0, namely introduction of API: `MIDDLEWARES` and `Templates` (#303) 19 | 20 | 2020-09-03 1.7.2 21 | * [FIX] Prevent deprecation warning by removing deprecated call to set_extra_header (#297) 22 | 23 | 2019-04-04 1.7.1 24 | * [FIX] Fixes non specified version of algoliasearch in requirements (#281) 25 | 26 | 2018-08-27 1.7.0 27 | * [FEAT] Context Decorator to temporarily disable the auto-indexing (#266) 28 | * [FIX] Make the temp index name respect the suffix and prefix (#268) 29 | 30 | 2018-05-30 1.6.0 31 | * [FEAT] Auto-discover the index.py files 32 | 33 | 2018-02-14 1.5.5 34 | * [FIX] Settings no more shared across AlgoliaIndex instances 35 | * [FIX] Save rules and synonyms over reindex 36 | 37 | 2018-01-16 1.5.4 38 | * Shallow release to solve 1.5.3 release issue 39 | 40 | 2018-01-16 1.5.3 41 | * [FIX] Fix reindex_all deleting settings (#239) 42 | 43 | 2017-11-02 1.5.2 44 | * [FIX] Remove relations from indexed model fields (#233) 45 | 46 | 2017-11-02 1.5.1 47 | * [FIX] Fix reindex_all() and save_record() when should_index is not callable 48 | 49 | 2017-05-21 1.5.0 50 | * [FEAT] Allow properties as custom_objectID 51 | 52 | 2017-05-21 1.4.1 53 | * [FIX] Fix handling of unicode strings in python2 54 | 55 | 2017-05-21 1.4.0 56 | * [ADD] Reset method to reinitialize an AlgoliaEngine 57 | 58 | 2017-05-21 1.3.2 59 | * [FIX] Fix reindex to handle replicas 60 | 61 | 2017-05-24 1.3.1 62 | * [README] Update readme to match new package name 63 | 64 | 2017-05-23 1.3.0 65 | * [CHANGE] Module name to avoid conflict when installing the package with pip 66 | * [CHANGE] Big performance improvements 67 | * [ADD] Option to raise or not exceptions. By default, raise only when DEBUG=True 68 | * [ADD] Create multiple AlgoliaEngine with different settings 69 | * [ADD] Auto-indexing option at registration time 70 | * [FIX] algolia_reindex command with batchsize argument on python34-django18 71 | * [FIX] Fields syntax 72 | * [FIX] Avoid mutable default parameters 73 | * [FIX] Fix bug in error handling 74 | 75 | 2017-01-20 1.2.5 76 | * [FIX] Default to model.pk instead of 'id' 77 | 78 | 2016-04-15 1.2.4 79 | * [FIX] Fix --batch-size of reindex command on Django 1.7 80 | 81 | 2015-12-15 1.2.3 82 | * [FIX] Check that geo_field callable returns something valid 83 | 84 | 2015-12-03 1.2.2 85 | * [FIX] Compatibility warning with Django 1.9 86 | 87 | 2015-07-09 1.2.1 88 | * [ADD] `get_queryset` for grain indexing possibility 89 | * [ADD] Option to deactivate auto-indexing 90 | * [FIX] Various bugs 91 | 92 | 2015-07-04 1.2.0 93 | * [REMOVE] algolia_buildindex command. Use algolia_reindex instead. 94 | * [CHANGE] Settings format. Last format is still supported. 95 | * [ADD] Unit test. 96 | * [ADD] Tag capacity 97 | * [ADD] Conditional indexing 98 | * [ADD] Search capacity on backend 99 | * [FIX] Invalid custom_objectID attribute 100 | * [FIX] Exception throw by the command when using Django 1.7 101 | -------------------------------------------------------------------------------- /DOCKER_README.md: -------------------------------------------------------------------------------- 1 | ## Build the image 2 | 3 | > Make sure to have [Docker installed](https://docs.docker.com/engine/install/) 4 | 5 | ```bash 6 | docker build -t algoliasearch-django . 7 | ``` 8 | 9 | ## Run the image 10 | 11 | You need to provide a few environment variables at runtime to be able to run the image and the test suite. 12 | 13 | ```bash 14 | docker run -it --rm --env ALGOLIA_APPLICATION_ID=XXXXXX \ 15 | --env ALGOLIA_API_KEY=XXXXXXXXXXXXXX \ 16 | -v $PWD:/code -w /app algoliasearch-django bash 17 | ``` 18 | 19 | However, we advise you to export them. That way, you can use [Docker's shorten syntax](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file) to set your variables. 20 | 21 | ```bash 22 | export ALGOLIA_APPLICATION_ID=XXXXXX 23 | export ALGOLIA_API_KEY=XXX 24 | 25 | docker run -it --rm --env ALGOLIA_APPLICATION_ID --env ALGOLIA_API_KEY -v $PWD:/code -w /code algoliasearch-django bash 26 | ``` 27 | 28 | Once your container is running, any changes you make in your IDE are directly reflected in the container. 29 | 30 | To launch the tests, you can use this command 31 | 32 | ```bash 33 | tox -e py313-django51 34 | ``` 35 | 36 | If you'd like to sue an env other that `py313-django51`, run `tox --listenvs` to see the list of available envs. 37 | Feel free to contact us if you have any questions. 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | 3 | # Force the stdout and stderr streams to be unbuffered. 4 | # Ensure python output goes to your terminal 5 | ENV PYTHONUNBUFFERED=1 6 | 7 | WORKDIR /code 8 | COPY requirements.txt /code/ 9 | 10 | RUN pip3 install --upgrade pip && pip3 install -r requirements.txt 11 | 12 | COPY . /code/ 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-Present Algolia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | ## `algolia/algoliasearch-django` maintainers 2 | 3 | | Name | Email | 4 | |-----------------|------------------------| 5 | | Algolia | https://alg.li/support | 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include ChangeLog 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
16 | Documentation • 17 | Community Forum • 18 | Stack Overflow • 19 | Report a bug • 20 | FAQ • 21 | Support 22 |
23 | 24 | ## API Documentation 25 | 26 | You can find the full reference on [Algolia's website](https://www.algolia.com/doc/framework-integration/django/). 27 | 28 | 1. **[Setup](#setup)** 29 | 30 | - [Introduction](#introduction) 31 | - [Install](#install) 32 | - [Setup](#setup) 33 | - [Quick Start](#quick-start) 34 | 35 | 1. **[Commands](#commands)** 36 | 37 | - [Commands](#commands) 38 | 39 | 1. **[Search](#search)** 40 | 41 | - [Search](#search) 42 | 43 | 1. **[Geo-Search](#geo-search)** 44 | 45 | - [Geo-Search](#geo-search) 46 | 47 | 1. **[Tags](#tags)** 48 | 49 | - [Tags](#tags) 50 | 51 | 1. **[Options](#options)** 52 | 53 | - [CustomobjectID
](#custom-codeobjectidcode)
54 | - [Custom index name](#custom-index-name)
55 | - [Field Preprocessing and Related objects](#field-preprocessing-and-related-objects)
56 | - [Index settings](#index-settings)
57 | - [Restrict indexing to a subset of your data](#restrict-indexing-to-a-subset-of-your-data)
58 | - [Multiple indices per model](#multiple-indices-per-model)
59 | - [Temporarily disable the auto-indexing](#temporarily-disable-the-auto-indexing)
60 |
61 | 1. **[Tests](#tests)**
62 |
63 | - [Run Tests](#run-tests)
64 |
65 | 1. **[Troubleshooting](#troubleshooting)**
66 | - [Frequently asked questions](#frequently-asked-questions)
67 |
68 | # Setup
69 |
70 | ## Introduction
71 |
72 | This package lets you easily integrate the Algolia Search API to your [Django](https://www.djangoproject.com/) project. It's based on the [algoliasearch-client-python](https://github.com/algolia/algoliasearch-client-python) package.
73 |
74 | You might be interested in this sample Django application providing a typeahead.js based auto-completion and Google-like instant search: [algoliasearch-django-example](https://github.com/algolia/algoliasearch-django-example).
75 |
76 | - Compatible with **Python 3.8+**.
77 | - Supports **Django 4.x** and **5.x**.
78 |
79 | ## Install
80 |
81 | ```sh
82 | pip install algoliasearch-django
83 | ```
84 |
85 | ## Setup
86 |
87 | In your Django settings, add `algoliasearch_django` to `INSTALLED_APPS` and add these two settings:
88 |
89 | ```python
90 | ALGOLIA = {
91 | 'APPLICATION_ID': 'MyAppID',
92 | 'API_KEY': 'MyApiKey'
93 | }
94 | ```
95 |
96 | There are several optional settings:
97 |
98 | - `INDEX_PREFIX`: prefix all indices. Use it to separate different applications, like `site1_Products` and `site2_Products`.
99 | - `INDEX_SUFFIX`: suffix all indices. Use it to differentiate development and production environments, like `Location_dev` and `Location_prod`.
100 | - `AUTO_INDEXING`: automatically synchronize the models with Algolia (default to **True**).
101 | - `RAISE_EXCEPTIONS`: raise exceptions on network errors instead of logging them (default to **settings.DEBUG**).
102 |
103 | ## Quick Start
104 |
105 | Create an `index.py` inside each application that contains the models you want to index.
106 | Inside this file, call `algoliasearch.register()` for each of the models you want to index:
107 |
108 | ```python
109 | # index.py
110 |
111 | import algoliasearch_django as algoliasearch
112 |
113 | from .models import YourModel
114 |
115 | algoliasearch.register(YourModel)
116 | ```
117 |
118 | By default, all the fields of your model will be used. You can configure the index by creating a subclass of `AlgoliaIndex` and using the `register` decorator:
119 |
120 | ```python
121 | # index.py
122 |
123 | from algoliasearch_django import AlgoliaIndex
124 | from algoliasearch_django.decorators import register
125 |
126 | from .models import YourModel
127 |
128 | @register(YourModel)
129 | class YourModelIndex(AlgoliaIndex):
130 | fields = ('name', 'date')
131 | geo_field = 'location'
132 | settings = {'searchableAttributes': ['name']}
133 | index_name = 'my_index'
134 |
135 | ```
136 |
137 | # Commands
138 |
139 | ## Commands
140 |
141 | - `python manage.py algolia_reindex`: reindex all the registered models. This command will first send all the record to a temporary index and then moves it.
142 | - you can pass `--model` parameter to reindex a given model
143 | - `python manage.py algolia_applysettings`: (re)apply the index settings.
144 | - `python manage.py algolia_clearindex`: clear the index
145 |
146 | # Search
147 |
148 | ## Search
149 |
150 | We recommend using our [InstantSearch.js library](https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/) to build your search
151 | interface and perform search queries directly from the end-user browser without going through your server.
152 |
153 | However, if you want to search from your backend you can use the `raw_search(YourModel, 'yourQuery', params)` method.
154 | It retrieves the raw JSON answer from the API, and accepts in `param` any
155 | [search parameters](https://www.algolia.com/doc/api-reference/search-api-parameters/).
156 |
157 | ```python
158 | from algoliasearch_django import raw_search
159 |
160 | params = { "hitsPerPage": 5 }
161 | response = raw_search(Contact, "jim", params)
162 | ```
163 |
164 | # Geo-Search
165 |
166 | ## Geo-Search
167 |
168 | Use the `geo_field` attribute to localize your record. `geo_field` should be a callable that returns a tuple (latitude, longitude).
169 |
170 | ```python
171 | class Contact(models.model):
172 | name = models.CharField(max_length=20)
173 | lat = models.FloatField()
174 | lng = models.FloatField()
175 |
176 | def location(self):
177 | return (self.lat, self.lng)
178 |
179 | class ContactIndex(AlgoliaIndex):
180 | fields = 'name'
181 | geo_field = 'location'
182 |
183 | algoliasearch.register(Contact, ContactIndex)
184 | ```
185 |
186 | # Tags
187 |
188 | ## Tags
189 |
190 | Use the `tags` attributes to add tags to your record. It can be a field or a callable.
191 |
192 | ```python
193 | class ArticleIndex(AlgoliaIndex):
194 | tags = 'category'
195 | ```
196 |
197 | At query time, specify `{ tagFilters: 'tagvalue' }` or `{ tagFilters: ['tagvalue1', 'tagvalue2'] }` as search parameters to restrict the result set to specific tags.
198 |
199 | # Options
200 |
201 | ## Custom `objectID`
202 |
203 | You can choose which field will be used as the `objectID `. The field should be unique and can
204 | be a string or integer. By default, we use the `pk` field of the model.
205 |
206 | ```python
207 | class ArticleIndex(AlgoliaIndex):
208 | custom_objectID = 'post_id'
209 | ```
210 |
211 | ## Custom index name
212 |
213 | You can customize the index name. By default, the index name will be the name of the model class.
214 |
215 | ```python
216 | class ContactIndex(algoliaindex):
217 | index_name = 'Enterprise'
218 | ```
219 |
220 | ## Field Preprocessing and Related objects
221 |
222 | If you want to process a field before indexing it (e.g. capitalizing a `Contact`'s `name`),
223 | or if you want to index a [related object](https://docs.djangoproject.com/en/1.11/ref/models/relations/)'s
224 | attribute, you need to define **proxy methods** for these fields.
225 |
226 | ### Models
227 |
228 | ```python
229 | class Account(models.Model):
230 | username = models.CharField(max_length=40)
231 | service = models.CharField(max_length=40)
232 |
233 | class Contact(models.Model):
234 | name = models.CharField(max_length=40)
235 | email = models.EmailField(max_length=60)
236 | //...
237 | accounts = models.ManyToManyField(Account)
238 |
239 | def account_names(self):
240 | return [str(account) for account in self.accounts.all()]
241 |
242 | def account_ids(self):
243 | return [account.id for account in self.accounts.all()]
244 | ```
245 |
246 | ### Index
247 |
248 | ```python
249 | from algoliasearch_django import AlgoliaIndex
250 |
251 | class ContactIndex(AlgoliaIndex):
252 | fields = ('name', 'email', 'company', 'address', 'city', 'county',
253 | 'state', 'zip_code', 'phone', 'fax', 'web', 'followers', 'account_names', 'account_ids')
254 |
255 | settings = {
256 | 'searchableAttributes': ['name', 'email', 'company', 'city', 'county', 'account_names',
257 | }
258 | ```
259 |
260 | - With this configuration, you can search for a `Contact` using its `Account` names
261 | - You can use the associated `account_ids` at search-time to fetch more data from your
262 | model (you should **only proxy the fields relevant for search** to keep your records' size
263 | as small as possible)
264 |
265 | ## Index settings
266 |
267 | We provide many ways to configure your index allowing you to tune your overall index relevancy.
268 | All the configuration is explained on [our doc](https://www.algolia.com/doc/api-reference/api-parameters/).
269 |
270 | ```python
271 | class ArticleIndex(AlgoliaIndex):
272 | settings = {
273 | 'searchableAttributes': ['name', 'description', 'url'],
274 | 'customRanking': ['desc(vote_count)', 'asc(name)']
275 | }
276 | ```
277 |
278 | ## Restrict indexing to a subset of your data
279 |
280 | You can add constraints controlling if a record must be indexed or not. `should_index` should be a
281 | callable that returns a boolean.
282 |
283 | ```python
284 | class Contact(models.model):
285 | name = models.CharField(max_length=20)
286 | age = models.IntegerField()
287 |
288 | def is_adult(self):
289 | return (self.age >= 18)
290 |
291 | class ContactIndex(AlgoliaIndex):
292 | should_index = 'is_adult'
293 | ```
294 |
295 | ## Multiple indices per model
296 |
297 | It is possible to have several indices for a single model.
298 |
299 | - First, define all your indices that you want for a model:
300 |
301 | ```python
302 | from algoliasearch_django import AlgoliaIndex
303 |
304 | class MyModelIndex1(AlgoliaIndex):
305 | name = 'MyModelIndex1'
306 | ...
307 |
308 | class MyModelIndex2(AlgoliaIndex):
309 | name = 'MyModelIndex2'
310 | ...
311 | ```
312 |
313 | - Then, define a meta model which will aggregate those indices:
314 |
315 | ```python
316 | class MyModelMetaIndex(AlgoliaIndex):
317 | def __init__(self, model, client, settings):
318 | self.indices = [
319 | MyModelIndex1(model, client, settings),
320 | MyModelIndex2(model, client, settings),
321 | ]
322 |
323 | def raw_search(self, query='', params=None):
324 | res = {}
325 | for index in self.indices:
326 | res[index.name] = index.raw_search(query, params)
327 | return res
328 |
329 | def update_records(self, qs, batch_size=1000, **kwargs):
330 | for index in self.indices:
331 | index.update_records(qs, batch_size, **kwargs)
332 |
333 | def reindex_all(self, batch_size=1000):
334 | for index in self.indices:
335 | index.reindex_all(batch_size)
336 |
337 | def set_settings(self):
338 | for index in self.indices:
339 | index.set_settings()
340 |
341 | def clear_objects(self):
342 | for index in self.indices:
343 | index.clear_objects()
344 |
345 | def save_record(self, instance, update_fields=None, **kwargs):
346 | for index in self.indices:
347 | index.save_record(instance, update_fields, **kwargs)
348 |
349 | def delete_record(self, instance):
350 | for index in self.indices:
351 | index.delete_record(instance)
352 | ```
353 |
354 | - Finally, register this `AlgoliaIndex` with your `Model`:
355 |
356 | ```python
357 | import algoliasearch_django as algoliasearch
358 | algoliasearch.register(MyModel, MyModelMetaIndex)
359 | ```
360 |
361 | ## Temporarily disable the auto-indexing
362 |
363 | It is possible to temporarily disable the auto-indexing feature using the `disable_auto_indexing` context decorator:
364 |
365 | ```python
366 | from algoliasearch_django.decorators import disable_auto_indexing
367 |
368 | # Used as a context manager
369 | with disable_auto_indexing():
370 | MyModel.save()
371 |
372 | # Used as a decorator
373 | @disable_auto_indexing():
374 | my_method()
375 |
376 | # You can also specifiy for which model you want to disable the auto-indexing
377 | with disable_auto_indexing(MyModel):
378 | MyModel.save()
379 | MyOtherModel.save()
380 |
381 | ```
382 |
383 | # Tests
384 |
385 | ## Run Tests
386 |
387 | To run the tests, first find your Algolia application id and Admin API key (found on the Credentials page).
388 |
389 | ```shell
390 | ALGOLIA_APPLICATION_ID={APPLICATION_ID} ALGOLIA_API_KEY={ADMIN_API_KEY} tox
391 | ```
392 |
393 | To override settings for some tests, use the [settings method](https://docs.djangoproject.com/en/1.11/topics/testing/tools/#django.test.SimpleTestCase.settings):
394 |
395 | ```python
396 | class OverrideSettingsTestCase(TestCase):
397 | def setUp(self):
398 | with self.settings(ALGOLIA={
399 | 'APPLICATION_ID': 'foo',
400 | 'API_KEY': 'bar',
401 | 'AUTO_INDEXING': False
402 | }):
403 | algolia_engine.reset(settings.ALGOLIA)
404 |
405 | def tearDown(self):
406 | algolia_engine.reset(settings.ALGOLIA)
407 |
408 | def test_foo():
409 | # ...
410 | ```
411 |
412 | # Troubleshooting
413 |
414 | # Use the Dockerfile
415 |
416 | If you want to contribute to this project without installing all its dependencies, you can use our Docker image. Please check our [dedicated guide](DOCKER_README.md) to learn more.
417 |
418 | ## Frequently asked questions
419 |
420 | Encountering an issue? Before reaching out to support, we recommend heading to our [FAQ](https://www.algolia.com/doc/framework-integration/django/faq/) where you will find answers for the most common issues and gotchas with the package.
421 |
--------------------------------------------------------------------------------
/algoliasearch_django/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | AlgoliaSearch integration for Django.
3 | http://www.algolia.com
4 | """
5 |
6 | from django.utils.module_loading import autodiscover_modules
7 |
8 | import logging
9 | from . import models
10 | from . import registration
11 | from . import settings
12 | from . import version
13 |
14 | __version__ = version.VERSION
15 | ALGOLIA_SETTINGS = settings.SETTINGS
16 |
17 | AlgoliaIndex = models.AlgoliaIndex
18 | AlgoliaEngine = registration.AlgoliaEngine
19 | algolia_engine = registration.algolia_engine
20 |
21 | # Algolia Engine functions
22 |
23 | register = algolia_engine.register
24 | unregister = algolia_engine.unregister
25 | get_registered_model = algolia_engine.get_registered_models
26 |
27 | get_adapter = algolia_engine.get_adapter
28 | get_adapter_from_instance = algolia_engine.get_adapter_from_instance
29 |
30 | save_record = algolia_engine.save_record
31 | delete_record = algolia_engine.delete_record
32 | update_records = algolia_engine.update_records
33 | raw_search = algolia_engine.raw_search
34 | clear_objects = algolia_engine.clear_objects
35 | reindex_all = algolia_engine.reindex_all
36 |
37 |
38 | class NullHandler(logging.Handler):
39 | def emit(self, record):
40 | pass
41 |
42 |
43 | def autodiscover():
44 | autodiscover_modules("index")
45 |
46 |
47 | logging.getLogger(__name__.split(".")[0]).addHandler(NullHandler())
48 |
49 | default_app_config = "algoliasearch_django.apps.AlgoliaConfig"
50 |
--------------------------------------------------------------------------------
/algoliasearch_django/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class AlgoliaConfig(AppConfig):
5 | """Simple AppConfig which does not do automatic discovery."""
6 |
7 | name = "algoliasearch_django"
8 |
9 | def ready(self):
10 | super(AlgoliaConfig, self).ready()
11 | self.module.autodiscover()
12 |
--------------------------------------------------------------------------------
/algoliasearch_django/decorators.py:
--------------------------------------------------------------------------------
1 | from contextlib import ContextDecorator
2 | from functools import WRAPPER_ASSIGNMENTS
3 |
4 | from django.db.models.signals import post_save, pre_delete
5 |
6 | from . import algolia_engine
7 |
8 |
9 | def available_attrs(fn):
10 | """
11 | Return the list of functools-wrappable attributes on a callable.
12 | This was required as a workaround for http://bugs.python.org/issue3445
13 | under Python 2.
14 | """
15 | return WRAPPER_ASSIGNMENTS
16 |
17 |
18 | def register(model):
19 | """
20 | Register the given model class and wrapped AlgoliaIndex class with the Algolia engine:
21 |
22 | @register(Author)
23 | class AuthorIndex(AlgoliaIndex):
24 | pass
25 |
26 | """
27 | from algoliasearch_django import AlgoliaIndex, register
28 |
29 | def _algolia_engine_wrapper(index_class):
30 | if not issubclass(index_class, AlgoliaIndex):
31 | raise ValueError("Wrapped class must subclass AlgoliaIndex.")
32 |
33 | register(model, index_class)
34 |
35 | return index_class
36 |
37 | return _algolia_engine_wrapper
38 |
39 |
40 | class disable_auto_indexing(ContextDecorator):
41 | """
42 | A context decorator to disable the auto-indexing behaviour of the AlgoliaIndex
43 |
44 | Can be used either as a context manager or a method decorator:
45 | >>> with disable_auto_indexing():
46 | >>> my_object.save()
47 |
48 | >>> @disable_auto_indexing()
49 | >>> big_operation()
50 | """
51 |
52 | def __init__(self, model=None):
53 | if model is not None:
54 | self.models = [model]
55 | else:
56 | self.models = algolia_engine._AlgoliaEngine__registered_models # pyright: ignore
57 |
58 | def __enter__(self):
59 | for model in self.models:
60 | post_save.disconnect(
61 | algolia_engine._AlgoliaEngine__post_save_receiver, # pyright: ignore
62 | sender=model,
63 | )
64 | pre_delete.disconnect(
65 | algolia_engine._AlgoliaEngine__pre_delete_receiver, # pyright: ignore
66 | sender=model,
67 | )
68 |
69 | def __exit__(self, exc_type, exc_value, traceback):
70 | for model in self.models:
71 | post_save.connect(
72 | algolia_engine._AlgoliaEngine__post_save_receiver, # pyright: ignore
73 | sender=model,
74 | )
75 | pre_delete.connect(
76 | algolia_engine._AlgoliaEngine__pre_delete_receiver, # pyright: ignore
77 | sender=model,
78 | )
79 |
--------------------------------------------------------------------------------
/algoliasearch_django/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algolia/algoliasearch-django/cbf5df0492ae4d5dab9843a938b51745f7b1a281/algoliasearch_django/management/__init__.py
--------------------------------------------------------------------------------
/algoliasearch_django/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algolia/algoliasearch-django/cbf5df0492ae4d5dab9843a938b51745f7b1a281/algoliasearch_django/management/commands/__init__.py
--------------------------------------------------------------------------------
/algoliasearch_django/management/commands/algolia_applysettings.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from algoliasearch_django import get_registered_model
4 | from algoliasearch_django import get_adapter
5 |
6 |
7 | class Command(BaseCommand):
8 | help = "Apply index settings."
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument("--model", nargs="+", type=str)
12 |
13 | def handle(self, *args, **options):
14 | """Run the management command."""
15 | self.stdout.write("Apply settings to index:")
16 | for model in get_registered_model():
17 | if options.get("model", None) and model.__name__ not in options["model"]:
18 | continue
19 |
20 | get_adapter(model).set_settings()
21 | self.stdout.write("\t* {}".format(model.__name__))
22 |
--------------------------------------------------------------------------------
/algoliasearch_django/management/commands/algolia_clearindex.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from algoliasearch_django import get_registered_model
4 | from algoliasearch_django import clear_objects
5 |
6 |
7 | class Command(BaseCommand):
8 | help = "Clear index."
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument("--model", nargs="+", type=str)
12 |
13 | def handle(self, *args, **options):
14 | """Run the management command."""
15 | self.stdout.write("Clear index:")
16 | for model in get_registered_model():
17 | if options.get("model", None) and model.__name__ not in options["model"]:
18 | continue
19 |
20 | clear_objects(model)
21 | self.stdout.write("\t* {}".format(model.__name__))
22 |
--------------------------------------------------------------------------------
/algoliasearch_django/management/commands/algolia_reindex.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from algoliasearch_django import get_registered_model
4 | from algoliasearch_django import reindex_all
5 |
6 |
7 | class Command(BaseCommand):
8 | help = "Reindex all models to Algolia"
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument("--batchsize", nargs="?", default=1000, type=int)
12 | parser.add_argument("--model", nargs="+", type=str)
13 |
14 | def handle(self, *args, **options):
15 | """Run the management command."""
16 | batch_size = options.get("batchsize", None)
17 | if not batch_size:
18 | # py34-django18: batchsize is set to None if the user don't set
19 | # the value, instead of not be present in the dict
20 | batch_size = 1000
21 |
22 | self.stdout.write("The following models were reindexed:")
23 | for model in get_registered_model():
24 | if options.get("model", None) and model.__name__ not in options["model"]:
25 | continue
26 |
27 | counts = reindex_all(model, batch_size=batch_size)
28 | self.stdout.write("\t* {} --> {}".format(model.__name__, counts))
29 |
--------------------------------------------------------------------------------
/algoliasearch_django/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import inspect
4 | from functools import partial
5 | from itertools import chain
6 | import logging
7 | from typing import Callable, Iterable, Optional
8 |
9 | from algoliasearch.http.exceptions import AlgoliaException
10 | from algoliasearch.search.models.operation_index_params import OperationIndexParams
11 | from algoliasearch.search.models.operation_type import OperationType
12 | from algoliasearch.search.models.search_params_object import SearchParamsObject
13 | from django.db.models.query_utils import DeferredAttribute
14 |
15 | from .settings import DEBUG
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | def _getattr(obj, name):
21 | return getattr(obj, name)
22 |
23 |
24 | def check_and_get_attr(model, name):
25 | try:
26 | attr = getattr(model, name)
27 | if callable(attr):
28 | return attr
29 | else:
30 | return get_model_attr(name)
31 | except AttributeError:
32 | raise AlgoliaIndexError("{} is not an attribute of {}".format(name, model))
33 |
34 |
35 | def get_model_attr(name):
36 | return partial(_getattr, name=name)
37 |
38 |
39 | def sanitize(hit):
40 | if "_highlightResult" in hit:
41 | hit.pop("_highlightResult")
42 | return hit
43 |
44 |
45 | class AlgoliaIndexError(Exception):
46 | """Something went wrong with an Algolia Index."""
47 |
48 |
49 | class AlgoliaIndex(object):
50 | """An index in the Algolia backend."""
51 |
52 | # Use to specify a custom field that will be used for the objectID.
53 | # This field should be unique.
54 | custom_objectID = "pk"
55 |
56 | # Use to specify the fields that should be included in the index.
57 | fields = ()
58 |
59 | # Use to specify the geo-fields that should be used for location search.
60 | # The attribute should be a callable that returns a tuple.
61 | geo_field = None
62 |
63 | # Use to specify the field that should be used for filtering by tag.
64 | tags = None
65 |
66 | # Use to specify the index to target on Algolia.
67 | index_name: Optional[str] = None
68 |
69 | # Use to specify the settings of the index.
70 | settings = None
71 |
72 | # Used to specify if the instance should be indexed.
73 | # The attribute should be either:
74 | # - a callable that returns a boolean.
75 | # - a BooleanField
76 | # - a boolean property or attribute
77 | should_index = None
78 |
79 | # Name of the attribute to check on instances if should_index is not a callable
80 | _should_index_is_method = False
81 |
82 | get_queryset: Optional[Callable[[], Iterable]] = None
83 |
84 | def __init__(self, model, client, settings):
85 | """Initializes the index."""
86 | if not self.index_name:
87 | self.index_name = model.__name__
88 |
89 | tmp_index_name = "{index_name}_tmp".format(index_name=self.index_name)
90 |
91 | if "INDEX_PREFIX" in settings:
92 | self.index_name = settings["INDEX_PREFIX"] + "_" + self.index_name
93 | tmp_index_name = "{index_prefix}_{tmp_index_name}".format(
94 | tmp_index_name=tmp_index_name, index_prefix=settings["INDEX_PREFIX"]
95 | )
96 | if "INDEX_SUFFIX" in settings:
97 | self.index_name += "_" + settings["INDEX_SUFFIX"]
98 | tmp_index_name = "{tmp_index_name}_{index_suffix}".format(
99 | tmp_index_name=tmp_index_name, index_suffix=settings["INDEX_SUFFIX"]
100 | )
101 |
102 | self.tmp_index_name = tmp_index_name
103 |
104 | self.model = model
105 | self.__client = client
106 | self.__named_fields = {}
107 | self.__translate_fields = {}
108 |
109 | if (
110 | self.settings is None
111 | ): # Only set settings if the actual index class does not define some
112 | self.settings = {}
113 |
114 | all_model_fields = [
115 | f.name for f in model._meta.get_fields() if not f.is_relation
116 | ]
117 |
118 | if isinstance(self.fields, str):
119 | self.fields = (self.fields,)
120 | elif isinstance(self.fields, (list, tuple, set)):
121 | self.fields = tuple(self.fields)
122 | else:
123 | raise AlgoliaIndexError("Fields must be a str, list, tuple or set")
124 |
125 | # Check fields
126 | for field in self.fields:
127 | if isinstance(field, str):
128 | attr = field
129 | name = field
130 | elif isinstance(field, (list, tuple)) and len(field) == 2:
131 | attr = field[0]
132 | name = field[1]
133 | else:
134 | raise AlgoliaIndexError(
135 | "Invalid fields syntax: {} (type: {})".format(field, type(field))
136 | )
137 |
138 | self.__translate_fields[attr] = name
139 | if attr in all_model_fields:
140 | self.__named_fields[name] = get_model_attr(attr)
141 | else:
142 | self.__named_fields[name] = check_and_get_attr(model, attr)
143 |
144 | # If no fields are specified, index all the fields of the model
145 | if not self.fields:
146 | self.fields = set(all_model_fields)
147 | for elt in ("pk", "id", "objectID"):
148 | try:
149 | self.fields.remove(elt)
150 | except KeyError:
151 | continue
152 | self.__translate_fields = dict(zip(self.fields, self.fields))
153 | self.__named_fields = dict(
154 | zip(self.fields, map(get_model_attr, self.fields))
155 | )
156 |
157 | # Check custom_objectID
158 | if self.custom_objectID in chain(["pk"], all_model_fields) or hasattr(
159 | model, self.custom_objectID
160 | ):
161 | self.objectID = get_model_attr(self.custom_objectID)
162 | else:
163 | raise AlgoliaIndexError(
164 | "{} is not a model field of {}".format(self.custom_objectID, model)
165 | )
166 |
167 | # Check tags
168 | if self.tags:
169 | if self.tags in all_model_fields:
170 | self.tags = get_model_attr(self.tags)
171 | else:
172 | self.tags = check_and_get_attr(model, self.tags)
173 |
174 | # Check geo_field
175 | if self.geo_field:
176 | self.geo_field = check_and_get_attr(model, self.geo_field)
177 |
178 | # Check should_index + get the callable or attribute/field name
179 | if self.should_index:
180 | if hasattr(model, self.should_index):
181 | attr = getattr(model, self.should_index)
182 | if (
183 | type(attr) is not bool
184 | ): # if attr is a bool, we keep attr=name to getattr on instance
185 | self.should_index = attr
186 | if callable(self.should_index):
187 | self._should_index_is_method = True
188 | else:
189 | try:
190 | model._meta.get_field_by_name(self.should_index)
191 | except Exception:
192 | raise AlgoliaIndexError(
193 | "{} is not an attribute nor a field of {}.".format(
194 | self.should_index, model
195 | )
196 | )
197 |
198 | @staticmethod
199 | def _validate_geolocation(geolocation):
200 | """
201 | Make sure we have the proper geolocation format.
202 | """
203 | if set(geolocation) != {"lat", "lng"}:
204 | raise AlgoliaIndexError(
205 | 'Invalid geolocation format, requires "lat" and "lng" keys only got {}'.format(
206 | geolocation
207 | )
208 | )
209 |
210 | def get_raw_record(self, instance, update_fields=None):
211 | """
212 | Gets the raw record.
213 |
214 | If `update_fields` is set, the raw record will be build with only
215 | the objectID and the given fields. Also, `_geoloc` and `_tags` will
216 | not be included.
217 | """
218 | tmp = {"objectID": self.objectID(instance)}
219 |
220 | if update_fields:
221 | if isinstance(update_fields, str):
222 | update_fields = (update_fields,)
223 |
224 | for elt in update_fields:
225 | key = self.__translate_fields.get(elt, None)
226 | if key:
227 | tmp[key] = self.__named_fields[key](instance)
228 | else:
229 | for key, value in self.__named_fields.items():
230 | tmp[key] = value(instance)
231 |
232 | if self.geo_field:
233 | loc = self.geo_field(instance)
234 |
235 | if isinstance(loc, tuple):
236 | tmp["_geoloc"] = {"lat": loc[0], "lng": loc[1]}
237 | elif isinstance(loc, dict):
238 | self._validate_geolocation(loc)
239 | tmp["_geoloc"] = loc
240 | elif isinstance(loc, list):
241 | [self._validate_geolocation(geo) for geo in loc]
242 | tmp["_geoloc"] = loc
243 |
244 | if self.tags:
245 | if callable(self.tags):
246 | tmp["_tags"] = self.tags(instance)
247 | if not isinstance(tmp["_tags"], list):
248 | tmp["_tags"] = list(tmp["_tags"]) # pyright: ignore
249 |
250 | logger.debug("BUILD %s FROM %s", tmp["objectID"], self.model)
251 | return tmp
252 |
253 | def _has_should_index(self):
254 | """Return True if this AlgoliaIndex has a should_index method or attribute"""
255 | return self.should_index is not None
256 |
257 | def _should_index(self, instance):
258 | """Return True if the object should be indexed (including when self.should_index is not set)."""
259 | if self._has_should_index():
260 | return self._should_really_index(instance)
261 | else:
262 | return True
263 |
264 | def _should_really_index(self, instance):
265 | """Return True if according to should_index the object should be indexed."""
266 | if self.should_index is None:
267 | raise AlgoliaIndexError("{} should be defined.".format(self.should_index))
268 |
269 | if self._should_index_is_method:
270 | is_method = inspect.ismethod(self.should_index)
271 | try:
272 | count_args = len(inspect.signature(self.should_index).parameters) # pyright: ignore -- should_index_is_method
273 | except AttributeError:
274 | # noinspection PyDeprecation
275 | count_args = len(inspect.getfullargspec(self.should_index).args)
276 |
277 | if is_method or count_args == 1:
278 | # bound method, call with instance
279 | return self.should_index(instance) # pyright: ignore -- should_index_is_method
280 | else:
281 | # unbound method, simply call without arguments
282 | return self.should_index() # pyright: ignore -- should_index_is_method
283 | else:
284 | # property/attribute/Field, evaluate as bool
285 | attr_type = type(self.should_index)
286 | if attr_type is DeferredAttribute:
287 | attr_value = self.should_index.__get__(instance, None)
288 | elif attr_type is str:
289 | attr_value = getattr(instance, self.should_index)
290 | elif attr_type is property:
291 | attr_value = self.should_index.__get__(instance)
292 | else:
293 | raise AlgoliaIndexError(
294 | "{} should be a boolean attribute or a method that returns a boolean.".format(
295 | self.should_index
296 | )
297 | )
298 | if type(attr_value) is not bool:
299 | raise AlgoliaIndexError(
300 | "%s's should_index (%s) should be a boolean"
301 | % (instance.__class__.__name__, self.should_index)
302 | )
303 | return attr_value
304 |
305 | def save_record(self, instance, update_fields=None, **kwargs):
306 | """Saves the record.
307 |
308 | If `update_fields` is set, this method will use partial_update_object()
309 | and will update only the given fields (never `_geoloc` and `_tags`).
310 |
311 | For more information about partial_update_object:
312 | https://github.com/algolia/algoliasearch-client-python#update-an-existing-object-in-the-index
313 | """
314 | if not self._should_index(instance):
315 | # Should not index, but since we don't now the state of the
316 | # instance, we need to send a DELETE request to ensure that if
317 | # the instance was previously indexed, it will be removed.
318 | self.delete_record(instance)
319 | return
320 |
321 | obj = {}
322 | try:
323 | if update_fields:
324 | obj = self.get_raw_record(instance, update_fields=update_fields)
325 | self.__client.partial_update_objects(
326 | index_name=self.index_name, objects=[obj], wait_for_tasks=True
327 | )
328 | else:
329 | obj = self.get_raw_record(instance)
330 | self.__client.save_objects(
331 | index_name=self.index_name, objects=[obj], wait_for_tasks=True
332 | )
333 | logger.info("SAVE %s FROM %s", obj["objectID"], self.model)
334 | except AlgoliaException as e:
335 | if DEBUG:
336 | raise e
337 | else:
338 | logger.warning(
339 | "%s FROM %s NOT SAVED: %s", obj["objectID"], self.model, e
340 | )
341 |
342 | def delete_record(self, instance):
343 | """Deletes the record."""
344 | objectID = self.objectID(instance)
345 | try:
346 | self.__client.delete_objects(
347 | index_name=self.index_name, object_ids=[objectID], wait_for_tasks=True
348 | )
349 | logger.info("DELETE %s FROM %s", objectID, self.model)
350 | except AlgoliaException as e:
351 | if DEBUG:
352 | raise e
353 | else:
354 | logger.warning("%s FROM %s NOT DELETED: %s", objectID, self.model, e)
355 |
356 | def update_records(self, qs, batch_size=1000, **kwargs):
357 | """
358 | Updates multiple records.
359 |
360 | This method is optimized for speed. It takes a QuerySet and the same
361 | arguments as QuerySet.update(). Optionnaly, you can specify the size
362 | of the batch send to Algolia with batch_size (default to 1000).
363 |
364 | >>> from algoliasearch_django import update_records
365 | >>> qs = MyModel.objects.filter(myField=False)
366 | >>> update_records(MyModel, qs, myField=True)
367 | >>> qs.update(myField=True)
368 | """
369 | tmp = {}
370 | for key, value in kwargs.items():
371 | name = self.__translate_fields.get(key, None)
372 | if name:
373 | tmp[name] = value
374 |
375 | batch = []
376 | objectsIDs = qs.only(self.custom_objectID).values_list(
377 | self.custom_objectID, flat=True
378 | )
379 | for elt in objectsIDs:
380 | tmp["objectID"] = elt
381 | batch.append(dict(tmp))
382 |
383 | if len(batch) > 0:
384 | self.__client.partial_update_objects(
385 | index_name=self.index_name, objects=batch, wait_for_tasks=True, batch_size=batch_size,
386 | )
387 |
388 | def raw_search(self, query="", params=None):
389 | """Performs a search query and returns the parsed JSON."""
390 | if params is None:
391 | params = SearchParamsObject().to_dict()
392 |
393 | params["query"] = query
394 |
395 | try:
396 | return self.__client.search_single_index(self.index_name, params).to_dict()
397 | except AlgoliaException as e:
398 | if DEBUG:
399 | raise e
400 | else:
401 | logger.warning("ERROR DURING SEARCH ON %s: %s", self.index_name, e)
402 |
403 | def get_settings(self) -> Optional[dict]:
404 | """Returns the settings of the index."""
405 | try:
406 | logger.info("GET SETTINGS ON %s", self.index_name)
407 | return self.__client.get_settings(self.index_name).to_dict()
408 | except AlgoliaException as e:
409 | if DEBUG:
410 | raise e
411 | else:
412 | logger.warning("ERROR DURING GET_SETTINGS ON %s: %s", self.model, e)
413 |
414 | def set_settings(self):
415 | """Applies the settings to the index."""
416 | if not self.settings:
417 | return
418 |
419 | try:
420 | _resp = self.__client.set_settings(self.index_name, self.settings)
421 | self.__client.wait_for_task(self.index_name, _resp.task_id)
422 | logger.info("APPLY SETTINGS ON %s", self.index_name)
423 | except AlgoliaException as e:
424 | if DEBUG:
425 | raise e
426 | else:
427 | logger.warning("SETTINGS NOT APPLIED ON %s: %s", self.model, e)
428 |
429 | def clear_objects(self):
430 | """Clears all objects of an index."""
431 | try:
432 | _resp = self.__client.clear_objects(self.index_name)
433 | self.__client.wait_for_task(self.index_name, _resp.task_id)
434 | logger.info("CLEAR INDEX %s", self.index_name)
435 | except AlgoliaException as e:
436 | if DEBUG:
437 | raise e
438 | else:
439 | logger.warning("%s NOT CLEARED: %s", self.model, e)
440 |
441 | def wait_task(self, task_id):
442 | try:
443 | self.__client.wait_for_task(self.index_name, task_id)
444 | logger.info("WAIT TASK %s", self.index_name)
445 | except AlgoliaException as e:
446 | if DEBUG:
447 | raise e
448 | else:
449 | logger.warning("%s NOT WAIT: %s", self.model, e)
450 |
451 | def delete(self):
452 | _resp = self.__client.delete_index(self.index_name)
453 | self.__client.wait_for_task(self.index_name, _resp.task_id)
454 | if self.tmp_index_name:
455 | _resp = self.__client.delete_index(self.tmp_index_name)
456 | self.__client.wait_for_task(self.tmp_index_name, _resp.task_id)
457 |
458 | def reindex_all(self, batch_size=1000):
459 | """
460 | Reindex all the records.
461 |
462 | By default, this method use Model.objects.all() but you can implement
463 | a method `get_queryset` in your subclass. This can be used to optimize
464 | the performance (for example with select_related or prefetch_related).
465 | """
466 | should_keep_synonyms = False
467 | should_keep_rules = False
468 | try:
469 | if not self.settings:
470 | self.settings = self.get_settings()
471 | logger.debug(
472 | "Got settings for index %s: %s", self.index_name, self.settings
473 | )
474 | else:
475 | logger.debug(
476 | "index %s already has settings: %s", self.index_name, self.settings
477 | )
478 | except AlgoliaException as e:
479 | if any("Index does not exist" in arg for arg in e.args):
480 | pass # Expected, let's clear and recreate from scratch
481 | else:
482 | raise e # Unexpected error while getting settings
483 | try:
484 | should_keep_replicas = False
485 | replicas = None
486 |
487 | if self.settings:
488 | replicas = self.settings.get("replicas", None)
489 |
490 | should_keep_replicas = replicas is not None
491 |
492 | if should_keep_replicas:
493 | self.settings["replicas"] = []
494 | logger.debug("REMOVE REPLICAS FROM SETTINGS")
495 |
496 | _resp = self.__client.set_settings(self.tmp_index_name, self.settings)
497 | self.__client.wait_for_task(self.tmp_index_name, _resp.task_id)
498 | logger.debug("APPLY SETTINGS ON %s_tmp", self.index_name)
499 |
500 | rules = []
501 | self.__client.browse_rules(
502 | self.index_name,
503 | lambda _resp: rules.extend([sanitize(_hit.to_dict()) for _hit in _resp.hits]),
504 | )
505 | if len(rules):
506 | logger.debug("Got rules for index %s: %s", self.index_name, rules)
507 | should_keep_rules = True
508 |
509 | synonyms = []
510 | self.__client.browse_synonyms(
511 | self.index_name,
512 | lambda _resp: synonyms.extend([sanitize(_hit.to_dict()) for _hit in _resp.hits]),
513 | )
514 | if len(synonyms):
515 | logger.debug("Got synonyms for index %s: %s", self.index_name, rules)
516 | should_keep_synonyms = True
517 |
518 | _resp = self.__client.clear_objects(self.tmp_index_name)
519 | self.__client.wait_for_task(self.tmp_index_name, _resp.task_id)
520 | logger.debug("CLEAR INDEX %s", self.tmp_index_name)
521 |
522 | counts = 0
523 | batch = []
524 | qs = []
525 |
526 | if hasattr(self, "get_queryset") and callable(self.get_queryset):
527 | qs = self.get_queryset()
528 | else:
529 | qs = self.model.objects.all()
530 |
531 | for instance in qs:
532 | if not self._should_index(instance):
533 | continue # should not index
534 |
535 | batch.append(self.get_raw_record(instance))
536 | if len(batch) >= batch_size:
537 | self.__client.save_objects(
538 | index_name=self.tmp_index_name,
539 | objects=batch,
540 | wait_for_tasks=True,
541 | )
542 | logger.info(
543 | "SAVE %d OBJECTS TO %s", len(batch), self.tmp_index_name
544 | )
545 | batch = []
546 | counts += 1
547 | if len(batch) > 0:
548 | self.__client.save_objects(
549 | index_name=self.tmp_index_name, objects=batch, wait_for_tasks=True
550 | )
551 | logger.info("SAVE %d OBJECTS TO %s", len(batch), self.tmp_index_name)
552 |
553 | _resp = self.__client.operation_index(
554 | self.tmp_index_name,
555 | OperationIndexParams(
556 | operation=OperationType.MOVE,
557 | destination=self.index_name, # pyright: ignore
558 | ),
559 | )
560 | self.__client.wait_for_task(self.tmp_index_name, _resp.task_id)
561 | logger.info("MOVE INDEX %s TO %s", self.tmp_index_name, self.index_name)
562 |
563 | if self.settings:
564 | if should_keep_replicas:
565 | self.settings["replicas"] = replicas
566 | logger.debug("RESTORE REPLICAS")
567 | if should_keep_replicas:
568 | _resp = self.__client.set_settings(self.index_name, self.settings)
569 | self.__client.wait_for_task(self.index_name, _resp.task_id)
570 | if should_keep_rules:
571 | _resp = self.__client.save_rules(self.index_name, rules, True)
572 | self.__client.wait_for_task(self.index_name, _resp.task_id)
573 | logger.info(
574 | "Saved rules for index %s with response: {}".format(_resp),
575 | self.index_name,
576 | )
577 | if should_keep_synonyms:
578 | _resp = self.__client.save_synonyms(self.index_name, synonyms, True)
579 | self.__client.wait_for_task(self.index_name, _resp.task_id)
580 | logger.info(
581 | "Saved synonyms for index %s with response: {}".format(_resp),
582 | self.index_name,
583 | )
584 | return counts
585 | except AlgoliaException as e:
586 | if DEBUG:
587 | raise e
588 | else:
589 | logger.warning("ERROR DURING REINDEXING %s: %s", self.model, e)
590 |
--------------------------------------------------------------------------------
/algoliasearch_django/registration.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 | import logging
3 |
4 | from django import __version__ as __django__version__
5 | from django.db.models.signals import post_save
6 | from django.db.models.signals import pre_delete
7 | from algoliasearch_django.version import VERSION as __version__
8 | from algoliasearch.search.client import SearchClientSync
9 |
10 | from .models import AlgoliaIndex
11 | from .settings import SETTINGS
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class AlgoliaEngineError(Exception):
17 | """Something went wrong with Algolia Engine."""
18 |
19 |
20 | class RegistrationError(AlgoliaEngineError):
21 | """Something went wrong when registering a model."""
22 |
23 |
24 | class AlgoliaEngine(object):
25 | def __init__(self, settings=SETTINGS):
26 | """Initializes the Algolia engine."""
27 |
28 | try:
29 | app_id = settings["APPLICATION_ID"]
30 | api_key = settings["API_KEY"]
31 | except KeyError:
32 | raise AlgoliaEngineError("APPLICATION_ID and API_KEY must be defined.")
33 |
34 | self.__auto_indexing = settings.get("AUTO_INDEXING", True)
35 | self.__settings = settings
36 |
37 | self.__registered_models = {}
38 | self.client = SearchClientSync(app_id, api_key)
39 | self.client.add_user_agent("Algolia for Django", __version__)
40 | self.client.add_user_agent("Django", __django__version__)
41 |
42 | def is_registered(self, model):
43 | """Checks whether the given models is registered with Algolia engine"""
44 | return model in self.__registered_models
45 |
46 | def register(self, model, index_cls=AlgoliaIndex, auto_indexing=None):
47 | """
48 | Registers the given model with Algolia engine.
49 |
50 | If the given model is already registered with Algolia engine, a
51 | RegistrationError will be raised.
52 | """
53 | # Check for existing registration.
54 | if self.is_registered(model):
55 | raise RegistrationError(
56 | "{} is already registered with Algolia engine".format(model)
57 | )
58 |
59 | # Perform the registration.
60 | if not issubclass(index_cls, AlgoliaIndex):
61 | raise RegistrationError(
62 | "{} should be a subclass of AlgoliaIndex".format(index_cls)
63 | )
64 | index_obj = index_cls(model, self.client, self.__settings)
65 | self.__registered_models[model] = index_obj
66 |
67 | if (isinstance(auto_indexing, bool) and auto_indexing) or self.__auto_indexing:
68 | # Connect to the signalling framework.
69 | post_save.connect(self.__post_save_receiver, model)
70 | pre_delete.connect(self.__pre_delete_receiver, model)
71 | logger.info("REGISTER %s", model)
72 |
73 | def unregister(self, model):
74 | """
75 | Unregisters the given model with Algolia engine.
76 |
77 | If the given model is not registered with Algolia engine, a
78 | RegistrationError will be raised.
79 | """
80 | if not self.is_registered(model):
81 | raise RegistrationError(
82 | "{} is not registered with Algolia engine".format(model)
83 | )
84 | # Perform the unregistration.
85 | del self.__registered_models[model]
86 |
87 | # Disconnect from the signalling framework.
88 | post_save.disconnect(self.__post_save_receiver, model)
89 | pre_delete.disconnect(self.__pre_delete_receiver, model)
90 | logger.info("UNREGISTER %s", model)
91 |
92 | def get_registered_models(self):
93 | """
94 | Returns a list of models that have been registered with Algolia
95 | engine.
96 | """
97 | return list(self.__registered_models.keys())
98 |
99 | def get_adapter(self, model):
100 | """Returns the adapter associated with the given model."""
101 | if not self.is_registered(model):
102 | raise RegistrationError(
103 | "{} is not registered with Algolia engine".format(model)
104 | )
105 |
106 | return self.__registered_models[model]
107 |
108 | def get_adapter_from_instance(self, instance):
109 | """Returns the adapter associated with the given instance."""
110 | model = instance.__class__
111 | return self.get_adapter(model)
112 |
113 | # Proxies methods.
114 |
115 | def save_record(self, instance, **kwargs):
116 | """Saves the record.
117 |
118 | If `update_fields` is set, this method will use partial_update_object()
119 | and will update only the given fields (never `_geoloc` and `_tags`).
120 |
121 | For more information about partial_update_object:
122 | https://github.com/algolia/algoliasearch-client-python#update-an-existing-object-in-the-index
123 | """
124 | adapter = self.get_adapter_from_instance(instance)
125 | adapter.save_record(instance, **kwargs)
126 |
127 | def delete_record(self, instance):
128 | """Deletes the record."""
129 | adapter = self.get_adapter_from_instance(instance)
130 | adapter.delete_record(instance)
131 |
132 | def update_records(self, model, qs, batch_size=1000, **kwargs):
133 | """
134 | Updates multiple records.
135 |
136 | This method is optimized for speed. It takes a QuerySet and the same
137 | arguments as QuerySet.update(). Optionally, you can specify the size
138 | of the batch send to Algolia with batch_size (default to 1000).
139 |
140 | >>> from algoliasearch_django import update_records
141 | >>> qs = MyModel.objects.filter(myField=False)
142 | >>> update_records(MyModel, qs, myField=True)
143 | >>> qs.update(myField=True)
144 | """
145 | adapter = self.get_adapter(model)
146 | adapter.update_records(qs, batch_size=batch_size, **kwargs)
147 |
148 | def raw_search(self, model, query="", params=None):
149 | """Performs a search query and returns the parsed JSON."""
150 | if params is None:
151 | params = {}
152 |
153 | adapter = self.get_adapter(model)
154 | return adapter.raw_search(query, params)
155 |
156 | def clear_objects(self, model):
157 | """Clears the index."""
158 | adapter = self.get_adapter(model)
159 | adapter.clear_objects()
160 |
161 | def reindex_all(self, model, batch_size=1000):
162 | """
163 | Reindex all the records.
164 |
165 | By default, this method use Model.objects.all() but you can implement
166 | a method `get_queryset` in your subclass. This can be used to optimize
167 | the performance (for example with select_related or prefetch_related).
168 | """
169 | adapter = self.get_adapter(model)
170 | return adapter.reindex_all(batch_size)
171 |
172 | def reset(self, settings=None):
173 | """Reinitializes the Algolia engine and its client.
174 | :param settings: settings to use instead of the default django.conf.settings.algolia
175 | """
176 | self.__init__(settings=settings if settings is not None else SETTINGS)
177 |
178 | # Signalling hooks.
179 |
180 | def __post_save_receiver(self, instance, **kwargs):
181 | """Signal handler for when a registered model has been saved."""
182 | logger.debug("RECEIVE post_save FOR %s", instance.__class__)
183 | self.save_record(instance, **kwargs)
184 |
185 | def __pre_delete_receiver(self, instance, **kwargs):
186 | """Signal handler for when a registered model has been deleted."""
187 | logger.debug("RECEIVE pre_delete FOR %s", instance.__class__)
188 | self.delete_record(instance)
189 |
190 |
191 | # Algolia engine
192 | algolia_engine = AlgoliaEngine()
193 |
--------------------------------------------------------------------------------
/algoliasearch_django/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | SETTINGS = settings.ALGOLIA
4 | DEBUG = SETTINGS.get("RAISE_EXCEPTIONS", settings.DEBUG)
5 |
--------------------------------------------------------------------------------
/algoliasearch_django/version.py:
--------------------------------------------------------------------------------
1 | VERSION = "4.0.0"
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django>=4.0
2 | algoliasearch>=4.0,<5.0
3 | # dev dependencies
4 | factory_boy>=3.0,<4.0
5 | mock>=5.0,<6.0
6 | pypandoc>=1.0,<2.0
7 | pyright>=1.1.389,<2.0
8 | ruff>=0.7.4,<1.0
9 | setuptools>=75.0,<76.0
10 | six>=1.16,<2.0
11 | tox
12 | twine
13 | wheel
14 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 |
6 | import django
7 | from django.conf import settings
8 | from django.test.utils import get_runner
9 |
10 |
11 | def main():
12 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
13 | django.setup()
14 | TestRunner = get_runner(settings)
15 | test_runner = TestRunner(failfast=True)
16 | # kept here to run a single test
17 | # failures = test_runner.run_tests(
18 | # [
19 | # "tests.test_index.IndexTestCase"
20 | # ]
21 | # )
22 | failures = test_runner.run_tests(["tests"])
23 | sys.exit(bool(failures))
24 |
25 |
26 | if __name__ == "__main__":
27 | main()
28 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal=1
3 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 |
6 | from setuptools import setup
7 | from setuptools import find_packages
8 |
9 |
10 | # Allow setup.py to be run from any path
11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
12 |
13 | path_readme = os.path.join(os.path.dirname(__file__), "README.md")
14 | try:
15 | import pypandoc
16 |
17 | README = pypandoc.convert_file(path_readme, "rst")
18 | except (IOError, ImportError):
19 | with open(path_readme) as readme:
20 | README = readme.read()
21 |
22 | path_version = os.path.join(
23 | os.path.dirname(__file__), "algoliasearch_django/version.py"
24 | )
25 | if sys.version_info < (3, 8):
26 | raise RuntimeError("algoliasearch_django 4.x requires Python 3.8+")
27 | else:
28 | exec(open(path_version).read())
29 |
30 |
31 | setup(
32 | name="algoliasearch-django",
33 | version="4.0.0",
34 | license="MIT License",
35 | packages=find_packages(exclude=["tests"]),
36 | install_requires=["django>=4.0"],
37 | description="Algolia Search integration for Django",
38 | long_description=README,
39 | long_description_content_type="text/markdown",
40 | author="Algolia Team",
41 | author_email="support@algolia.com",
42 | url="https://github.com/algolia/algoliasearch-django",
43 | keywords=[
44 | "algolia",
45 | "pyalgolia",
46 | "search",
47 | "backend",
48 | "hosted",
49 | "cloud",
50 | "full-text search",
51 | "faceted search",
52 | "django",
53 | ],
54 | classifiers=[
55 | "Environment :: Web Environment",
56 | "Framework :: Django",
57 | "Intended Audience :: Developers",
58 | "License :: OSI Approved :: MIT License",
59 | "Operating System :: OS Independent",
60 | "Programming Language :: Python",
61 | "Programming Language :: Python :: 3",
62 | "Programming Language :: Python :: 3.8",
63 | "Programming Language :: Python :: 3.9",
64 | "Programming Language :: Python :: 3.10",
65 | "Programming Language :: Python :: 3.11",
66 | "Programming Language :: Python :: 3.12",
67 | "Programming Language :: Python :: 3.13",
68 | "Topic :: Internet :: WWW/HTTP",
69 | ],
70 | )
71 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algolia/algoliasearch-django/cbf5df0492ae4d5dab9843a938b51745f7b1a281/tests/__init__.py
--------------------------------------------------------------------------------
/tests/factories.py:
--------------------------------------------------------------------------------
1 | import factory
2 |
3 | from .models import Example, User, Website
4 |
5 |
6 | class ExampleFactory(factory.django.DjangoModelFactory):
7 | uid = factory.Sequence(lambda n: n)
8 | name = factory.Sequence(lambda n: "Example name-{}".format(n))
9 | address = factory.Sequence(lambda n: "Example address-{}".format(n))
10 | lat = factory.Faker("latitude")
11 | lng = factory.Faker("longitude")
12 |
13 | class Meta:
14 | model = Example
15 |
16 |
17 | class UserFactory(factory.django.DjangoModelFactory):
18 | name = factory.Sequence(lambda n: "User name-{}".format(n))
19 | username = factory.Sequence(lambda n: "User username-{}".format(n))
20 | following_count = 0
21 | followers_count = 0
22 |
23 | _lat = factory.Faker("latitude")
24 | _lng = factory.Faker("longitude")
25 |
26 | class Meta:
27 | model = User
28 |
29 |
30 | class WebsiteFactory(factory.django.DjangoModelFactory):
31 | name = factory.Sequence(lambda n: "Website name-{}".format(n))
32 | url = factory.Faker("url")
33 | is_online = False
34 |
35 | class Meta:
36 | model = Website
37 |
--------------------------------------------------------------------------------
/tests/index.py:
--------------------------------------------------------------------------------
1 | from algoliasearch_django import register
2 | from algoliasearch_django.models import AlgoliaIndex
3 | from algoliasearch_django.decorators import register as register_decorator
4 |
5 | from .models import User, Website
6 |
7 |
8 | @register_decorator(User)
9 | class UserIndex(AlgoliaIndex):
10 | pass
11 |
12 |
13 | class WebsiteIndex(AlgoliaIndex):
14 | pass
15 |
16 |
17 | register(Website, WebsiteIndex)
18 |
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class User(models.Model):
5 | name = models.CharField(max_length=30)
6 | username = models.CharField(max_length=30, unique=True)
7 | bio = models.CharField(max_length=140, blank=True)
8 | followers_count = models.BigIntegerField(0)
9 | following_count = models.BigIntegerField(0)
10 | _lat = models.FloatField(0)
11 | _lng = models.FloatField(0)
12 | _permissions = models.CharField(max_length=30, blank=True)
13 |
14 | @property
15 | def reverse_username(self):
16 | return self.username[::-1]
17 |
18 | def location(self):
19 | return self._lat, self._lng
20 |
21 | def permissions(self):
22 | return self._permissions.split(",")
23 |
24 |
25 | class Website(models.Model):
26 | name = models.CharField(max_length=100)
27 | url = models.URLField()
28 | is_online = models.BooleanField(False)
29 |
30 |
31 | class Example(models.Model):
32 | uid = models.IntegerField()
33 | name = models.CharField(max_length=20)
34 | address = models.CharField(max_length=200)
35 | lat = models.FloatField()
36 | lng = models.FloatField()
37 | is_admin = models.BooleanField(False)
38 | category = []
39 | locations = []
40 | index_me = True
41 |
42 | def location(self):
43 | return self.lat, self.lng
44 |
45 | def geolocations(self):
46 | return self.locations
47 |
48 | def has_name(self):
49 | return self.name is not None
50 |
51 | @staticmethod
52 | def static_should_index():
53 | return True
54 |
55 | @staticmethod
56 | def static_should_not_index():
57 | return False
58 |
59 | @property
60 | def property_should_index(self):
61 | return True
62 |
63 | @property
64 | def property_should_not_index(self):
65 | return False
66 |
67 | @property
68 | def property_string(self):
69 | return "foo"
70 |
71 |
72 | class BlogPost(models.Model):
73 | author = models.ForeignKey(User, on_delete=models.CASCADE)
74 | text = models.TextField(default="")
75 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for core project.
3 |
4 | Generated by 'django-admin startproject' using Django 5.1.3.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.1/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/5.1/ref/settings/
11 | """
12 |
13 | import os
14 | import time
15 | from pathlib import Path
16 |
17 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
18 | BASE_DIR = Path(__file__).resolve().parent.parent
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = "MillisecondsMatter"
25 |
26 | # SECURITY WARNING: don't run with debug turned on in production!
27 | DEBUG = True
28 |
29 | ALLOWED_HOSTS = []
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "algoliasearch_django",
41 | "tests",
42 | ]
43 |
44 | MIDDLEWARE = [
45 | "django.contrib.auth.middleware.AuthenticationMiddleware",
46 | "django.contrib.auth.middleware.SessionAuthenticationMiddleware",
47 | "django.contrib.messages.middleware.MessageMiddleware",
48 | "django.contrib.sessions.middleware.SessionMiddleware",
49 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
50 | "django.middleware.common.CommonMiddleware",
51 | "django.middleware.csrf.CsrfViewMiddleware",
52 | "django.middleware.security.SecurityMiddleware",
53 | ]
54 |
55 | ROOT_URLCONF = "tests.urls"
56 |
57 | TEMPLATES = [
58 | {
59 | "BACKEND": "django.template.backends.django.DjangoTemplates",
60 | "DIRS": [],
61 | "APP_DIRS": True,
62 | "OPTIONS": {
63 | "context_processors": [
64 | "django.template.context_processors.debug",
65 | "django.template.context_processors.request",
66 | "django.contrib.auth.context_processors.auth",
67 | "django.contrib.messages.context_processors.messages",
68 | ],
69 | },
70 | },
71 | ]
72 |
73 | # Database
74 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases
75 |
76 | DATABASES = {
77 | "default": {
78 | "ENGINE": "django.db.backends.sqlite3",
79 | "NAME": BASE_DIR / "db.sqlite3",
80 | }
81 | }
82 |
83 | # Internationalization
84 | # https://docs.djangoproject.com/en/5.1/topics/i18n/
85 |
86 | LANGUAGE_CODE = "en-us"
87 |
88 | TIME_ZONE = "UTC"
89 |
90 | USE_I18N = True
91 |
92 | USE_L10N = True
93 |
94 | USE_TZ = True
95 |
96 | # Default primary key field type
97 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
98 |
99 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
100 |
101 |
102 | def safe_index_name(name):
103 | return "{}_ci-{}".format(name, time.time())
104 |
105 |
106 | # AlgoliaSearch settings
107 | ALGOLIA = {
108 | "APPLICATION_ID": os.getenv("ALGOLIA_APPLICATION_ID"),
109 | "API_KEY": os.getenv("ALGOLIA_API_KEY"),
110 | "INDEX_PREFIX": "test",
111 | "INDEX_SUFFIX": safe_index_name("django"),
112 | "RAISE_EXCEPTIONS": True,
113 | }
114 |
--------------------------------------------------------------------------------
/tests/test_commands.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from six import StringIO
3 | from django.core.management import call_command
4 |
5 | from algoliasearch_django import algolia_engine
6 | from algoliasearch_django import get_adapter
7 | from algoliasearch_django import clear_objects
8 |
9 | from .models import Website
10 | from .models import User
11 |
12 |
13 | class CommandsTestCase(TestCase):
14 | @classmethod
15 | def tearDownClass(cls):
16 | user_index_name = get_adapter(User).index_name
17 | website_index_name = get_adapter(Website).index_name
18 |
19 | algolia_engine.client.delete_index(user_index_name)
20 | algolia_engine.client.delete_index(website_index_name)
21 |
22 | def setUp(self):
23 | # Create some records
24 | u = User(
25 | name="James Bond",
26 | username="jb",
27 | followers_count=0,
28 | following_count=0,
29 | _lat=0,
30 | _lng=0,
31 | )
32 | u.save()
33 | u = User(
34 | name="Captain America",
35 | username="captain",
36 | followers_count=0,
37 | following_count=0,
38 | _lat=0,
39 | _lng=0,
40 | )
41 | u.save()
42 | u = User(
43 | name="John Snow",
44 | username="john_snow",
45 | _lat=120.2,
46 | _lng=42.1,
47 | followers_count=0,
48 | following_count=0,
49 | )
50 | u.save()
51 | u = User(
52 | name="Steve Jobs",
53 | username="genius",
54 | followers_count=331213,
55 | following_count=0,
56 | _lat=0,
57 | _lng=0,
58 | )
59 | u.save()
60 |
61 | self.out = StringIO()
62 |
63 | def tearDown(self):
64 | clear_objects(Website)
65 | clear_objects(User)
66 |
67 | def test_reindex(self):
68 | call_command("algolia_reindex", stdout=self.out)
69 | result = self.out.getvalue()
70 |
71 | regex = r"Website --> 0"
72 | try:
73 | self.assertRegex(result, regex)
74 | except AttributeError:
75 | self.assertRegexpMatches(result, regex)
76 |
77 | regex = r"User --> 4"
78 | try:
79 | self.assertRegex(result, regex)
80 | except AttributeError:
81 | self.assertRegexpMatches(result, regex)
82 |
83 | def test_reindex_with_args(self):
84 | call_command("algolia_reindex", stdout=self.out, model=["Website"])
85 | result = self.out.getvalue()
86 |
87 | regex = r"Website --> \d+"
88 | try:
89 | self.assertRegex(result, regex)
90 | except AttributeError:
91 | self.assertRegexpMatches(result, regex)
92 |
93 | regex = r"User --> \d+"
94 | try:
95 | self.assertNotRegex(result, regex)
96 | except AttributeError:
97 | self.assertNotRegexpMatches(result, regex)
98 |
99 | def test_clearindex(self):
100 | call_command("algolia_clearindex", stdout=self.out)
101 | result = self.out.getvalue()
102 |
103 | regex = r"Website"
104 | try:
105 | self.assertRegex(result, regex)
106 | except AttributeError:
107 | self.assertRegexpMatches(result, regex)
108 |
109 | regex = r"User"
110 | try:
111 | self.assertRegex(result, regex)
112 | except AttributeError:
113 | self.assertRegexpMatches(result, regex)
114 |
115 | def test_clearindex_with_args(self):
116 | call_command("algolia_clearindex", stdout=self.out, model=["Website"])
117 | result = self.out.getvalue()
118 |
119 | regex = r"Website"
120 | try:
121 | self.assertRegex(result, regex)
122 | except AttributeError:
123 | self.assertRegexpMatches(result, regex)
124 |
125 | regex = r"User"
126 | try:
127 | self.assertNotRegex(result, regex)
128 | except AttributeError:
129 | self.assertNotRegexpMatches(result, regex)
130 |
131 | def test_applysettings(self):
132 | call_command("algolia_applysettings", stdout=self.out)
133 | result = self.out.getvalue()
134 |
135 | regex = r"Website"
136 | try:
137 | self.assertRegex(result, regex)
138 | except AttributeError:
139 | self.assertRegexpMatches(result, regex)
140 |
141 | regex = r"User"
142 | try:
143 | self.assertRegex(result, regex)
144 | except AttributeError:
145 | self.assertRegexpMatches(result, regex)
146 |
147 | def test_applysettings_with_args(self):
148 | call_command("algolia_applysettings", stdout=self.out, model=["Website"])
149 | result = self.out.getvalue()
150 |
151 | regex = r"Website"
152 | try:
153 | self.assertRegex(result, regex)
154 | except AttributeError:
155 | self.assertRegexpMatches(result, regex)
156 |
157 | regex = r"User"
158 | try:
159 | self.assertNotRegex(result, regex)
160 | except AttributeError:
161 | self.assertNotRegexpMatches(result, regex)
162 |
--------------------------------------------------------------------------------
/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from mock import ANY, call, patch
2 |
3 | from django.test import TestCase
4 |
5 | from algoliasearch_django import algolia_engine
6 | from algoliasearch_django.decorators import disable_auto_indexing
7 |
8 | from .factories import UserFactory, WebsiteFactory
9 | from .models import User
10 |
11 |
12 | class DecoratorsTestCase(TestCase):
13 | def test_disable_auto_indexing_as_decorator_for_all(self):
14 | """Test that the `disable_auto_indexing` should work as a decorator for all the model"""
15 |
16 | @disable_auto_indexing()
17 | def decorated_operation():
18 | WebsiteFactory()
19 | UserFactory()
20 |
21 | def non_decorated_operation():
22 | WebsiteFactory()
23 | UserFactory()
24 |
25 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
26 | decorated_operation()
27 |
28 | # The decorated method should have prevented the indexing operations
29 | mocked_save_record.assert_not_called()
30 |
31 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
32 | non_decorated_operation()
33 |
34 | # The non-decorated method is not preventing the indexing operations
35 | # (the signal was correctly re-connected for both of the models)
36 | mocked_save_record.assert_has_calls(
37 | [
38 | call(
39 | ANY,
40 | created=True,
41 | raw=False,
42 | sender=ANY,
43 | signal=ANY,
44 | update_fields=None,
45 | using=ANY,
46 | )
47 | ]
48 | * 2
49 | )
50 |
51 | def test_disable_auto_indexing_as_decorator_for_model(self):
52 | """Test that the `disable_auto_indexing` should work as a decorator for a specific model"""
53 |
54 | @disable_auto_indexing(model=User)
55 | def decorated_operation():
56 | WebsiteFactory()
57 | UserFactory()
58 |
59 | def non_decorated_operation():
60 | WebsiteFactory()
61 | UserFactory()
62 |
63 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
64 | decorated_operation()
65 |
66 | # The decorated method should have prevented the indexing operation for the `User` model
67 | # but not for the `Website` model (we get only one call)
68 | mocked_save_record.assert_called_once_with(
69 | ANY,
70 | created=True,
71 | raw=False,
72 | sender=ANY,
73 | signal=ANY,
74 | update_fields=None,
75 | using=ANY,
76 | )
77 |
78 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
79 | non_decorated_operation()
80 |
81 | # The non-decorated method is not preventing the indexing operations
82 | # (the signal was correctly re-connected for both of the models)
83 | mocked_save_record.assert_has_calls(
84 | [
85 | call(
86 | ANY,
87 | created=True,
88 | raw=False,
89 | sender=ANY,
90 | signal=ANY,
91 | update_fields=None,
92 | using=ANY,
93 | )
94 | ]
95 | * 2
96 | )
97 |
98 | def test_disable_auto_indexing_as_context_manager(self):
99 | """Test that the `disable_auto_indexing` should work as a context manager"""
100 |
101 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
102 | with disable_auto_indexing():
103 | WebsiteFactory()
104 |
105 | mocked_save_record.assert_not_called()
106 |
107 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
108 | WebsiteFactory()
109 |
110 | mocked_save_record.assert_called_once()
111 |
--------------------------------------------------------------------------------
/tests/test_engine.py:
--------------------------------------------------------------------------------
1 | import six
2 |
3 | from django import __version__ as __django__version__
4 | from django.conf import settings
5 | from django.test import TestCase
6 |
7 | from algoliasearch_django import algolia_engine, __version__
8 | from algoliasearch_django import AlgoliaIndex
9 | from algoliasearch_django import AlgoliaEngine
10 | from algoliasearch_django.registration import AlgoliaEngineError
11 | from algoliasearch_django.registration import RegistrationError
12 |
13 | from .models import Website, User
14 |
15 |
16 | class EngineTestCase(TestCase):
17 | def setUp(self):
18 | self.engine = AlgoliaEngine()
19 |
20 | def tearDown(self):
21 | for elt in self.engine.get_registered_models():
22 | self.engine.unregister(elt)
23 |
24 | def test_init_exception(self):
25 | algolia_settings = dict(settings.ALGOLIA)
26 | del algolia_settings["APPLICATION_ID"]
27 | del algolia_settings["API_KEY"]
28 |
29 | with self.settings(ALGOLIA=algolia_settings):
30 | with self.assertRaises(AlgoliaEngineError):
31 | AlgoliaEngine(settings=settings.ALGOLIA)
32 |
33 | def test_user_agent(self):
34 | self.assertIn(
35 | "Algolia for Django ({}); Django ({})".format(
36 | __version__, __django__version__
37 | ),
38 | self.engine.client._config._user_agent.get(),
39 | )
40 |
41 | def test_auto_discover_indexes(self):
42 | """Test that the `index` module was auto-discovered and the models registered"""
43 |
44 | six.assertCountEqual(
45 | self,
46 | [
47 | User, # Registered using the `register` decorator
48 | Website, # Registered using the `register` method
49 | ],
50 | algolia_engine.get_registered_models(),
51 | )
52 |
53 | def test_is_register(self):
54 | self.engine.register(Website)
55 | self.assertTrue(self.engine.is_registered(Website))
56 | self.assertFalse(self.engine.is_registered(User))
57 |
58 | def test_get_adapter(self):
59 | self.engine.register(Website)
60 | self.assertEqual(AlgoliaIndex, self.engine.get_adapter(Website).__class__)
61 |
62 | def test_get_adapter_exception(self):
63 | with self.assertRaises(RegistrationError):
64 | self.engine.get_adapter(Website)
65 |
66 | def test_get_adapter_from_instance(self):
67 | self.engine.register(Website)
68 | instance = Website()
69 | self.assertEqual(
70 | AlgoliaIndex, self.engine.get_adapter_from_instance(instance).__class__
71 | )
72 |
73 | def test_register(self):
74 | self.engine.register(Website)
75 | self.engine.register(User)
76 | self.assertIn(Website, self.engine.get_registered_models())
77 | self.assertIn(User, self.engine.get_registered_models())
78 |
79 | def test_register_exception(self):
80 | self.engine.register(Website)
81 | self.engine.register(User)
82 |
83 | with self.assertRaises(RegistrationError):
84 | self.engine.register(Website)
85 |
86 | def test_register_with_custom_index(self):
87 | class WebsiteIndex(AlgoliaIndex):
88 | pass
89 |
90 | self.engine.register(Website, WebsiteIndex)
91 | self.assertEqual(
92 | WebsiteIndex.__name__, self.engine.get_adapter(Website).__class__.__name__
93 | )
94 |
95 | def test_register_with_custom_index_exception(self):
96 | class WebsiteIndex(object):
97 | pass
98 |
99 | # WebsiteIndex is not a subclass of AlgoliaIndex
100 | with self.assertRaises(RegistrationError):
101 | self.engine.register(Website, WebsiteIndex)
102 |
103 | def test_unregister(self):
104 | self.engine.register(Website)
105 | self.engine.register(User)
106 | self.engine.unregister(Website)
107 |
108 | registered_models = self.engine.get_registered_models()
109 | self.assertNotIn(Website, registered_models)
110 | self.assertIn(User, registered_models)
111 |
112 | def test_unregister_exception(self):
113 | self.engine.register(User)
114 |
115 | with self.assertRaises(RegistrationError):
116 | self.engine.unregister(Website)
117 |
--------------------------------------------------------------------------------
/tests/test_index.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | from django.conf import settings
3 | from django.test import TestCase
4 |
5 |
6 | from algoliasearch_django import AlgoliaIndex
7 | from algoliasearch_django import algolia_engine
8 | from algoliasearch_django.models import AlgoliaIndexError
9 |
10 | from .models import User, Website, Example
11 |
12 |
13 | def sanitize(hit):
14 | if "_highlightResult" in hit:
15 | hit.pop("_highlightResult")
16 | return hit
17 |
18 |
19 | class IndexTestCase(TestCase):
20 | def setUp(self):
21 | self.client = algolia_engine.client
22 | self.user = User(
23 | name="Algolia",
24 | username="algolia",
25 | bio="Milliseconds matter",
26 | followers_count=42001,
27 | following_count=42,
28 | _lat=123,
29 | _lng=-42.24,
30 | _permissions="read,write,admin",
31 | )
32 | self.website = Website(name="Algolia", url="https://algolia.com")
33 |
34 | self.contributor = User(
35 | name="Contributor",
36 | username="contributor",
37 | bio="Contributions matter",
38 | followers_count=7,
39 | following_count=5,
40 | _lat=52.0705,
41 | _lng=-4.3007,
42 | _permissions="contribute,follow",
43 | )
44 |
45 | self.example = Example(
46 | uid=4, name="SuperK", address="Finland", lat=63.3, lng=-32.0, is_admin=True
47 | )
48 | self.example.category = ["Shop", "Grocery"]
49 | self.example.locations = [
50 | {"lat": 10.3, "lng": -20.0},
51 | {"lat": 22.3, "lng": 10.0},
52 | ]
53 |
54 | def tearDown(self):
55 | if hasattr(self, "index"):
56 | self.index.delete()
57 |
58 | def test_default_index_name(self):
59 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
60 | regex = r"^test_Website_django(_ci-\d+.\d+)?$"
61 | try:
62 | self.assertRegex(self.index.index_name, regex)
63 | except AttributeError:
64 | self.assertRegexpMatches(self.index.index_name, regex)
65 |
66 | def test_custom_index_name(self):
67 | class WebsiteIndex(AlgoliaIndex):
68 | index_name = "customName"
69 |
70 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
71 | regex = r"^test_customName_django(_ci-\d+.\d+)?$"
72 | try:
73 | self.assertRegex(self.index.index_name, regex)
74 | except AttributeError:
75 | self.assertRegexpMatches(self.index.index_name, regex)
76 |
77 | def test_index_model_with_foreign_key_reference(self):
78 | self.index = AlgoliaIndex(User, self.client, settings.ALGOLIA)
79 | self.index.reindex_all()
80 | self.assertFalse("blogpost" in self.index.fields)
81 |
82 | def test_index_name_settings(self):
83 | algolia_settings = dict(settings.ALGOLIA)
84 | del algolia_settings["INDEX_PREFIX"]
85 | del algolia_settings["INDEX_SUFFIX"]
86 |
87 | with self.settings(ALGOLIA=algolia_settings):
88 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
89 | regex = r"^Website$"
90 | try:
91 | self.assertRegex(self.index.index_name, regex)
92 | except AttributeError:
93 | self.assertRegexpMatches(self.index.index_name, regex)
94 |
95 | def test_tmp_index_name(self):
96 | """Test that the temporary index name should respect suffix and prefix settings"""
97 |
98 | algolia_settings = dict(settings.ALGOLIA)
99 |
100 | # With no suffix nor prefix
101 | del algolia_settings["INDEX_PREFIX"]
102 | del algolia_settings["INDEX_SUFFIX"]
103 |
104 | with self.settings(ALGOLIA=algolia_settings):
105 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
106 | self.assertEqual(self.index.tmp_index_name, "Website_tmp")
107 |
108 | # With only a prefix
109 | algolia_settings["INDEX_PREFIX"] = "prefix"
110 |
111 | with self.settings(ALGOLIA=algolia_settings):
112 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
113 | self.assertEqual(self.index.tmp_index_name, "prefix_Website_tmp")
114 |
115 | # With only a suffix
116 | del algolia_settings["INDEX_PREFIX"]
117 | algolia_settings["INDEX_SUFFIX"] = "suffix"
118 |
119 | with self.settings(ALGOLIA=algolia_settings):
120 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
121 | self.assertEqual(self.index.tmp_index_name, "Website_tmp_suffix")
122 |
123 | # With a prefix and a suffix
124 | algolia_settings["INDEX_PREFIX"] = "prefix"
125 | algolia_settings["INDEX_SUFFIX"] = "suffix"
126 |
127 | with self.settings(ALGOLIA=algolia_settings):
128 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
129 | self.assertEqual(self.index.tmp_index_name, "prefix_Website_tmp_suffix")
130 |
131 | def test_reindex_with_replicas(self):
132 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
133 |
134 | class WebsiteIndex(AlgoliaIndex):
135 | settings = {
136 | "replicas": [
137 | self.index.index_name + "_name_asc", # pyright: ignore
138 | self.index.index_name + "_name_desc", # pyright: ignore
139 | ]
140 | }
141 |
142 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
143 | self.index.reindex_all()
144 |
145 | def test_reindex_with_should_index_boolean(self):
146 | Website(name="Algolia", url="https://algolia.com", is_online=True)
147 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
148 |
149 | class WebsiteIndex(AlgoliaIndex):
150 | settings = {
151 | "replicas": [
152 | self.index.index_name + "_name_asc", # pyright: ignore
153 | self.index.index_name + "_name_desc", # pyright: ignore
154 | ]
155 | }
156 | should_index = "is_online"
157 |
158 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
159 | self.index.reindex_all()
160 |
161 | def test_reindex_no_settings(self):
162 | self.maxDiff = None
163 |
164 | # Given an existing index defined without settings
165 | class WebsiteIndex(AlgoliaIndex):
166 | pass
167 |
168 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
169 |
170 | # Given some existing settings on the index
171 | existing_settings = self.apply_some_settings(self.index)
172 |
173 | # When reindexing with no settings on the instance
174 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
175 | self.index.reindex_all()
176 |
177 | # Expect the former settings to be kept across reindex
178 | self.assertEqual(
179 | self.index.get_settings(),
180 | existing_settings,
181 | "An index whose model has no settings should keep its settings after reindex",
182 | )
183 |
184 | def test_reindex_with_settings(self):
185 | import uuid
186 |
187 | id = str(uuid.uuid4())
188 | self.maxDiff = None
189 | index_settings = {
190 | "searchableAttributes": [
191 | "name",
192 | "email",
193 | "company",
194 | "city",
195 | "county",
196 | "account_names",
197 | "unordered(address)",
198 | "state",
199 | "zip_code",
200 | "phone",
201 | "fax",
202 | "unordered(web)",
203 | ],
204 | "attributesForFaceting": ["city", "company"],
205 | "customRanking": ["desc(followers)"],
206 | "queryType": "prefixAll",
207 | "highlightPreTag": "",
208 | "ranking": [
209 | "asc(name)",
210 | "typo",
211 | "geo",
212 | "words",
213 | "filters",
214 | "proximity",
215 | "attribute",
216 | "exact",
217 | "custom",
218 | ],
219 | "replicas": [
220 | "WebsiteIndexReplica_" + id + "_name_asc",
221 | "WebsiteIndexReplica_" + id + "_name_desc",
222 | ],
223 | "highlightPostTag": "",
224 | "hitsPerPage": 15,
225 | }
226 |
227 | # Given an existing index defined with settings
228 | class WebsiteIndex(AlgoliaIndex):
229 | settings = index_settings
230 |
231 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
232 |
233 | # Given some existing query rules on the index
234 | # index.__index.save_rule() # TODO: Check query rules are kept
235 |
236 | # Given some existing settings on the index
237 | existing_settings = self.apply_some_settings(self.index)
238 |
239 | # When reindexing with no settings on the instance
240 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
241 | self.index.reindex_all()
242 |
243 | # Expect the settings to be reset to model definition over reindex
244 | former_settings = existing_settings
245 | former_settings["hitsPerPage"] = 15
246 |
247 | new_settings = self.index.get_settings()
248 |
249 | self.assertIsNotNone(new_settings)
250 |
251 | if new_settings is not None:
252 | self.assertDictEqual(new_settings, former_settings)
253 |
254 | def test_reindex_with_rules(self):
255 | # Given an existing index defined with settings
256 | class WebsiteIndex(AlgoliaIndex):
257 | settings = {"hitsPerPage": 42}
258 |
259 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
260 |
261 | # Given some existing query rules on the index
262 | rule = {
263 | "objectID": "my-rule",
264 | "condition": {"pattern": "some text", "anchoring": "is"},
265 | "consequence": {"params": {"hitsPerPage": 42}},
266 | }
267 |
268 | self.assertIsNotNone(self.index.index_name)
269 |
270 | if self.index.index_name is None:
271 | return
272 |
273 | _resp = self.client.save_rule(self.index.index_name, rule["objectID"], rule)
274 | self.client.wait_for_task(self.index.index_name, _resp.task_id)
275 |
276 | # When reindexing with no settings on the instance
277 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
278 | self.index.reindex_all()
279 |
280 | rules = []
281 | self.client.browse_rules(
282 | self.index.index_name,
283 | lambda _resp: rules.extend([_hit.to_dict() for _hit in _resp.hits]),
284 | )
285 | self.assertEqual(len(rules), 1, "There should only be one rule")
286 | self.assertEqual(
287 | rules[0]["consequence"],
288 | rule["consequence"],
289 | "The existing rule should be kept over reindex",
290 | )
291 | self.assertEqual(
292 | rules[0]["objectID"],
293 | rule["objectID"],
294 | "The existing rule should be kept over reindex",
295 | )
296 |
297 | def test_reindex_with_synonyms(self):
298 | # Given an existing index defined with settings
299 | class WebsiteIndex(AlgoliaIndex):
300 | settings = {"hitsPerPage": 42}
301 |
302 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
303 |
304 | self.assertIsNotNone(self.index.index_name)
305 |
306 | if self.index.index_name is None:
307 | return
308 |
309 | # Given some existing synonyms on the index
310 | synonym = {
311 | "objectID": "street",
312 | "type": "altCorrection1",
313 | "word": "Street",
314 | "corrections": ["St"],
315 | }
316 | save_synonyms_response = self.client.save_synonyms(
317 | self.index.index_name, synonym_hit=[synonym]
318 | )
319 | self.client.wait_for_task(self.index.index_name, save_synonyms_response.task_id)
320 |
321 | # When reindexing with no settings on the instance
322 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
323 | self.index.reindex_all()
324 |
325 | # Expect the synonyms to be kept across reindex
326 | synonyms = []
327 | self.client.browse_synonyms(
328 | self.index.index_name,
329 | lambda _resp: synonyms.extend([sanitize(_hit.to_dict()) for _hit in _resp.hits]),
330 | )
331 | self.assertEqual(len(synonyms), 1, "There should only be one synonym")
332 | self.assertIn(
333 | synonym, synonyms, "The existing synonym should be kept over reindex"
334 | )
335 |
336 | def apply_some_settings(self, index) -> dict:
337 | """
338 | Applies a sample setting to the index.
339 |
340 | :param index: an AlgoliaIndex that will be updated
341 | :return: the new settings
342 | """
343 | # When reindexing with settings on the instance
344 | old_hpp = (
345 | index.settings["hitsPerPage"] if "hitsPerPage" in index.settings else None
346 | )
347 | index.settings["hitsPerPage"] = 42
348 | index.reindex_all()
349 | index.settings["hitsPerPage"] = old_hpp
350 | index_settings = index.get_settings()
351 | # Expect the instance's settings to be applied at reindex
352 | self.assertEqual(
353 | index_settings["hitsPerPage"],
354 | 42,
355 | "An index whose model has settings should apply those at reindex",
356 | )
357 | return index_settings
358 |
359 | def test_custom_objectID(self):
360 | class UserIndex(AlgoliaIndex):
361 | custom_objectID = "username"
362 |
363 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
364 | obj = self.index.get_raw_record(self.user)
365 | self.assertEqual(obj["objectID"], "algolia")
366 |
367 | def test_custom_objectID_property(self):
368 | class UserIndex(AlgoliaIndex):
369 | custom_objectID = "reverse_username"
370 |
371 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
372 | obj = self.index.get_raw_record(self.user)
373 | self.assertEqual(obj["objectID"], "ailogla")
374 |
375 | def test_invalid_custom_objectID(self):
376 | class UserIndex(AlgoliaIndex):
377 | custom_objectID = "uid"
378 |
379 | with self.assertRaises(AlgoliaIndexError):
380 | UserIndex(User, self.client, settings.ALGOLIA)
381 |
382 | def test_geo_fields(self):
383 | class UserIndex(AlgoliaIndex):
384 | geo_field = "location"
385 |
386 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
387 | obj = self.index.get_raw_record(self.user)
388 | self.assertEqual(obj["_geoloc"], {"lat": 123, "lng": -42.24})
389 |
390 | def test_several_geo_fields(self):
391 | class ExampleIndex(AlgoliaIndex):
392 | geo_field = "geolocations"
393 |
394 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
395 | obj = self.index.get_raw_record(self.example)
396 | self.assertEqual(
397 | obj["_geoloc"],
398 | [
399 | {"lat": 10.3, "lng": -20.0},
400 | {"lat": 22.3, "lng": 10.0},
401 | ],
402 | )
403 |
404 | def test_geo_fields_already_formatted(self):
405 | class ExampleIndex(AlgoliaIndex):
406 | geo_field = "geolocations"
407 |
408 | self.example.locations = {"lat": 10.3, "lng": -20.0}
409 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
410 | obj = self.index.get_raw_record(self.example)
411 | self.assertEqual(obj["_geoloc"], {"lat": 10.3, "lng": -20.0})
412 |
413 | def test_none_geo_fields(self):
414 | class ExampleIndex(AlgoliaIndex):
415 | geo_field = "location"
416 |
417 | Example.location = lambda x: None
418 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
419 | obj = self.index.get_raw_record(self.example)
420 | self.assertIsNone(obj.get("_geoloc"))
421 |
422 | def test_invalid_geo_fields(self):
423 | class UserIndex(AlgoliaIndex):
424 | geo_field = "position"
425 |
426 | with self.assertRaises(AlgoliaIndexError):
427 | UserIndex(User, self.client, settings.ALGOLIA)
428 |
429 | def test_tags(self):
430 | class UserIndex(AlgoliaIndex):
431 | tags = "permissions"
432 |
433 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
434 |
435 | # Test the users' tag individually
436 | obj = self.index.get_raw_record(self.user)
437 | self.assertListEqual(obj["_tags"], ["read", "write", "admin"])
438 |
439 | obj = self.index.get_raw_record(self.contributor)
440 | self.assertListEqual(obj["_tags"], ["contribute", "follow"])
441 |
442 | def test_invalid_tags(self):
443 | class UserIndex(AlgoliaIndex):
444 | tags = "tags"
445 |
446 | with self.assertRaises(AlgoliaIndexError):
447 | UserIndex(User, self.client, settings.ALGOLIA)
448 |
449 | def test_one_field(self):
450 | class UserIndex(AlgoliaIndex):
451 | fields = "name"
452 |
453 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
454 | obj = self.index.get_raw_record(self.user)
455 | self.assertIn("name", obj)
456 | self.assertNotIn("username", obj)
457 | self.assertNotIn("bio", obj)
458 | self.assertNotIn("followers_count", obj)
459 | self.assertNotIn("following_count", obj)
460 | self.assertNotIn("_lat", obj)
461 | self.assertNotIn("_lng", obj)
462 | self.assertNotIn("_permissions", obj)
463 | self.assertNotIn("location", obj)
464 | self.assertNotIn("_geoloc", obj)
465 | self.assertNotIn("permissions", obj)
466 | self.assertNotIn("_tags", obj)
467 | self.assertEqual(len(obj), 2)
468 |
469 | def test_multiple_fields(self):
470 | class UserIndex(AlgoliaIndex):
471 | fields = ("name", "username", "bio")
472 |
473 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
474 | obj = self.index.get_raw_record(self.user)
475 | self.assertIn("name", obj)
476 | self.assertIn("username", obj)
477 | self.assertIn("bio", obj)
478 | self.assertNotIn("followers_count", obj)
479 | self.assertNotIn("following_count", obj)
480 | self.assertNotIn("_lat", obj)
481 | self.assertNotIn("_lng", obj)
482 | self.assertNotIn("_permissions", obj)
483 | self.assertNotIn("location", obj)
484 | self.assertNotIn("_geoloc", obj)
485 | self.assertNotIn("permissions", obj)
486 | self.assertNotIn("_tags", obj)
487 | self.assertEqual(len(obj), 4)
488 |
489 | def test_fields_with_custom_name(self):
490 | # tuple syntax
491 | class UserIndex(AlgoliaIndex):
492 | fields = ("name", ("username", "login"), "bio")
493 |
494 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
495 | obj = self.index.get_raw_record(self.user)
496 | self.assertIn("name", obj)
497 | self.assertNotIn("username", obj)
498 | self.assertIn("login", obj)
499 | self.assertEqual(obj["login"], "algolia")
500 | self.assertIn("bio", obj)
501 | self.assertNotIn("followers_count", obj)
502 | self.assertNotIn("following_count", obj)
503 | self.assertNotIn("_lat", obj)
504 | self.assertNotIn("_lng", obj)
505 | self.assertNotIn("_permissions", obj)
506 | self.assertNotIn("location", obj)
507 | self.assertNotIn("_geoloc", obj)
508 | self.assertNotIn("permissions", obj)
509 | self.assertNotIn("_tags", obj)
510 | self.assertEqual(len(obj), 4)
511 |
512 | # list syntax
513 | class UserIndex(AlgoliaIndex):
514 | fields = ("name", ["username", "login"], "bio")
515 |
516 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
517 | obj = self.index.get_raw_record(self.user)
518 | self.assertIn("name", obj)
519 | self.assertNotIn("username", obj)
520 | self.assertIn("login", obj)
521 | self.assertEqual(obj["login"], "algolia")
522 | self.assertIn("bio", obj)
523 | self.assertNotIn("followers_count", obj)
524 | self.assertNotIn("following_count", obj)
525 | self.assertNotIn("_lat", obj)
526 | self.assertNotIn("_lng", obj)
527 | self.assertNotIn("_permissions", obj)
528 | self.assertNotIn("location", obj)
529 | self.assertNotIn("_geoloc", obj)
530 | self.assertNotIn("permissions", obj)
531 | self.assertNotIn("_tags", obj)
532 | self.assertEqual(len(obj), 4)
533 |
534 | def test_invalid_fields(self):
535 | class UserIndex(AlgoliaIndex):
536 | fields = ("name", "color")
537 |
538 | with self.assertRaises(AlgoliaIndexError):
539 | UserIndex(User, self.client, settings.ALGOLIA)
540 |
541 | def test_invalid_fields_syntax(self):
542 | class UserIndex(AlgoliaIndex):
543 | fields = {"name": "user_name"}
544 |
545 | with self.assertRaises(AlgoliaIndexError):
546 | UserIndex(User, self.client, settings.ALGOLIA)
547 |
548 | def test_invalid_named_fields_syntax(self):
549 | class UserIndex(AlgoliaIndex):
550 | fields = ("name", {"username": "login"})
551 |
552 | with self.assertRaises(AlgoliaIndexError):
553 | UserIndex(User, self.client, settings.ALGOLIA)
554 |
555 | def test_get_raw_record_with_update_fields(self):
556 | class UserIndex(AlgoliaIndex):
557 | fields = ("name", "username", ["bio", "description"])
558 |
559 | self.index = UserIndex(User, self.client, settings.ALGOLIA)
560 | obj = self.index.get_raw_record(self.user, update_fields=("name", "bio"))
561 | self.assertIn("name", obj)
562 | self.assertNotIn("username", obj)
563 | self.assertNotIn("bio", obj)
564 | self.assertIn("description", obj)
565 | self.assertEqual(obj["description"], "Milliseconds matter")
566 | self.assertNotIn("followers_count", obj)
567 | self.assertNotIn("following_count", obj)
568 | self.assertNotIn("_lat", obj)
569 | self.assertNotIn("_lng", obj)
570 | self.assertNotIn("_permissions", obj)
571 | self.assertNotIn("location", obj)
572 | self.assertNotIn("_geoloc", obj)
573 | self.assertNotIn("permissions", obj)
574 | self.assertNotIn("_tags", obj)
575 | self.assertEqual(len(obj), 3)
576 |
577 | def test_should_index_method(self):
578 | class ExampleIndex(AlgoliaIndex):
579 | fields = "name"
580 | should_index = "has_name"
581 |
582 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
583 | self.assertTrue(
584 | self.index._should_index(self.example),
585 | "We should index an instance when should_index(instance) returns True",
586 | )
587 |
588 | instance_should_not = Example(name=None)
589 | self.assertFalse(
590 | self.index._should_index(instance_should_not),
591 | "We should not index an instance when should_index(instance) returns False",
592 | )
593 |
594 | def test_should_index_unbound(self):
595 | class ExampleIndex(AlgoliaIndex):
596 | fields = "name"
597 | should_index = "static_should_index"
598 |
599 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
600 | self.assertTrue(
601 | self.index._should_index(self.example),
602 | "We should index an instance when should_index() returns True",
603 | )
604 |
605 | class ExampleIndex(AlgoliaIndex):
606 | fields = "name"
607 | should_index = "static_should_not_index"
608 |
609 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
610 | instance_should_not = Example()
611 | self.assertFalse(
612 | self.index._should_index(instance_should_not),
613 | "We should not index an instance when should_index() returns False",
614 | )
615 |
616 | def test_should_index_attr(self):
617 | class ExampleIndex(AlgoliaIndex):
618 | fields = "name"
619 | should_index = "index_me"
620 |
621 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
622 | self.assertTrue(
623 | self.index._should_index(self.example),
624 | "We should index an instance when its should_index attr is True",
625 | )
626 |
627 | instance_should_not = Example()
628 | instance_should_not.index_me = False
629 | self.assertFalse(
630 | self.index._should_index(instance_should_not),
631 | "We should not index an instance when its should_index attr is False",
632 | )
633 |
634 | class ExampleIndex(AlgoliaIndex):
635 | fields = "name"
636 | should_index = "category"
637 |
638 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
639 | with self.assertRaises(
640 | AlgoliaIndexError,
641 | msg="We should raise when the should_index attr is not boolean",
642 | ):
643 | self.index._should_index(self.example)
644 |
645 | def test_should_index_field(self):
646 | class ExampleIndex(AlgoliaIndex):
647 | fields = "name"
648 | should_index = "is_admin"
649 |
650 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
651 | self.assertTrue(
652 | self.index._should_index(self.example),
653 | "We should index an instance when its should_index field is True",
654 | )
655 |
656 | instance_should_not = Example()
657 | instance_should_not.is_admin = False
658 | self.assertFalse(
659 | self.index._should_index(instance_should_not),
660 | "We should not index an instance when its should_index field is False",
661 | )
662 |
663 | class ExampleIndex(AlgoliaIndex):
664 | fields = "name"
665 | should_index = "name"
666 |
667 | self.index = ExampleIndex(Example, self.client, settings.ALGOLIA)
668 | with self.assertRaises(
669 | AlgoliaIndexError,
670 | msg="We should raise when the should_index field is not boolean",
671 | ):
672 | self.index._should_index(self.example)
673 |
674 | def test_should_index_property(self):
675 | class ExampleIndex1(AlgoliaIndex):
676 | fields = "name"
677 | should_index = "property_should_index"
678 |
679 | self.index = ExampleIndex1(Example, self.client, settings.ALGOLIA)
680 | self.assertTrue(
681 | self.index._should_index(self.example),
682 | "We should index an instance when its should_index property is True",
683 | )
684 |
685 | class ExampleIndex2(AlgoliaIndex):
686 | fields = "name"
687 | should_index = "property_should_not_index"
688 |
689 | self.index = ExampleIndex2(Example, self.client, settings.ALGOLIA)
690 | self.assertFalse(
691 | self.index._should_index(self.example),
692 | "We should not index an instance when its should_index property is False",
693 | )
694 |
695 | class ExampleIndex3(AlgoliaIndex):
696 | fields = "name"
697 | should_index = "property_string"
698 |
699 | self.index = ExampleIndex3(Example, self.client, settings.ALGOLIA)
700 | with self.assertRaises(
701 | AlgoliaIndexError,
702 | msg="We should raise when the should_index property is not boolean",
703 | ):
704 | self.index._should_index(self.example)
705 |
706 | def test_save_record_should_index_boolean(self):
707 | self.index = AlgoliaIndex(Website, self.client, settings.ALGOLIA)
708 |
709 | class WebsiteIndex(AlgoliaIndex):
710 | custom_objectID = "name"
711 | settings = {
712 | "replicas": [
713 | self.index.index_name + "_name_asc", # pyright: ignore
714 | self.index.index_name + "_name_desc", # pyright: ignore
715 | ]
716 | }
717 | should_index = "is_online"
718 |
719 | self.website.is_online = True
720 | self.index = WebsiteIndex(Website, self.client, settings.ALGOLIA)
721 | self.index.save_record(self.website)
722 |
723 | def test_cyrillic(self):
724 | class CyrillicIndex(AlgoliaIndex):
725 | fields = ["bio", "name"]
726 | settings = {
727 | "searchableAttributes": ["name", "bio"],
728 | }
729 | index_name = "test_cyrillic"
730 |
731 | self.user.bio = "крупнейших"
732 | self.user.save()
733 | self.index = CyrillicIndex(User, self.client, settings.ALGOLIA)
734 | self.index.save_record(self.user)
735 | result = self.index.raw_search("крупнейших")
736 | self.assertIsNotNone(result)
737 |
738 | if result is not None:
739 | self.assertEqual(result["nbHits"], 1, "Search should return one result")
740 | self.assertEqual(
741 | result["hits"][0]["name"], "Algolia", "The result should be self.user"
742 | )
743 |
--------------------------------------------------------------------------------
/tests/test_signal.py:
--------------------------------------------------------------------------------
1 | import time
2 | from mock import patch, call, ANY
3 |
4 | from django.test import TestCase
5 |
6 | from algoliasearch_django import algolia_engine
7 | from algoliasearch_django import get_adapter
8 | from algoliasearch_django import raw_search
9 | from algoliasearch_django import clear_objects
10 | from algoliasearch_django import update_records
11 |
12 | from .factories import WebsiteFactory
13 | from .models import Website
14 |
15 |
16 | class SignalTestCase(TestCase):
17 | @classmethod
18 | def tearDownClass(cls):
19 | get_adapter(Website).delete()
20 |
21 | def tearDown(self):
22 | clear_objects(Website)
23 |
24 | def test_save_signal(self):
25 | with patch.object(algolia_engine, "save_record") as mocked_save_record:
26 | websites = WebsiteFactory.create_batch(3)
27 |
28 | mocked_save_record.assert_has_calls(
29 | [
30 | call(
31 | website,
32 | created=True,
33 | raw=False,
34 | sender=ANY,
35 | signal=ANY,
36 | update_fields=None,
37 | using=ANY,
38 | )
39 | for website in websites
40 | ]
41 | )
42 |
43 | def test_delete_signal(self):
44 | websites = WebsiteFactory.create_batch(3)
45 |
46 | with patch.object(algolia_engine, "delete_record") as mocked_delete_record:
47 | websites[0].delete()
48 | websites[1].delete()
49 |
50 | mocked_delete_record.assert_has_calls([call(websites[0]), call(websites[1])])
51 |
52 | def test_update_records(self):
53 | Website(name="Algolia", url="https://www.algolia.com", is_online=False)
54 | Website(name="Google", url="https://www.google.com", is_online=False)
55 | Website(name="Facebook", url="https://www.facebook.com", is_online=False)
56 | Website(name="Facebook", url="https://www.facebook.fr", is_online=False)
57 | Website(name="Facebook", url="https://fb.com", is_online=False)
58 |
59 | qs = Website.objects.filter(name="Facebook")
60 | update_records(Website, qs, url="https://facebook.com")
61 | time.sleep(10)
62 | qs.update(url="https://facebook.com")
63 |
64 | time.sleep(10)
65 | result = raw_search(Website, "Facebook")
66 | self.assertEqual(result["nbHits"], qs.count())
67 | for res, url in zip(result["hits"], qs.values_list("url", flat=True)):
68 | self.assertEqual(res["url"], url)
69 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | {py38,py39,py310}-django40
4 | {py38,py39,py310,py311}-django41
5 | {py38,py39,py310,py311,py312}-django42
6 | {py310,py311,py312}-django50
7 | {py310,py311,py312,py313}-django51
8 | coverage
9 | skip_missing_interpreters = True
10 |
11 | [testenv]
12 | deps =
13 | six
14 | mock
15 | factory_boy
16 | py{38,313}: Faker>=5.0,<6.0
17 | django40: Django>=4.0,<4.1
18 | django41: Django>=4.1,<4.2
19 | django42: Django>=4.2,<4.3
20 | django50: Django>=5.0,<5.1
21 | django51: Django>=5.1,<5.2
22 | passenv =
23 | ALGOLIA*
24 | commands =
25 | pip3 install -r requirements.txt
26 | python runtests.py
27 |
28 | [versions]
29 | twine = >=5.1,<6.0
30 | wheel = >=0.45,<1.0
31 | ruff = >=0.7.4,<1.0
32 | pyright = >=1.1.389,<2.0
33 | setuptools = >=75.0,<76.0
34 |
35 | [testenv:coverage]
36 | basepython = python3.13
37 | deps = coverage
38 | passenv =
39 | ALGOLIA*
40 | commands =
41 | coverage run --branch --source=algoliasearch_django runtests.py
42 | coverage report
43 |
44 | [testenv:coveralls]
45 | basepython = python3.13
46 | deps =
47 | coverage
48 | coveralls
49 | passenv =
50 | ALGOLIA*
51 | commands =
52 | coverage run --branch --source=algoliasearch_django runtests.py
53 | coverage report
54 | coveralls
55 |
56 | [testenv:build]
57 | basepython = python3.13
58 | deps =
59 | twine {[versions]twine}
60 | wheel {[versions]wheel}
61 | setuptools {[versions]setuptools}
62 | commands =
63 | python setup.py sdist bdist_wheel
64 | twine check dist/*
65 |
66 | [testenv:lint]
67 | deps =
68 | ruff {[versions]ruff}
69 | pyright {[versions]pyright}
70 | commands =
71 | pip3 install -r requirements.txt
72 | ruff check --fix --unsafe-fixes
73 | ruff format .
74 | pyright algoliasearch_django
75 |
--------------------------------------------------------------------------------