├── .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 | | ![#050f2c](https://placehold.it/15/050f2c/000000?text=+) Do not merge | PR should not be merged (decided by maintainers) | 60 | | ![#ffc168](https://placehold.it/15/ffc168/000000?text=+) WIP | PR is not ready, no need to look at it (decided by contributors) | 61 | | ![#2ede98](https://placehold.it/15/2ede98/000000?text=+) Ready | The PR is ready, reviewed, tests are green, if you're brave enough: click merge button | 62 | | ![#ffc168](https://placehold.it/15/ffc168/000000?text=+) Waiting for API | The feature is implemented but the REST API is not live yet | 63 | | ![#3369e6](https://placehold.it/15/3369e6/000000?text=+) Discussion | We need everyone's opinion on this, please join the conversation and share your ideas | 64 | | ![#3369e6](https://placehold.it/15/3369e6/000000?text=+) Support | A user needs help but it's not really a bug | 65 | | ![#3369e6](https://placehold.it/15/3369e6/000000?text=+) API feature | New API feature added to every client (like query rules) | 66 | | ![#3369e6](https://placehold.it/15/3369e6/000000?text=+) Chore | CI, docs, and everything around the code | 67 | | ![#ff4f81](https://placehold.it/15/ff4f81/000000?text=+) Bug | It's a bug, fix it! | 68 | | ![#b60205](https://placehold.it/15/b60205/000000?text=+) Breaking change | RED ALERT! This means we need a new major version | 69 | | ![#ff6c5f](https://placehold.it/15/ff6c5f/000000?text=+) 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 | Algolia for Django 4 | 5 | 6 |

The perfect starting point to integrate Algolia within your Django project

7 | 8 |

9 | Build Status 10 | Coverage Status 11 | PyPi Version 12 |

13 |

14 | 15 |

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 | - [Custom objectID](#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 | --------------------------------------------------------------------------------