├── .flake8 ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── black.yml │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENCE.txt ├── Procfile ├── README.md ├── dev ├── Dockerfile ├── README.md ├── docker-compose.yml ├── local-test.sh └── pg │ ├── Dockerfile │ └── install-extensions.sql ├── garnett ├── __init__.py ├── apps.py ├── context.py ├── context_processors.py ├── exceptions.py ├── expressions.py ├── ext │ ├── __init__.py │ ├── drf.py │ ├── filters.py │ └── reversion.py ├── fields.py ├── lookups.py ├── middleware.py ├── migrate.py ├── mixins.py ├── patch.py ├── selectors.py ├── serializers │ ├── __init__.py │ ├── base.py │ └── json.py ├── templates │ ├── admin │ │ └── change_form_object_tools.html │ └── garnett │ │ └── language_selector.html ├── templatetags │ ├── __init__.py │ └── garnett.py ├── translatedstr.py └── utils.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── run.sh ├── runtime.txt ├── tests ├── library_app │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── generators.py │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ ├── managers.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_make_translatable.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates │ │ └── library_app │ │ │ ├── base.html │ │ │ ├── book_detail.html │ │ │ ├── book_form.html │ │ │ ├── book_history.html │ │ │ └── book_list.html │ ├── urls.py │ ├── utils.py │ └── views.py ├── manage.py └── tests │ ├── __init__.py │ ├── ext │ ├── README.md │ ├── __init__.py │ ├── test_drf.py │ ├── test_drf_filters.py │ └── test_reversion.py │ ├── migration_test_utils.py │ ├── test_admin.py │ ├── test_assignment.py │ ├── test_database_lookups.py │ ├── test_default.py │ ├── test_fallback.py │ ├── test_field.py │ ├── test_html.py │ ├── test_instantiation.py │ ├── test_locales.py │ ├── test_middleware.py │ ├── test_migration.py │ ├── test_modelforms.py │ ├── test_models.py │ ├── test_pg_search.py │ ├── test_requests.py │ └── test_update.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E501, E203, W503 4 | exclude = */migrations/* 5 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Switch line endings 2 | 60e17e885f19485da2b903b7296c3c26f2fff8c0 3 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-python@v2 11 | with: 12 | python-version: 3.9 13 | - uses: psf/black@stable 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Testing 7 | runs-on: ubuntu-latest 8 | 9 | # Service containers to run with `container-job` 10 | services: 11 | postgres: 12 | # Docker Hub image 13 | image: postgres 14 | # Provide the password for postgres 15 | env: 16 | POSTGRES_USER: postgres 17 | POSTGRES_PASSWORD: changeme 18 | POSTGRES_DB: test 19 | ports: 20 | - 5432:5432 21 | # Set health checks to wait until postgres has started 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | mariadb: 28 | # Docker Hub image 29 | image: mariadb:10 30 | env: 31 | MYSQL_DATABASE: test 32 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 33 | ports: 34 | - 13306:3306 35 | # Set health checks to wait until mariadb has started 36 | options: >- 37 | --health-cmd="mysqladmin ping" 38 | --health-interval 10s 39 | --health-timeout 5s 40 | --health-retries 10 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-python@v2 45 | with: 46 | python-version: 3.12 47 | 48 | - name: Install test tools 49 | run: pip install tox coverage 50 | 51 | - name: Run SQLite tests 52 | run: tox 53 | 54 | - name: Run Postgres tests 55 | env: 56 | DATABASE_URL: postgres://postgres:changeme@localhost:5432/test 57 | run: tox 58 | 59 | - name: Run MariaDB tests 60 | env: 61 | DATABASE_URL: mysql://root:@127.0.0.1:13306/test 62 | run: | 63 | /usr/bin/mysql --host=127.0.0.1 --port=13306 --user=root --execute "SHOW DATABASES;" 64 | tox 65 | 66 | - name: Coverage report 67 | run: | 68 | coverage report -m 69 | coverage lcov -o ./coverage/lcov.info 70 | 71 | - name: Coveralls Parallel 72 | uses: coverallsapp/github-action@master 73 | with: 74 | github-token: ${{ secrets.github_token }} 75 | flag-name: run-${{ matrix.test_number }} 76 | parallel: true 77 | 78 | public-coveralls: 79 | needs: test 80 | runs-on: ubuntu-latest 81 | steps: 82 | - name: Publish to Coveralls.io 83 | uses: coverallsapp/github-action@master 84 | with: 85 | github-token: ${{ secrets.github_token }} 86 | parallel-finished: true 87 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | 9 | pypi: 10 | name: Publish to pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | - name: Install poetry 19 | run: pip install poetry 20 | - name: Build 21 | run: poetry build 22 | - name: Publish 23 | uses: pypa/gh-action-pypi-publish@v1.4.1 24 | with: 25 | user: __token__ 26 | password: ${{ secrets.PYPI_API_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 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 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .vscode/tags 132 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "[python]": { 4 | "editor.formatOnSave": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Logs 2 | 3 | ## 0.5.1 4 | - Added support for django 4.1 5 | ## 0.5.3 6 | - Added support for django 4.2 7 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License for django-garnett 2 | 3 | Copyright (c) 2020 Aristotle Metadata Enterprises 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its contributors 17 | may be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bash ./run.sh 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-garnett 2 | 3 | Django Garnett is a field level translation library that allows you to store strings in multiple languages for fields in Django - with minimal changes to your models and without having to rewrite your code. 4 | 5 | Want a demo? https://django-garnett.herokuapp.com/ 6 | 7 | 9 | Made with by 10 | Aristotle Metadata 11 | 12 | 13 | In summary it allows you to do this: 14 | 15 | 16 | 17 | 22 | 23 | 49 | 97 |
18 | models.py 19 | 20 | You can do this! 21 |
24 | By changing your models from this... 25 | 26 | ```python 27 | class Greeting(models.model): 28 | text = CharField(max_length=150) 29 | target = models.CharField() 30 | def __str__(self): 31 | return f"{self.greeting}, {self.target}" 32 | 33 | ``` 34 | 35 | to this... 36 | 37 | ```python 38 | # Import garnett 39 | from garnett.fields import Translated 40 | 41 | class Greeting(models.model): 42 | # Convert greeting to a translatable field 43 | text = Translated(CharField(max_length=150)) 44 | target = models.CharField() 45 | def __str__(self): 46 | return f"{self.greeting} {self.target}" 47 | ``` 48 | 50 | 51 | ```python 52 | from garnett.context import set_field_language 53 | greeting = Greeting(text="Hello", target="World") 54 | 55 | with set_field_language("en"): 56 | greeting.text = "Hello" 57 | with set_field_language("fr"): 58 | greeting.text = "Bonjour" 59 | 60 | greeting.save() 61 | greeting.refresh_from_db() 62 | 63 | with set_field_language("en"): 64 | print(greeting.text) 65 | print(greeting) 66 | # >>> "Hello" 67 | # >>> "Hello World" 68 | 69 | with set_field_language("fr"): 70 | print(greeting.text) 71 | print(greeting) 72 | # >>> "Bonjour" 73 | # >>> "Bonjour World!" 74 | 75 | with set_field_language("en"): 76 | print(greeting.text) 77 | print(greeting) 78 | # >>> "Hello" 79 | # >>> "Hello World" 80 | Greeting.objects.filter(title="Hello").exists() 81 | # >>> True 82 | Greeting.objects.filter(title="Bonjour").exists() 83 | # >>> False 84 | Greeting.objects.filter(title__fr="Bonjour").exists() 85 | # >>> True!! 86 | 87 | # Assuming that GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE="en" 88 | # Or a middleware has set the language context 89 | print(greeting.text) 90 | # >>> Hello 91 | print(greeting) 92 | # >>> Hello World! 93 | 94 | ``` 95 | 96 |
98 | 99 | Tested on: 100 | - Django 3.1+ 101 | - Postgres, SQLite, MariaDB 102 | - Python 3.7+ 103 | 104 | Tested with the following libraries: 105 | - [Django Rest Framework](https://www.django-rest-framework.org/) 106 | - [django-reversion](https://github.com/etianen/django-reversion) & [django-reversion-compare](https://github.com/jedie/django-reversion-compare) 107 | - [django-filters](https://github.com/carltongibson/django-filter) 108 | 109 | Pros: 110 | * Battletested in production - [Aristotle Metadata](https://www.aristotlemetadata.com) built, support and uses this library for 2 separate products, served to government and enterprise clients! 111 | * Fetching all translations for a model requires a single query 112 | * Translations are stored in a single database field with the model 113 | * All translations act like a regular field, so `Model.field_name = "some string"` and `print(Model.field_name)` work as you would expect 114 | * Includes a configurable middleware that can set the current language context based on users cookies, query string or HTTP headers 115 | * Works nicely with Django Rest Framework - translatable fields can be set as strings or as json dictionaries 116 | * Works nicely with Django `F()` and `Q()` objects within the ORM - and when it doesn't we have a language aware `LangF()` replacement you can use. 117 | 118 | Cons: 119 | * You need to alter the models, so you can't make third-party libraries translatable. 120 | * It doesn't work on `queryset.values_list` or `queryset.values` natively - but we have an easy Queryset Mixin to add language support for both below. 121 | 122 | ## Why write a new Django field translator? 123 | 124 | A few reasons: 125 | * Most existing django field translation libraries are static, and add separate database columns or extra tables per translation. 126 | * Other libraries may not be compatible with common django libraries, like django-rest-framework. 127 | * We had a huge codebase that we wanted to upgrade to be multilingual - so we needed a library that could be added in without requiring a rewriting every access to fields on models, and only required minor tweaks. 128 | 129 | Note: Field language is different to the django display language. Django can be set up to translate your pages based on the users browser and serve them with a user interface in their preferred language. 130 | 131 | Garnett *does not* use the browser language by design - a user with a French browser may want the user interface in French, but want to see content in English or French based on their needs. 132 | 133 | 134 | # How to install 135 | 136 | 1. Add `django-garnett` to your dependencies. eg. `pip install django-garnett` 137 | 2. Convert your chosen field using the `Translated` function 138 | 139 | * For example: `title = fields.Translated(models.CharField(*args))` 140 | 141 | 3. Add `GARNETT_TRANSLATABLE_LANGUAGES` (a callable or list of [language codes][term-language-code]) to your django settings. 142 | > Note: At the moment there is no way to allow "a user to enter any language". 143 | 4. Add `GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE` (a callable or single [language code][term-language-code]) to your settings. 144 | 5. Re-run `django makemigrations`, perform a data migration to make your existing data translatable (See 'Data migrations') & `django migrate` for any apps you've updated. 145 | 6. Thats mostly it. 146 | 147 | You can also add a few optional value adds: 148 | 149 | 7. (Optional) Add a garnett middleware to take care of field language handling: 150 | 151 | * You want to capture the garnett language in a context variable available in views use: `garnett.middleware.TranslationContextMiddleware` 152 | 153 | * You want to capture the garnett language in a context variable available in views, and want to raise a 404 if the user requests an invalid language use: `garnett.middleware.TranslationContextNotFoundMiddleware` 154 | 155 | * (Future addition) You want to capture the garnett language in a context variable available in views, and want to redirect to the default language if the user requests an invalid language use: `garnett.middleware.TranslationContextRedirectDefaultMiddleware` 156 | 157 | * If you want to cache the current language in session storage use `garnett.middleware.TranslationCacheMiddleware` after one of the above middleware (this is useful with the session selector mentioned below) 158 | 159 | 8. (Optional) Add the `garnett` app to your `INSTALLED_APPS` to use garnett's template_tags. If this is installed before `django.contrib.admin` it also include a language switcher in the Django Admin Site. 160 | 161 | 9. (Optional) Add a template processor: 162 | 163 | * Install `garnett.context_processors.languages` this will add `garnett_languages` (a list of available `Language`s) and `garnett_current_language` (the currently selected language). 164 | 165 | 10. (Optional) Add a custom translation fallback: 166 | 167 | By default, if a language isn't available for a field, Garnett will show a mesage like: 168 | > No translation of this field available in English 169 | 170 | You can override this either by creating a custom fallback method: 171 | ``` 172 | Translated(CharField(max_length=150), fallback=my_fallback_method)) 173 | ``` 174 | Where `my_fallback_method` takes a dictionary of language codes and corresponding strings, and returns the necessary text. 175 | 176 | Additionally, you can customise how django outputs text in templates by creating a new 177 | `TranslationStr` class, and overriding the [`__html__` method][dunder-html]. 178 | 179 | 180 | ## Data migrations 181 | 182 | If you have lots of existing data (and if you are using this library you probably do) you will need to perform data migrations to make sure all of your existing data is multi-lingual aware. Fortunately, we've added some well tested migration utilities that can take care of this for you. 183 | 184 | Once you have run `django-admin makemigrations` you just need to add the `step_1_safe_encode_content` before and `step_2_safe_prepare_translations` after your schema migrations, like in the following example: 185 | 186 | ```python 187 | # Generated by Django 3.1.13 on 2022-01-11 10:13 188 | 189 | from django.db import migrations, models 190 | import garnett.fields 191 | import library_app.models 192 | 193 | #### Add this line in 194 | from garnett.migrate import step_1_safe_encode_content, step_2_safe_prepare_translations 195 | 196 | #### Define the models and fields you want ot migrate 197 | model_fields = { 198 | "book": ["title", "description"], 199 | } 200 | 201 | 202 | class Migration(migrations.Migration): 203 | 204 | dependencies = [ 205 | ("library_app", "0001_initial"), 206 | ] 207 | 208 | operations = [ 209 | ## Add this operation at the start 210 | step_1_safe_encode_content("library_app", model_fields), 211 | 212 | ## These are the automatically generated migrations 213 | migrations.AlterField( # ... migrate title to TranslatedField), 214 | migrations.AlterField( # ... migrate description to TranslatedField), 215 | 216 | ## Add this operation at the start 217 | step_2_safe_prepare_translations("library_app", model_fields), 218 | ] 219 | 220 | ``` 221 | 222 | 223 | ## `Language` vs language 224 | 225 | Django Garnett uses the python `langcodes` library to determine more information about the languages being used - including the full name and local name of the language being used. This is stored as a `Language` object. 226 | 227 | 228 | ## Django Settings options: 229 | 230 | * `GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE` 231 | * Stores the default language to be used for reading and writing fields if no language is set in a context manager or by a request. 232 | * By default it is 'en-AU' the [language code][term-language-code] for 'Strayan, the native tongue of inhabitants of 'Straya (or more commonly known as Australia). 233 | * This can also be a callable that returns list of language codes. Combined with storing user settings in something like (django-solo)[https://github.com/lazybird/django-solo] users can dynamically add or change their language settings. 234 | * default: `'en-AU'` 235 | * `GARNETT_TRANSLATABLE_LANGUAGES`: 236 | * Stores a list of [language codes][term-language-code] that users can use to save against TranslatableFields. 237 | * This can also be a callable that returns list of language codes. Combined with storing user settings in something like (django-solo)[https://github.com/lazybird/django-solo] users can dynamically add or change their language settings. 238 | * default `[GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE]` 239 | * `GARNETT_REQUEST_LANGUAGE_SELECTORS`: 240 | * A list of string modules that determines the order of options used to determine the language selected by the user. The first selector found is used for the language for the request, if none are found the DEFAULT_LANGUAGE is used. These can any of the following in any order: 241 | * `garnett.selector.query`: Checks the `GARNETT_QUERY_PARAMETER_NAME` for a language to display 242 | * `garnett.selector.cookie`: Checks for a cookie called `GARNETT_LANGUAGE_CODE` for a language to display. 243 | Note: you cannot change this cookie name. 244 | * `garnett.selector.session`: Checks for a session key `GARNETT_LANGUAGE_CODE` for a language to display. 245 | Note: you cannot change this key name. 246 | * `garnett.selector.header`: Checks for a HTTP Header called `X-Garnett-Language-Code` for a language to display. 247 | Note: you cannot change this Header name. 248 | * `garnett.selector.browser`: Uses Django's `get_language` function to get the users browser/UI language [as determined by Django][django-how]. 249 | * For example, if you only want to check headers and cookies in that order, set this to `['garnett.selectors.header', 'garnett.selectors.cookie']`. 250 | * default: `['garnett.selectors.header', 'garnett.selectors.query', 'garnett.selectors.cookie']` 251 | * `GARNETT_QUERY_PARAMETER_NAME`: 252 | * The query parameter used to determine the language requested by a user during a HTTP request. 253 | * default: `glang` 254 | * `GARNETT_ALLOW_BLANK_FALLBACK_OVERRIDE` 255 | * If set to true, when allpying the current language middleware, this will check for an extra get URL parameter to override the fallback to return blank strings where a languge has no content. This is useful for APIs 256 | * default: False 257 | 258 | Advanced Settings (you probably don't need to adjust these) 259 | * `GARNETT_TRANSLATABLE_FIELDS_PROPERTY_NAME`: 260 | * Garnett adds a property to all models that returns a list of all TranslatableFields. By default, this is 'translatable_fields', but you can customise it here if you want. 261 | * default: `translatable_fields` 262 | * `GARNETT_TRANSLATIONS_PROPERTY_NAME`: 263 | * Garnett adds a property to all models that returns a dictionary of all translations of all TranslatableFields. By default, this is 'translations', but you can customise it here if you want. 264 | * default: `translations` 265 | 266 | # Using Garnett 267 | 268 | If you did everything above correctly, garnett should for the most part "just work". 269 | 270 | ## Switching the active language 271 | 272 | Garnett comes with a handy context manager that can be used to specify the current language. In any place where you want to manually control the current language, wrap your code in `set_field_language` and garnett will correctly store the language. This can be nested, or you can change the language for a context multiple times before saving. 273 | 274 | ```python 275 | from garnett.context import set_field_language 276 | greeting = Greeting(text="Hello", target="World") 277 | 278 | with set_field_language("en"): 279 | greeting.text = "Hello" 280 | with set_field_language("fr"): 281 | greeting.text = "Bonjour" 282 | 283 | greeting.save() 284 | ``` 285 | 286 | ## Using Garnett with `values_list` or `values` 287 | 288 | This is one of the areas that garnett _doesn't_ work immediately, but there is a solution. 289 | 290 | In the places you are using values lists or `values`, wrap any translated field in an L-expression and the values list will return correctly. For example: 291 | 292 | ```python 293 | from garnett.expressions import L 294 | Book.objects.values_list(L("title")) 295 | Book.objects.values(L("title")) 296 | ``` 297 | 298 | 299 | ## Using Garnett with Django-Rest-Framework 300 | 301 | As `TranslationField`s are based on JSONField, by default Django-Rest-Framework renders these as a JSONField, which may not be ideal. 302 | 303 | You can get around this by using the `TranslatableSerializerMixin` _as the first mixin_, which adds the necessary hooks to your serializer. This will mean class changes, but you won't need to update or override every field. 304 | 305 | For example: 306 | 307 | ``` 308 | from rest_framework import serializers 309 | from library_app import models 310 | from garnett.ext.drf import TranslatableSerializerMixin 311 | 312 | 313 | class BookSerializer(TranslatableSerializerMixin, serializers.ModelSerializer): 314 | class Meta: 315 | model = models.Book 316 | fields = "__all__" 317 | ``` 318 | 319 | This will allow you to set the value for a translatable as either a string for the active langauge, or by setting a dictionary that has all languages to be saved (note: this will override the existing language set). 320 | For example: 321 | 322 | To override just the active language: 323 | 324 | curl -X PATCH ... -d "{ \"title\": \"Hello\"}" 325 | 326 | To specifically override a single language (for example, Klingon): 327 | 328 | curl -X PATCH ... -H "X-Garnett-Language-Code: tlh" -d "{ \"title\": \"Hello\"}" 329 | 330 | To override all languages: 331 | 332 | curl -X PATCH ... -d "{ \"title\": {\"en\": \"Hello\", \"fr\": \"Bonjour\"}}" 333 | 334 | ## Using Garnett with django-reversion and django-reversion-compare 335 | 336 | There are a few minor tweaks required to get Garnett to operate properly with 337 | django-reversion and django-reversion-compare based on how they serialise and display data. 338 | 339 | This is because Garnett does not use the same 'field.attname' and 'field.name' which means serialization in Django will not work correctly. 340 | 341 | To get django-reversion to work you will need to use a translation-aware serialiser and apply a patch to ensure that django-reversion-compare can show the right information. 342 | 343 | An example json translation-aware serializer is included with Garnett and this can be applied with the following two settings in `settings.py`: 344 | 345 | ``` 346 | # In settings.py 347 | 348 | GARNETT_PATCH_REVERSION_COMPARE = True 349 | SERIALIZATION_MODULES = {"json": "garnett.serializers.json"} 350 | ``` 351 | 352 | TranslatedFields will list the history and changes in json, but it does do comparisons correctly. 353 | 354 | ## Why call it Garnett? 355 | 356 | * Libraries need a good name. 357 | * Searching for "Famous Translators" will tell you about [Constance Garnett](https://en.wikipedia.org/wiki/Constance_Garnett). 358 | * Searching for "Django Garnett" showed there was no python library with this name. 359 | * It did however talk about [Garnet Clark](https://en.wikipedia.org/wiki/Garnet_Clark) (also spelled Garnett), a jazz pianist who played with Django Reinhart - the namesake of the Django Web Framework. 360 | * Voila - a nice name 361 | 362 | ## Warnings 363 | 364 | * `contains` acts like `icontains` on SQLite only when doing a contains query 365 | it does a case insensitive search. I don't know why - https://www.youtube.com/watch?v=PgGNWRtceag 366 | * Due to how django sets admin form fields you will not get the admin specific widgets like 367 | `AdminTextAreaWidget` on translated fields in the django admin site by default. They can however 368 | be specified explicitly on the corresponding admin model form. 369 | 370 | ## Want to help maintain this library? 371 | 372 | There is a `/dev/` directory with a docker-compose stack you can ues to bring up a database and clean development environment. 373 | 374 | ## Want other options? 375 | 376 | There are a few good options for adding translatable strings to Django that may meet other use cases. We've included a few other options here, their strengths and why we didn't go with them. 377 | 378 | * [django-modeltranslation](https://github.com/deschler/django-modeltranslation) 379 | 380 | **Pros:** this library lets you apply translations to external apps, without altering their models. 381 | 382 | **Cons:** each translation adds an extra column, which means languages are specified in code, and can't be altered by users later. 383 | * [django-translated-fields](https://github.com/matthiask/django-translated-fields) 384 | 385 | **Pros:** uses a great context processor for switching lanugages (which is where we got the idea for ours). 386 | 387 | **Cons:** Languages are specified in django-settings, and can't be altered by users later. 388 | * [django-parler](https://github.com/django-parler/django-parler) 389 | 390 | **Pros:** Django admin site support. 391 | 392 | **Cons:** Languages are stored in a separate table and can't be altered by users later. Translated fields are specified in model meta, away from the fields definition which makes complex lookups harder. 393 | 394 | 395 | 396 | [term-language-code]: https://docs.djangoproject.com/en/3.1/topics/i18n/#term-language-code 397 | [django-how]: https://docs.djangoproject.com/en/3.1/topics/i18n/translation/#how-django-discovers-language-preference 398 | [dunder-html]: https://code.djangoproject.com/ticket/7261 -------------------------------------------------------------------------------- /dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | # Install python package management tools 4 | RUN pip install --upgrade setuptools pip poetry tox 5 | 6 | COPY ./* /usr/src/app/ 7 | WORKDIR /usr/src/app 8 | 9 | RUN poetry config virtualenvs.create false \ 10 | && poetry lock \ 11 | && poetry install --no-root 12 | 13 | ENV PYTHONPATH=/usr/src/app 14 | 15 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | * Start the dev environment (including Postgres & MariaDB databases) with: 2 | 3 | ``` 4 | docker-compose up 5 | ``` 6 | 7 | * After you have started the dev stack, start the tests with: 8 | 9 | ``` 10 | docker-compose exec dev tox 11 | ``` 12 | 13 | * Relock with 14 | 15 | ``` 16 | docker-compose run dev poetry relock 17 | ``` 18 | 19 | * Testing using script and tox (so you don't have to remember the db urls): 20 | 21 | 22 | -e is the tox environment to run - eg. dj-31, dj-32 or dj-40 23 | -PSM are optional flags to run for Postgres, SQLite, MariaDB 24 | ``` 25 | ./dev/local-test.sh -e dj-31 -PSM 26 | ``` 27 | 28 | * Reformat the file using black command: 29 | 30 | Run `black .` at the root directory of the project 31 | 32 | * Testing against a specific database: 33 | 34 | DATABASE_URL=postgres://postgres:changeme@postgres_db/postgres tox -e dj-51 -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This is a very simple docker-compose file 2 | # to make testing different databases easier on a local machine 3 | version: '3' 4 | 5 | services: 6 | maria_db: 7 | image: mariadb:10 8 | environment: 9 | - MYSQL_ROOT_PASSWORD=changeme 10 | postgres_db: 11 | build: 12 | context: ./pg 13 | dockerfile: Dockerfile 14 | environment: 15 | - POSTGRES_PASSWORD=changeme 16 | 17 | dev: 18 | build: 19 | context: .. 20 | dockerfile: ./dev/Dockerfile 21 | environment: 22 | - PYTHONPATH=/usr/src/app:/usr/src/app/tests/ 23 | - DJANGO_SETTINGS_MODULE=library_app.settings 24 | # env_file: 25 | # - default.env 26 | # - .env 27 | ports: 28 | - "8001:8001" 29 | depends_on: 30 | # - maria_db 31 | - postgres_db 32 | volumes: 33 | - ..:/usr/src/app 34 | command: 35 | tail -f /dev/null 36 | -------------------------------------------------------------------------------- /dev/local-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A little script to run on the different dev databases 3 | RUN_PG=0 4 | RUN_SQLITE=0 5 | RUN_MARIA=0 6 | 7 | while getopts e:PSM flag 8 | do 9 | case "${flag}" in 10 | e) TOX_ENV=${OPTARG};; 11 | P) RUN_PG=1;; 12 | S) RUN_SQLITE=1;; 13 | M) RUN_MARIA=1;; 14 | esac 15 | done 16 | 17 | if [ $RUN_SQLITE -eq 1 ] 18 | then 19 | echo "Testing with SQLLite" 20 | tox -e ${TOX_ENV} # Sqlite 21 | fi 22 | 23 | if [ $RUN_MARIA -eq 1 ] 24 | then 25 | echo "Testing with MariaDB" 26 | DATABASE_URL=mysql://root:@maria_db:13306/test tox -e ${TOX_ENV} 27 | fi 28 | 29 | if [ $RUN_PG -eq 1 ] 30 | then 31 | echo "Testing with Postgres" 32 | DATABASE_URL=postgres://postgres:changeme@postgres_db:5432/test tox -e ${TOX_ENV} # Sqlite 33 | fi 34 | 35 | -------------------------------------------------------------------------------- /dev/pg/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:13 2 | COPY install-extensions.sql /docker-entrypoint-initdb.d -------------------------------------------------------------------------------- /dev/pg/install-extensions.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 2 | -------------------------------------------------------------------------------- /garnett/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "garnett.apps.AppConfig" 2 | -------------------------------------------------------------------------------- /garnett/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppConfig(AppConfig): 5 | name = "garnett" 6 | 7 | def ready(self): 8 | from django.conf import settings 9 | 10 | if getattr(settings, "GARNETT_PATCH_REVERSION_COMPARE", False): 11 | from garnett.ext.reversion import patch_compare 12 | 13 | patch_compare() 14 | 15 | if getattr(settings, "GARNETT_PATCH_DJANGO_FILTERS", False): 16 | from garnett.ext.filters import patch_filters 17 | 18 | patch_filters() 19 | -------------------------------------------------------------------------------- /garnett/context.py: -------------------------------------------------------------------------------- 1 | from contextlib import ContextDecorator 2 | import contextvars 3 | from langcodes import Language 4 | 5 | # Internal context var should be set via set_field_language and get via get_current_language 6 | _ctx_language = contextvars.ContextVar("garnett_language") 7 | _ctx_force_blank = contextvars.ContextVar("garnett_language_blank") 8 | 9 | 10 | class set_field_language(ContextDecorator): 11 | def __init__(self, language, force_blank=False): 12 | if isinstance(language, Language): 13 | self.language = language 14 | else: 15 | self.language = Language.get(language) 16 | self.token = None 17 | self.token_blank = None 18 | self.force_blank = force_blank 19 | 20 | def __enter__(self): 21 | self.token = _ctx_language.set(self.language) 22 | self.token_blank = _ctx_force_blank.set(self.force_blank) 23 | 24 | def __exit__(self, exc_type, exc_value, traceback): 25 | _ctx_language.reset(self.token) 26 | _ctx_force_blank.reset(self.token_blank) 27 | -------------------------------------------------------------------------------- /garnett/context_processors.py: -------------------------------------------------------------------------------- 1 | from .utils import get_languages, get_current_language 2 | 3 | 4 | def languages(request): 5 | return { 6 | "garnett_languages": get_languages(), 7 | "garnett_current_language": get_current_language(), 8 | } 9 | -------------------------------------------------------------------------------- /garnett/exceptions.py: -------------------------------------------------------------------------------- 1 | class LanguageStructureError(Exception): 2 | """ 3 | A language structure isn't a dictionary 4 | """ 5 | 6 | pass 7 | 8 | 9 | class LanguageContentError(Exception): 10 | """ 11 | A language in a language dictionary has non-string content 12 | """ 13 | 14 | pass 15 | 16 | 17 | class LanguageSelectionError(Exception): 18 | """ 19 | A language in a language dictionary has non-string content 20 | """ 21 | 22 | pass 23 | -------------------------------------------------------------------------------- /garnett/expressions.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | from django.db.models.fields.json import KeyTextTransform 3 | 4 | from garnett.utils import get_current_language_code 5 | from garnett.fields import TranslatedField 6 | 7 | 8 | # Based on: https://code.djangoproject.com/ticket/29769#comment:5 9 | # Updated comment here 10 | # https://code.djangoproject.com/ticket/31639 11 | class LangF(F): 12 | def resolve_expression(self, *args, **kwargs): 13 | rhs = super().resolve_expression(*args, **kwargs) 14 | if isinstance(rhs.field, TranslatedField): 15 | field_list = self.name.split("__") 16 | # TODO: should this always lookup lang 17 | if len(field_list) == 1: 18 | # Lookup current lang for one field 19 | field_list.extend([get_current_language_code()]) 20 | for name in field_list[1:]: 21 | # Perform key lookups along path 22 | rhs = KeyTextTransform(name, rhs) 23 | return rhs 24 | 25 | 26 | # TODO: should this just inherit from LangF or do we want one without reference lookups 27 | class L(KeyTextTransform): 28 | """Expression to return the current language""" 29 | 30 | def __init__(self, *args, **kwargs): 31 | super().__init__(get_current_language_code(), *args, **kwargs) 32 | -------------------------------------------------------------------------------- /garnett/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/garnett/ext/__init__.py -------------------------------------------------------------------------------- /garnett/ext/drf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper methods for working with django-rest-framework 3 | """ 4 | 5 | from enum import Enum 6 | from rest_framework.fields import JSONField, empty 7 | from rest_framework import serializers 8 | 9 | from garnett.fields import TranslatedField 10 | 11 | 12 | class LanguageTypes(Enum): 13 | monolingual = 1 14 | multilingual = 2 15 | 16 | 17 | class TranslatableAPIField(JSONField): 18 | def __init__(self, *args, **kwargs): 19 | self.innerfield = kwargs.pop("innerfield", None) 20 | super().__init__(*args, **kwargs) 21 | 22 | @property 23 | def validators(self): 24 | if not hasattr(self, "_validators"): 25 | self._validators = self.get_validators() 26 | 27 | if self.translation_type == LanguageTypes.monolingual: 28 | return self.innerfield.validators 29 | else: 30 | return self._validators 31 | 32 | @validators.setter 33 | def validators(self, validators): 34 | self._validators = validators 35 | 36 | def run_validation(self, data=empty): 37 | (is_empty_value, data) = self.validate_empty_values(data) 38 | if is_empty_value: 39 | return data 40 | value = self.to_internal_value(data) 41 | 42 | if isinstance(data, str): 43 | self.translation_type = LanguageTypes.monolingual 44 | elif isinstance(data, dict): 45 | self.translation_type = LanguageTypes.multilingual 46 | else: 47 | # This branch shouldn't occur, but we'll let it pass. 48 | # This will get caught in the main validators. 49 | self.translation_type = LanguageTypes.multilingual 50 | self.run_validators(value) 51 | return value 52 | 53 | 54 | translatable_serializer_field_mapping = {TranslatedField: TranslatableAPIField} 55 | translatable_serializer_field_mapping.update( 56 | serializers.ModelSerializer.serializer_field_mapping.copy() 57 | ) 58 | 59 | 60 | class TranslatableSerializerMixin: 61 | serializer_field_mapping = translatable_serializer_field_mapping 62 | 63 | def build_standard_field(self, field_name, model_field): 64 | field_class, field_kwargs = super().build_standard_field( 65 | field_name, model_field 66 | ) 67 | if field_class is TranslatableAPIField: 68 | field_kwargs["innerfield"] = model_field.field 69 | return field_class, field_kwargs 70 | -------------------------------------------------------------------------------- /garnett/ext/filters.py: -------------------------------------------------------------------------------- 1 | # Patches for django-filters 2 | """ 3 | This has been added as django-filters is used by multiple libraries, 4 | including DRF and Djgnao-Graphene. 5 | """ 6 | 7 | from django_filters.filterset import CharFilter 8 | from garnett.fields import TranslatedField 9 | 10 | 11 | class TranslatedCharFilter(CharFilter): 12 | pass 13 | 14 | 15 | def patch_filters(): 16 | from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS 17 | 18 | FILTER_FOR_DBFIELD_DEFAULTS.update( 19 | {TranslatedField: {"filter_class": TranslatedCharFilter}} 20 | ) 21 | 22 | from django_filters.rest_framework.filterset import FILTER_FOR_DBFIELD_DEFAULTS 23 | 24 | FILTER_FOR_DBFIELD_DEFAULTS.update( 25 | {TranslatedField: {"filter_class": TranslatedCharFilter}} 26 | ) 27 | -------------------------------------------------------------------------------- /garnett/ext/reversion.py: -------------------------------------------------------------------------------- 1 | from garnett.fields import TranslatedField 2 | 3 | 4 | def patch_compare(): 5 | from reversion_compare.compare import CompareObjects as COBase 6 | 7 | class CompareObjects(COBase): 8 | def __init__(self, field, field_name, obj, version1, version2, is_reversed): 9 | if isinstance(field, TranslatedField): 10 | field_name = field.attname 11 | super().__init__(field, field_name, obj, version1, version2, is_reversed) 12 | 13 | # Import this as late as possible as we are monkey patching over it 14 | import reversion_compare.compare 15 | 16 | reversion_compare.compare.CompareObjects = CompareObjects 17 | -------------------------------------------------------------------------------- /garnett/fields.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import exceptions 3 | from django.db.models import JSONField 4 | from django.db.models.fields.json import KeyTransform 5 | from dataclasses import make_dataclass 6 | from functools import partial 7 | import logging 8 | from typing import Callable, Dict, Union 9 | 10 | from garnett.translatedstr import TranslatedStr, VerboseTranslatedStr 11 | from garnett.utils import ( 12 | get_current_language_code, 13 | get_property_name, 14 | get_languages, 15 | is_valid_language, 16 | normalise_language_codes, 17 | ) 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def innerfield_validator_factory(innerfield) -> callable: 23 | def validator(values: dict): 24 | from garnett import exceptions as e 25 | 26 | if not isinstance(values, dict): 27 | raise e.LanguageStructureError( 28 | "Invalid value assigned to translatable field" 29 | ) 30 | 31 | # Run validators on sub field 32 | errors = [] 33 | for code, value in values.items(): 34 | # Check language codes 35 | if not isinstance(value, str): 36 | raise exceptions.ValidationError(f'Invalid value for language "{code}"') 37 | if not is_valid_language(code): 38 | raise exceptions.ValidationError( 39 | f'"{code}" is not a valid language code' 40 | ) 41 | 42 | for v in innerfield.validators: 43 | try: 44 | v(value) 45 | except exceptions.ValidationError as e: 46 | errors.extend(e.error_list) 47 | if errors: 48 | raise exceptions.ValidationError(errors) 49 | 50 | return validator 51 | 52 | 53 | def translatable_default( 54 | inner_default: Union[str, Callable[[], str]] 55 | ) -> Dict[str, str]: 56 | """Return default from inner field as dict with current language""" 57 | lang = get_current_language_code() 58 | if callable(inner_default): 59 | return {lang: inner_default()} 60 | 61 | return {lang: inner_default} 62 | 63 | 64 | class TranslatedField(JSONField): 65 | """Translated text field that mirrors the behaviour of another text field 66 | 67 | All arguments except fallback can be provided on the inner field 68 | """ 69 | 70 | def __init__(self, field, *args, fallback=None, **kwargs): 71 | self.field = field 72 | self._fallback = fallback 73 | 74 | if type(fallback) is type and issubclass(fallback, TranslatedStr): 75 | self.fallback = fallback 76 | elif callable(fallback): 77 | self.fallback = partial(TranslatedStr, fallback=fallback) 78 | else: 79 | self.fallback = VerboseTranslatedStr 80 | 81 | # Move some args to outer field 82 | outer_args = [ 83 | "db_column", 84 | "db_index", 85 | "db_tablespace", 86 | "help_text", 87 | "verbose_name", 88 | ] 89 | inner_kwargs = self.field.deconstruct()[3] 90 | for arg_name in outer_args: 91 | if arg_name in inner_kwargs: 92 | kwargs[arg_name] = inner_kwargs[arg_name] 93 | 94 | # Create default for outer field based on inner field 95 | if "default" not in kwargs and "default" in inner_kwargs: 96 | # Use partial because it is serializable in django migrations 97 | kwargs["default"] = partial(translatable_default, inner_kwargs["default"]) 98 | 99 | super().__init__(*args, **kwargs) 100 | self.validators.append(innerfield_validator_factory(self.field)) 101 | 102 | def formfield(self, **kwargs): 103 | # We need to bypass the JSONField implementation 104 | return self.field.formfield(**kwargs) 105 | 106 | def get_prep_value(self, value): 107 | if hasattr(value, "items"): 108 | value = { 109 | lang_code: self.field.get_prep_value(text) 110 | for lang_code, text in value.items() 111 | } 112 | elif type(value) == str: 113 | value = self.field.get_prep_value(value) 114 | return value 115 | return super().get_prep_value(value) 116 | 117 | def get_db_prep_save(self, value, connection): 118 | """This ensures that any custom get_db_prep_save() method in self.field can be triggered""" 119 | if hasattr(value, "items"): 120 | value = { 121 | lang_code: self.field.get_db_prep_save(text, connection) 122 | for lang_code, text in value.items() 123 | } 124 | elif type(value) == str: 125 | value = self.field.get_db_prep_save(value, connection) 126 | return value 127 | return super().get_db_prep_save(value, connection) 128 | 129 | def from_db_value(self, value, expression, connection): 130 | value = super().from_db_value(value, expression, connection) 131 | if hasattr(self.field, "from_db_value"): 132 | value = { 133 | k: self.field.from_db_value(v, expression, connection) 134 | for k, v in value.items() 135 | } 136 | return value 137 | 138 | def value_from_object(self, obj): 139 | """Return the value of this field in the given model instance.""" 140 | all_ts = getattr(obj, self.ts_name) 141 | if type(all_ts) is not dict: 142 | logger.warning( 143 | "Displaying an untranslatable field - model:{} (pk:{}), field:{}".format( 144 | type(obj), obj.pk, self.name 145 | ) 146 | ) 147 | return str(all_ts) 148 | 149 | language = get_current_language_code() 150 | return all_ts.get(language, None) 151 | 152 | def translations_from_object(self, obj): 153 | """Return the value of this field in the given model instance.""" 154 | return getattr(obj, self.ts_name) 155 | 156 | def get_attname(self): 157 | # Use field with _tsall as the attribute name on the object 158 | # We've overrided this as this is usually the name of the attribute used to populate the database 159 | return self.name + "_tsall" 160 | 161 | def get_attname_column(self): 162 | attname = self.get_attname() 163 | # Use name without _tsall as the column name 164 | column = self.db_column or self.name 165 | return attname, column 166 | 167 | @property 168 | def ts_name(self): 169 | return f"{self.name}_tsall" 170 | 171 | def contribute_to_class(self, cls, name, private_only=False): 172 | super().contribute_to_class(cls, name, private_only) 173 | 174 | # We use `ego` to differentiate scope here as this is the inner self 175 | # Maybe its not necessary, but it is funny. 176 | 177 | @property 178 | def translator(ego): 179 | return self.fallback(getattr(ego, self.ts_name)) 180 | 181 | @translator.setter 182 | def translator(ego, value): 183 | """Setter for main field (without _tsall)""" 184 | all_ts = getattr(ego, self.ts_name) 185 | if not all_ts: 186 | # This is probably the first save through 187 | all_ts = {} 188 | elif type(all_ts) is not dict: 189 | logger.warning( 190 | "Saving a broken field - model:{} (pk:{}), field:{}".format( 191 | type(ego), ego.pk, name 192 | ) 193 | ) 194 | logger.debug("Field data was - {}".format(all_ts)) 195 | all_ts = {} 196 | 197 | if isinstance(value, str): 198 | language_code = get_current_language_code() 199 | all_ts[language_code] = value 200 | elif value is None: 201 | language_code = get_current_language_code() 202 | all_ts[language_code] = "" 203 | elif isinstance(value, dict): 204 | # normalise all language codes 205 | all_ts = normalise_language_codes(value) 206 | else: 207 | bad_type = type(value) 208 | raise TypeError( 209 | f"Invalid type assigned to translatable field - {bad_type}" 210 | ) 211 | 212 | setattr(ego, self.ts_name, all_ts) 213 | 214 | setattr(cls, f"{name}", translator) 215 | 216 | # This can probably be cached on the class 217 | @property 218 | def translatable_fields(ego): 219 | return [ 220 | field 221 | for field in ego._meta.get_fields() 222 | if isinstance(field, TranslatedField) 223 | ] 224 | 225 | propname = getattr( 226 | settings, "GARNETT_TRANSLATABLE_FIELDS_PROPERTY_NAME", "translatable_fields" 227 | ) 228 | setattr(cls, propname, translatable_fields) 229 | 230 | @property 231 | def translations(ego): 232 | translatable_fields = ego.translatable_fields 233 | Translations = make_dataclass( 234 | "Translations", [(f.name, dict) for f in translatable_fields] 235 | ) 236 | kwargs = {} 237 | for field in translatable_fields: 238 | kwargs[field.name] = getattr(ego, field.ts_name) 239 | return Translations(**kwargs) 240 | 241 | setattr(cls, get_property_name(), translations) 242 | 243 | @property 244 | def available_languages(ego): 245 | """Returns a list of codes available on the whole model""" 246 | langs = set() 247 | for field in ego.translatable_fields: 248 | langs |= getattr(ego, field.ts_name, {}).keys() 249 | return [lang for lang in get_languages() if lang.to_tag() in langs] 250 | 251 | setattr(cls, "available_languages", available_languages) 252 | 253 | def get_transform(self, name): 254 | # Call back to the Field get_transform 255 | transform = super(JSONField, self).get_transform(name) 256 | if transform: 257 | return transform 258 | # Use our new factory 259 | return TranslatedKeyTransformFactory(name) 260 | 261 | def deconstruct(self): 262 | name, path, args, kwargs = super().deconstruct() 263 | args.insert(0, self.field) 264 | kwargs["fallback"] = self._fallback 265 | return name, path, args, kwargs 266 | 267 | 268 | class TranslatedKeyTransform(KeyTransform): 269 | """Key transform for translate fields 270 | 271 | so we can register lookups on this without affecting the regular json field 272 | """ 273 | 274 | 275 | class TranslatedKeyTransformFactory: 276 | def __init__(self, key_name): 277 | self.key_name = key_name 278 | 279 | def __call__(self, *args, **kwargs): 280 | return TranslatedKeyTransform(self.key_name, *args, **kwargs) 281 | 282 | 283 | # Shorter name for the class 284 | Translated = TranslatedField 285 | 286 | 287 | # Import lookups here so that they are registered by just importing the field 288 | from garnett import lookups # noqa: F401, E402 289 | -------------------------------------------------------------------------------- /garnett/lookups.py: -------------------------------------------------------------------------------- 1 | from django.db.models import lookups 2 | from django.db.models.fields import json, CharField 3 | from django.db.models.functions import Cast 4 | from django.db.models.fields.json import KeyTextTransform 5 | from django.contrib.postgres.lookups import SearchLookup, TrigramSimilar 6 | from django.contrib.postgres.search import TrigramSimilarity 7 | 8 | from garnett.fields import TranslatedField, TranslatedKeyTransform 9 | from garnett.utils import get_current_language 10 | 11 | 12 | # We duplicate and process_lhs and process_rhs to be certain these are 13 | # Actually called during tests. 14 | # Otherwise, the blank classes appear to give 100% coverage 15 | 16 | 17 | @TranslatedField.register_lookup 18 | class HasLang(json.HasKey): 19 | lookup_name = "has_lang" 20 | 21 | def process_lhs(self, compiler, connection): 22 | return super().process_lhs(compiler, connection) 23 | 24 | def process_rhs(self, compiler, connection): 25 | return super().process_rhs(compiler, connection) 26 | 27 | 28 | @TranslatedField.register_lookup 29 | class HasLangs(json.HasKeys): 30 | lookup_name = "has_langs" 31 | 32 | def process_lhs(self, compiler, connection): 33 | return super().process_lhs(compiler, connection) 34 | 35 | def process_rhs(self, compiler, connection): 36 | return super().process_rhs(compiler, connection) 37 | 38 | 39 | @TranslatedField.register_lookup 40 | class HasAnyLangs(json.HasAnyKeys): 41 | lookup_name = "has_any_langs" 42 | 43 | def process_lhs(self, compiler, connection): 44 | return super().process_lhs(compiler, connection) 45 | 46 | def process_rhs(self, compiler, connection): 47 | return super().process_rhs(compiler, connection) 48 | 49 | 50 | # Override default lookups on our field to handle language lookups 51 | 52 | 53 | class CurrentLanguageMixin: 54 | """Mixin to perform language lookup on lhs""" 55 | 56 | def __init__(self, lhs, *args, **kwargs): 57 | tlhs = json.KeyTransform( 58 | str(get_current_language()), 59 | lhs, 60 | ) 61 | super().__init__(tlhs, *args, **kwargs) 62 | 63 | 64 | @TranslatedField.register_lookup 65 | class BaseLanguageExact( 66 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.Exact 67 | ): 68 | # Note: On some database engines lookup_name actually has an effect on the result 69 | # (See lookup_cast in the django postgres backend) 70 | lookup_name = "exact" 71 | prepare_rhs = False 72 | 73 | def process_lhs(self, compiler, connection): 74 | return super().process_lhs(compiler, connection) 75 | 76 | def process_rhs(self, compiler, connection): 77 | return super().process_rhs(compiler, connection) 78 | 79 | 80 | @TranslatedField.register_lookup 81 | class BaseLanguageIExact(CurrentLanguageMixin, json.KeyTransformIExact): 82 | lookup_name = "iexact" 83 | 84 | def process_lhs(self, compiler, connection): 85 | return super().process_lhs(compiler, connection) 86 | 87 | def process_rhs(self, compiler, connection): 88 | return super().process_rhs(compiler, connection) 89 | 90 | 91 | @TranslatedField.register_lookup 92 | class BaseLanguageIContains(CurrentLanguageMixin, json.KeyTransformIContains): 93 | def process_lhs(self, compiler, connection): 94 | return super().process_lhs(compiler, connection) 95 | 96 | def process_rhs(self, compiler, connection): 97 | return super().process_rhs(compiler, connection) 98 | 99 | 100 | @TranslatedField.register_lookup 101 | class BaseLanguageContains( 102 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.Contains 103 | ): 104 | # Override the default json field contains which is not a text contains 105 | # https://docs.djangoproject.com/en/3.1/topics/db/queries/#contains 106 | lookup_name = "contains" 107 | 108 | def process_lhs(self, compiler, connection): 109 | return super().process_lhs(compiler, connection) 110 | 111 | def process_rhs(self, compiler, connection): 112 | return super().process_rhs(compiler, connection) 113 | 114 | 115 | # Override contains lookup for after a key lookup i.e. title__en__contains="thing" 116 | @TranslatedKeyTransform.register_lookup 117 | class KeyTransformContains(json.KeyTransformTextLookupMixin, lookups.Contains): 118 | lookup_name = "contains" 119 | 120 | def process_lhs(self, compiler, connection): 121 | return super().process_lhs(compiler, connection) 122 | 123 | def process_rhs(self, compiler, connection): 124 | return super().process_rhs(compiler, connection) 125 | 126 | 127 | @TranslatedKeyTransform.register_lookup 128 | class KeyTransformExact(json.KeyTransformTextLookupMixin, lookups.Exact): 129 | def process_lhs(self, compiler, connection): 130 | self.lhs = Cast(self.lhs, CharField()) 131 | return super().process_lhs(compiler, connection) 132 | 133 | def process_rhs(self, compiler, connection): 134 | return super().process_rhs(compiler, connection) 135 | 136 | 137 | @TranslatedKeyTransform.register_lookup 138 | class KeyTransformGreaterThan(json.KeyTransformTextLookupMixin, lookups.GreaterThan): 139 | def process_lhs(self, compiler, connection): 140 | self.lhs = Cast(self.lhs, CharField()) 141 | return super().process_lhs(compiler, connection) 142 | 143 | def process_rhs(self, compiler, connection): 144 | return super().process_rhs(compiler, connection) 145 | 146 | 147 | @TranslatedKeyTransform.register_lookup 148 | class KeyTransformGreaterThanOrEqual( 149 | json.KeyTransformTextLookupMixin, lookups.GreaterThanOrEqual 150 | ): 151 | def process_lhs(self, compiler, connection): 152 | self.lhs = Cast(self.lhs, CharField()) 153 | return super().process_lhs(compiler, connection) 154 | 155 | def process_rhs(self, compiler, connection): 156 | return super().process_rhs(compiler, connection) 157 | 158 | 159 | @TranslatedKeyTransform.register_lookup 160 | class KeyTransformLessThan(json.KeyTransformTextLookupMixin, lookups.LessThan): 161 | def process_lhs(self, compiler, connection): 162 | self.lhs = Cast(self.lhs, CharField()) 163 | return super().process_lhs(compiler, connection) 164 | 165 | def process_rhs(self, compiler, connection): 166 | return super().process_rhs(compiler, connection) 167 | 168 | 169 | @TranslatedKeyTransform.register_lookup 170 | class KeyTransformLesshanOrEqual( 171 | json.KeyTransformTextLookupMixin, lookups.LessThanOrEqual 172 | ): 173 | def process_lhs(self, compiler, connection): 174 | self.lhs = Cast(self.lhs, CharField()) 175 | return super().process_lhs(compiler, connection) 176 | 177 | def process_rhs(self, compiler, connection): 178 | return super().process_rhs(compiler, connection) 179 | 180 | 181 | @TranslatedField.register_lookup 182 | class BaseLanguageStartsWith(CurrentLanguageMixin, json.KeyTransformStartsWith): 183 | def process_lhs(self, compiler, connection): 184 | return super().process_lhs(compiler, connection) 185 | 186 | def process_rhs(self, compiler, connection): 187 | return super().process_rhs(compiler, connection) 188 | 189 | 190 | @TranslatedField.register_lookup 191 | class BaseLanguageIStartsWith(CurrentLanguageMixin, json.KeyTransformIStartsWith): 192 | def process_lhs(self, compiler, connection): 193 | return super().process_lhs(compiler, connection) 194 | 195 | def process_rhs(self, compiler, connection): 196 | return super().process_rhs(compiler, connection) 197 | 198 | 199 | @TranslatedField.register_lookup 200 | class BaseLanguageEndsWith(CurrentLanguageMixin, json.KeyTransformEndsWith): 201 | def process_lhs(self, compiler, connection): 202 | return super().process_lhs(compiler, connection) 203 | 204 | def process_rhs(self, compiler, connection): 205 | return super().process_rhs(compiler, connection) 206 | 207 | 208 | @TranslatedField.register_lookup 209 | class BaseLanguageIEndsWith(CurrentLanguageMixin, json.KeyTransformIEndsWith): 210 | def process_lhs(self, compiler, connection): 211 | return super().process_lhs(compiler, connection) 212 | 213 | def process_rhs(self, compiler, connection): 214 | return super().process_rhs(compiler, connection) 215 | 216 | 217 | @TranslatedField.register_lookup 218 | class BaseLanguageRegex(CurrentLanguageMixin, json.KeyTransformRegex): 219 | def process_lhs(self, compiler, connection): 220 | return super().process_lhs(compiler, connection) 221 | 222 | def process_rhs(self, compiler, connection): 223 | return super().process_rhs(compiler, connection) 224 | 225 | 226 | @TranslatedField.register_lookup 227 | class BaseLanguageIRegex(CurrentLanguageMixin, json.KeyTransformIRegex): 228 | def process_lhs(self, compiler, connection): 229 | return super().process_lhs(compiler, connection) 230 | 231 | def process_rhs(self, compiler, connection): 232 | return super().process_rhs(compiler, connection) 233 | 234 | 235 | @TranslatedField.register_lookup 236 | class BaseLanguageGreaterThan( 237 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.GreaterThan 238 | ): 239 | def process_lhs(self, compiler, connection): 240 | self.lhs = Cast(self.lhs, CharField()) 241 | return super().process_lhs(compiler, connection) 242 | 243 | def process_rhs(self, compiler, connection): 244 | return super().process_rhs(compiler, connection) 245 | 246 | 247 | @TranslatedField.register_lookup 248 | class BaseLanguageGreaterThanOrEqual( 249 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.GreaterThanOrEqual 250 | ): 251 | def process_lhs(self, compiler, connection): 252 | self.lhs = Cast(self.lhs, CharField()) 253 | return super().process_lhs(compiler, connection) 254 | 255 | def process_rhs(self, compiler, connection): 256 | return super().process_rhs(compiler, connection) 257 | 258 | 259 | @TranslatedField.register_lookup 260 | class BaseLanguageLessThan( 261 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.LessThan 262 | ): 263 | def process_lhs(self, compiler, connection): 264 | self.lhs = Cast(self.lhs, CharField()) 265 | return super().process_lhs(compiler, connection) 266 | 267 | def process_rhs(self, compiler, connection): 268 | return super().process_rhs(compiler, connection) 269 | 270 | 271 | @TranslatedField.register_lookup 272 | class BaseLanguageLessThanOrEqual( 273 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, lookups.LessThanOrEqual 274 | ): 275 | def process_lhs(self, compiler, connection): 276 | self.lhs = Cast(self.lhs, CharField()) 277 | return super().process_lhs(compiler, connection) 278 | 279 | def process_rhs(self, compiler, connection): 280 | return super().process_rhs(compiler, connection) 281 | 282 | 283 | @TranslatedField.register_lookup 284 | class BaseLanguageSearch( 285 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, SearchLookup 286 | ): 287 | def process_lhs(self, compiler, connection): 288 | return super().process_lhs(compiler, connection) 289 | 290 | def process_rhs(self, compiler, connection): 291 | return super().process_rhs(compiler, connection) 292 | 293 | 294 | # --- Postgres only functions --- 295 | 296 | 297 | @TranslatedField.register_lookup 298 | class BaseLanguageTrigramSimilar( 299 | CurrentLanguageMixin, json.KeyTransformTextLookupMixin, TrigramSimilar 300 | ): 301 | def process_lhs(self, compiler, connection): 302 | return super().process_lhs(compiler, connection) 303 | 304 | def process_rhs(self, compiler, connection): 305 | return super().process_rhs(compiler, connection) 306 | 307 | 308 | class LangTrigramSimilarity(TrigramSimilarity): 309 | # There is no way we can calculate if a field is a language or not 310 | # So we have to write our own function here 311 | # We also need to cast the JSONB field to a string 312 | def __init__(self, expression, string, **extra): 313 | lang = str(get_current_language()) 314 | expression = KeyTextTransform(lang, expression) 315 | super().__init__(expression, string, **extra) 316 | -------------------------------------------------------------------------------- /garnett/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import Http404 3 | from django.utils.module_loading import import_string 4 | from django.utils.translation import gettext as _ 5 | 6 | import logging 7 | import langcodes 8 | from langcodes import Language 9 | 10 | from .utils import is_valid_language, get_default_language 11 | from .context import set_field_language 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class TranslationContextMiddleware: 17 | """ 18 | This middleware catches the requested "garnett language" and: 19 | * sets a garnett language attribute on the request 20 | * defines a context variable that is used when reading or altering fields 21 | """ 22 | 23 | def __init__(self, get_response): 24 | self.get_response = get_response 25 | 26 | def validate(self, language): 27 | """Validate the language raising http errors if invalid""" 28 | return None 29 | 30 | def __call__(self, request): 31 | request.garnett_language = get_language_from_request(request) 32 | request.garnett_fallback_blank = False 33 | if getattr(settings, "GARNETT_ALLOW_BLANK_FALLBACK_OVERRIDE", False): 34 | request.garnett_fallback_blank = bool(request.GET.get("gblank", False)) 35 | elif paths := getattr( 36 | settings, "GARNETT_FORCE_BLANK_FALLBACK_OVERRIDE_PATHS", [] 37 | ): 38 | for path in paths: 39 | if request.path.startswith(path): 40 | request.garnett_fallback_blank = True 41 | break 42 | 43 | self.validate(request.garnett_language) 44 | with set_field_language( 45 | request.garnett_language, force_blank=request.garnett_fallback_blank 46 | ): 47 | response = self.get_response(request) 48 | return response 49 | 50 | 51 | class TranslationContextNotFoundMiddleware(TranslationContextMiddleware): 52 | """ 53 | This middleware catches the requested "garnett language" and: 54 | * sets a garnett language attribute on the request 55 | * defines a context variable that is used when reading or altering fields 56 | * will raise a 404 if the request language is not in languages list 57 | """ 58 | 59 | def validate(self, language): 60 | if not is_valid_language(language): 61 | lang_obj = language 62 | lang_name = lang_obj.display_name(language) 63 | lang_en_name = lang_obj.display_name() 64 | raise Http404( 65 | _("This server does not support %(lang_name)s" " [%(lang_en_name)s].") 66 | % { 67 | "lang_name": lang_name, 68 | "lang_en_name": lang_en_name, 69 | } 70 | ) 71 | 72 | 73 | class TranslationCacheMiddleware: 74 | """Middleware to cache the garnett language in the users session storage 75 | 76 | This must be after one of the above middlewares and after the session middleware 77 | """ 78 | 79 | def __init__(self, get_response): 80 | self.get_response = get_response 81 | 82 | def __call__(self, request): 83 | if hasattr(request, "garnett_language") and hasattr(request, "session"): 84 | request.session["GARNETT_LANGUAGE_CODE"] = request.garnett_language 85 | else: 86 | logger.error( 87 | "TranslationCacheMiddleware must come after main garnett middleware " 88 | "and the session middleware." 89 | ) 90 | 91 | return self.get_response(request) 92 | 93 | 94 | def get_language_from_request(request) -> Language: 95 | opt_order = getattr( 96 | settings, 97 | "GARNETT_REQUEST_LANGUAGE_SELECTORS", 98 | [ 99 | "garnett.selectors.header", 100 | "garnett.selectors.query", 101 | "garnett.selectors.cookie", 102 | ], 103 | ) 104 | for opt in opt_order: 105 | func = import_string(opt) 106 | if lang := func(request): 107 | try: 108 | return Language.get(lang) 109 | except langcodes.tag_parser.LanguageTagError: 110 | raise Http404( 111 | _( 112 | "The provided language %(lang_code)s is not a valid language code" 113 | ) 114 | % { 115 | "lang_code": lang, 116 | } 117 | ) 118 | 119 | return get_default_language() 120 | -------------------------------------------------------------------------------- /garnett/migrate.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import pickle 4 | from django.apps.registry import Apps 5 | from django.db.migrations import RunPython 6 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 7 | from garnett.utils import get_current_language_code 8 | from typing import Callable, Dict, List 9 | 10 | 11 | """ 12 | These methods convert a string to and from a safe base64 encoded version of text. 13 | This helps with database encoding 14 | """ 15 | 16 | 17 | def safe_encode(value: str) -> str: 18 | return base64.b64encode(pickle.dumps(value)).decode("ascii") 19 | 20 | 21 | def safe_decode(value: str) -> str: 22 | return pickle.loads(base64.urlsafe_b64decode(value)) 23 | 24 | 25 | def _get_migrate_function( 26 | app_label: str, 27 | model_fields: Dict[str, List[str]], 28 | update: Callable[[str, str], str], 29 | ts_all: bool = False, 30 | ) -> Callable[[Apps, BaseDatabaseSchemaEditor], None]: 31 | """Generate a migration function given an update function for each value 32 | 33 | update is a function taking current language and old value and returning a new value 34 | """ 35 | 36 | def migrate(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: 37 | current_lang = get_current_language_code() 38 | 39 | for model_name, fields in model_fields.items(): 40 | updated = [] 41 | model = apps.get_model(app_label, model_name) 42 | for item in model.objects.all(): 43 | for field_name in fields: 44 | # Set new value retrieved from update function 45 | if ts_all: 46 | field_name = f"{field_name}_tsall" 47 | value = getattr(item, field_name) 48 | setattr(item, field_name, update(current_lang, value)) 49 | updated.append(item) 50 | 51 | # Bulk update only the required fields 52 | model.objects.bulk_update(updated, fields) 53 | 54 | return migrate 55 | 56 | 57 | # Part 1 58 | 59 | 60 | def update_safe_encode_content_forwards(current_lang: str, value: str) -> str: 61 | value = safe_encode(value) 62 | return json.dumps({current_lang: value}) 63 | 64 | 65 | def update_safe_encode_content_backwards(current_lang: str, value: str) -> str: 66 | value = json.loads(value) 67 | return safe_decode(value.get(current_lang, safe_encode(""))) 68 | 69 | 70 | def step_1_safe_encode_content( 71 | app_label: str, model_fields: Dict[str, List[str]] 72 | ) -> RunPython: 73 | """Generate a migration operation to safely store text content prior to field migration 74 | This is needed as standard json is not escaped according to Postgres (and maybe other database) requirements. 75 | For example, Postgres requires single quotes (') to be double escaped: ('') 76 | For more info see here: https://stackoverflow.com/questions/35677204/psql-insert-json-with-double-quotes-inside-strings 77 | 78 | Args: 79 | app_label: label for django app the migration is in 80 | model_fields: mapping of model name to list of text fields to migrate 81 | 82 | Returns: 83 | RunPython migration operation 84 | """ 85 | 86 | return RunPython( 87 | code=_get_migrate_function( 88 | app_label, model_fields, update_safe_encode_content_forwards 89 | ), 90 | reverse_code=_get_migrate_function( 91 | app_label, model_fields, update_safe_encode_content_backwards, ts_all=True 92 | ), 93 | ) 94 | 95 | 96 | # Part 2 97 | 98 | 99 | def update_safe_prepare_translations_forwards(current_lang: str, value: dict) -> dict: 100 | value = safe_decode(value.get(current_lang, safe_encode(""))) 101 | return {current_lang: value} 102 | 103 | 104 | def update_safe_prepare_translations_backwards(current_lang: str, value: dict) -> dict: 105 | value = safe_encode(value.get(current_lang, "")) 106 | return {current_lang: value} 107 | 108 | 109 | def step_2_safe_prepare_translations( 110 | app_label: str, model_fields: Dict[str, List[str]] 111 | ) -> RunPython: 112 | """Generate a migration operation to prepare text fields for being translated 113 | 114 | Args: 115 | app_label: label for django app the migration is in 116 | model_fields: mapping of model name to list of text fields to migrate 117 | 118 | Returns: 119 | RunPython migration operation 120 | """ 121 | 122 | return RunPython( 123 | code=_get_migrate_function( 124 | app_label, 125 | model_fields, 126 | update_safe_prepare_translations_forwards, 127 | ts_all=True, 128 | ), 129 | reverse_code=_get_migrate_function( 130 | app_label, 131 | model_fields, 132 | update_safe_prepare_translations_backwards, 133 | ts_all=True, 134 | ), 135 | ) 136 | -------------------------------------------------------------------------------- /garnett/mixins.py: -------------------------------------------------------------------------------- 1 | from django.db.models.query import BaseIterable 2 | from garnett.expressions import L 3 | 4 | PREFIX = "L_garnett__" 5 | 6 | 7 | class TranslatableValuesIterable(BaseIterable): 8 | """ 9 | Unwind the modifications that are made before calling .values() 10 | Iterable returned by QuerySet.values() that yields a dict for each row. 11 | """ 12 | 13 | def clean_garnett_field(self, field_name) -> str: 14 | """Return the field name minus the prefix""" 15 | return field_name.replace(PREFIX, "") 16 | 17 | def __iter__(self): 18 | queryset = self.queryset 19 | query = queryset.query 20 | compiler = query.get_compiler(queryset.db) 21 | 22 | # extra(select=...) cols are always at the start of the row. 23 | names = [ 24 | *query.extra_select, 25 | *query.values_select, 26 | *query.annotation_select, 27 | ] 28 | indexes = range(len(names)) 29 | for row in compiler.results_iter( 30 | chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size 31 | ): 32 | yield {self.clean_garnett_field(names[i]): row[i] for i in indexes} 33 | 34 | 35 | class TranslatedQuerySetMixin: 36 | """ 37 | A translated QuerySet mixin to add extra functionality to translated fields 38 | Must be mixedin to a QuerySet 39 | """ 40 | 41 | def values(self, *fields, **expressions): 42 | """ 43 | .values() for translatable fields 44 | Still expects values to be passed with L() 45 | """ 46 | 47 | # Convert anything that is an L from a field to an expression - so it treats it as an expression 48 | # rather than a field. 49 | # We will clean the field prefix in our custom iterable class "TranslatableQuerySetMixin" 50 | cleaned_fields = [] 51 | for field in fields: 52 | if isinstance(field, L): 53 | expressions.update( 54 | {f"{PREFIX}{field.source_expressions[0].name}": field} 55 | ) 56 | else: 57 | cleaned_fields.append(field) 58 | 59 | clone = super().values(*cleaned_fields, **expressions) 60 | clone._iterable_class = TranslatableValuesIterable 61 | 62 | return clone 63 | -------------------------------------------------------------------------------- /garnett/patch.py: -------------------------------------------------------------------------------- 1 | from contextlib import ContextDecorator 2 | from django.core.exceptions import FieldError 3 | from django.db.models.sql import query 4 | from dataclasses import dataclass 5 | from typing import Any 6 | import functools 7 | 8 | from garnett.fields import TranslatedField 9 | from garnett.utils import get_current_language 10 | 11 | 12 | # Save current join info 13 | _JoinInfo = query.JoinInfo 14 | 15 | 16 | @dataclass 17 | class JoinInfo: 18 | final_field: Any 19 | targets: Any 20 | opts: Any 21 | joins: Any 22 | path: Any 23 | transform_function_func: Any 24 | 25 | @property 26 | def transform_function(self): 27 | if isinstance(self.final_field, TranslatedField): 28 | # If it's a partial, it must have had a transformer applied - leave it alone! 29 | if isinstance(self.transform_function_func, functools.partial): 30 | return self.transform_function_func 31 | 32 | name = get_current_language() 33 | 34 | # Cloned in from django 35 | def transform(field, alias, *, name, previous): 36 | try: 37 | wrapped = previous(field, alias) 38 | return self.try_transform(wrapped, name) 39 | except FieldError: 40 | # TODO: figure out how to handle this case as we don't have 41 | # final_field or last_field_exception 42 | 43 | # FieldError is raised if the transform doesn't exist. 44 | # if isinstance(final_field, Field) and last_field_exception: 45 | # raise last_field_exception 46 | # else: 47 | # raise 48 | raise 49 | 50 | # ------------------- 51 | 52 | return functools.partial( 53 | transform, name=name, previous=self.transform_function_func 54 | ) 55 | 56 | return self.transform_function_func 57 | 58 | def try_transform(self, lhs, name): 59 | # Cloned in from django 60 | import difflib 61 | 62 | """ 63 | Helper method for build_lookup(). Try to fetch and initialize 64 | a transform for name parameter from lhs. 65 | """ 66 | transform_class = lhs.get_transform(name) 67 | if transform_class: 68 | return transform_class(lhs) 69 | else: 70 | output_field = lhs.output_field.__class__ 71 | suggested_lookups = difflib.get_close_matches( 72 | name, output_field.get_lookups() 73 | ) 74 | if suggested_lookups: 75 | suggestion = ", perhaps you meant %s?" % " or ".join(suggested_lookups) 76 | else: 77 | suggestion = "." 78 | raise FieldError( 79 | "Unsupported lookup '%s' for %s or join on the field not " 80 | "permitted%s" % (name, output_field.__name__, suggestion) 81 | ) 82 | 83 | def __iter__(self): 84 | # Necessary to mimic a tuple 85 | for x in [ 86 | self.final_field, 87 | self.targets, 88 | self.opts, 89 | self.joins, 90 | self.path, 91 | self.transform_function, 92 | ]: 93 | yield x 94 | 95 | 96 | def apply_patches(): 97 | """Apply monkey patches to django""" 98 | # This is needed to allow values/values_list and F lookups to work 99 | # The most dangerous monkey patch of all time 100 | query.JoinInfo = JoinInfo 101 | 102 | 103 | def revert_patches(): 104 | """Revert monkey patches to django""" 105 | query.JoinInfo = _JoinInfo 106 | 107 | 108 | class patch_lookups(ContextDecorator): 109 | def __enter__(self): 110 | apply_patches() 111 | 112 | def __exit__(self, exc_type, exc_value, traceback): 113 | revert_patches() 114 | -------------------------------------------------------------------------------- /garnett/selectors.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import get_language 2 | 3 | from garnett.utils import lang_param 4 | 5 | 6 | def query(request): 7 | return request.GET.get(lang_param(), None) 8 | 9 | 10 | def cookie(request): 11 | return request.COOKIES.get("GARNETT_LANGUAGE_CODE", None) 12 | 13 | 14 | def session(request): 15 | return request.session.get("GARNETT_LANGUAGE_CODE", None) 16 | 17 | 18 | def header(request): 19 | return request.META.get("HTTP_X_GARNETT_LANGUAGE_CODE", None) 20 | 21 | 22 | def browser(request): 23 | return get_language() 24 | -------------------------------------------------------------------------------- /garnett/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/garnett/serializers/__init__.py -------------------------------------------------------------------------------- /garnett/serializers/base.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers.base import Serializer as BaseSerializer 2 | from django.utils.encoding import is_protected_type 3 | from garnett.fields import TranslatedField 4 | 5 | 6 | def fetch_item_and_convert_to_generator(queryset): 7 | # This method exists so we can get the first item of an iterable 8 | # (which can include a generator), and then still iterate through the entire 9 | # set later. 10 | # This ensures that if we have a queryset we only fetch it once, 11 | # or run through the generator once. 12 | inner_iterable = iter(queryset) 13 | 14 | # Get the first item so we can do introspection 15 | item = next(inner_iterable) 16 | 17 | # Make a generator that returns the first item, then the rest of the iterable 18 | def inner_generator(): 19 | yield item 20 | yield from inner_iterable 21 | 22 | return item, inner_generator() 23 | 24 | 25 | class TranslatableSerializer(BaseSerializer): 26 | def serialize( 27 | self, 28 | queryset, 29 | *, 30 | stream=None, 31 | fields=None, 32 | use_natural_foreign_keys=False, 33 | use_natural_primary_keys=False, 34 | progress_output=None, 35 | object_count=0, 36 | **options 37 | ): 38 | if fields is not None: 39 | item, queryset = fetch_item_and_convert_to_generator(queryset) 40 | selected_fields = [] 41 | for f in item._meta.fields: 42 | if f.name in fields: 43 | if isinstance(f, TranslatedField): 44 | selected_fields.append(f.attname) 45 | else: 46 | selected_fields.append(f.name) 47 | fields = selected_fields 48 | 49 | return super().serialize( 50 | queryset, 51 | stream=stream, 52 | fields=fields, 53 | use_natural_foreign_keys=use_natural_foreign_keys, 54 | use_natural_primary_keys=use_natural_primary_keys, 55 | progress_output=progress_output, 56 | object_count=object_count, 57 | **options 58 | ) 59 | 60 | def _value_from_field(self, obj, field): 61 | value = field.value_from_object(obj) 62 | # Protected types (i.e., primitives like None, numbers, dates, 63 | # and Decimals) are passed through as is. All other values are 64 | # converted to string first. 65 | if isinstance(field, TranslatedField): 66 | return field.translations_from_object(obj) 67 | return value if is_protected_type(value) else field.value_to_string(obj) 68 | -------------------------------------------------------------------------------- /garnett/serializers/json.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers import json 2 | from garnett.serializers.base import TranslatableSerializer 3 | 4 | 5 | class Serializer(TranslatableSerializer, json.Serializer): 6 | pass 7 | 8 | 9 | def Deserializer(stream_or_string, **options): 10 | yield from json.Deserializer(stream_or_string, **options) 11 | -------------------------------------------------------------------------------- /garnett/templates/admin/change_form_object_tools.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 | {% load garnett %} 3 | 4 | {% block object-tools-items %} 5 |
  • 6 | {% for lang in garnett_languages %} 7 | {{ lang.to_tag }} 14 | {% endfor %} 15 |
  • 16 | 17 |
  • 18 | {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} 19 | {% translate "History" %} 20 |
  • 21 | {% if has_absolute_url %}
  • {% translate "View on site" %}
  • {% endif %} 22 | {% endblock %} -------------------------------------------------------------------------------- /garnett/templates/garnett/language_selector.html: -------------------------------------------------------------------------------- 1 | {% load garnett %} 2 | 3 |
    4 | Current language: 5 | {{ garnett_current_language.display_name }} 6 |
    7 | Change Language: 8 | {% for lang in garnett_languages %} 9 | | 10 | {% if lang == garnett_current_language %} 11 | {{ lang|language_display }} 12 | {% else %} 13 | {% if selector == "cookie" %} 14 | 18 | {% else %} 19 | {{ lang|language_display }} 22 | {% endif %} 23 | {% endif %} 24 | {% endfor %} 25 |
    -------------------------------------------------------------------------------- /garnett/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/garnett/templatetags/__init__.py -------------------------------------------------------------------------------- /garnett/templatetags/garnett.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from garnett.utils import lang_param 3 | from langcodes import Language 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag(takes_context=True) 9 | def switch_page_language(context, language_code): 10 | # http://stackoverflow.com/questions/2047622/how-to-paginate-django-with-other-get-variables 11 | request = context["request"] 12 | dict_ = request.GET.copy() 13 | dict_[lang_param()] = language_code 14 | get_params = dict_.urlencode() 15 | return f"{request.path}?{get_params}" 16 | 17 | 18 | @register.filter 19 | def language_display(language, display_language=None): 20 | if type(language) is str: 21 | language = Language.get(language) 22 | if display_language is None: 23 | return language.display_name() 24 | return language.display_name(display_language) 25 | 26 | 27 | @register.inclusion_tag("garnett/language_selector.html", takes_context=True) 28 | def language_selector(context, selector): 29 | context.update({"selector": selector}) 30 | return { 31 | "selector": selector, 32 | "request": context["request"], 33 | "garnett_languages": context["garnett_languages"], 34 | "garnett_current_language": context["garnett_current_language"], 35 | } 36 | -------------------------------------------------------------------------------- /garnett/translatedstr.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Optional, Callable 2 | from django.utils.translation import gettext as _ 3 | 4 | from langcodes import Language 5 | from garnett.utils import ( 6 | codes_to_langs, 7 | get_current_language, 8 | get_current_language_code, 9 | get_current_blank_override, 10 | get_languages, 11 | ) 12 | from garnett import exceptions as e 13 | 14 | 15 | class HTMLTranslationMixin: 16 | def __html__(self) -> str: 17 | # Add leading [lang] wrapped in a span 18 | text = self 19 | if not self.is_fallback: 20 | return text 21 | elif not self.fallback_language: 22 | return "??" 23 | else: 24 | return ( 25 | '" 28 | "[{lang}] " 29 | "{s}" 30 | ).format(s=self, lang=self.fallback_language.to_tag()) 31 | 32 | 33 | class TranslatedStr(str): 34 | """ 35 | A translated string subclasses string and allows us to attach more information about 36 | how a string was generated and the language of the string. 37 | """ 38 | 39 | def __new__(cls, content, fallback: Callable = None): 40 | try: 41 | current_language_code = get_current_language_code() 42 | has_current_language = current_language_code in content.keys() 43 | except (AttributeError, TypeError): 44 | raise e.LanguageStructureError 45 | blank_override = get_current_blank_override() 46 | 47 | if has_current_language: 48 | fallback_language = None 49 | text = content.get(current_language_code) 50 | else: 51 | if blank_override: 52 | return "" 53 | elif fallback: 54 | fallback_language, text = fallback(content) 55 | else: 56 | fallback_language, text = cls.get_fallback_text(content) 57 | 58 | instance = super().__new__(cls, text) 59 | instance.content = content 60 | instance.translations = codes_to_langs(content) 61 | instance.is_fallback = not has_current_language 62 | instance.fallback_language = fallback_language 63 | return instance 64 | 65 | @classmethod 66 | def get_fallback_text(cls, content) -> Tuple[Optional[Language], str]: 67 | return None, "" 68 | 69 | # TODO: Implmement the above logic in __str__ 70 | # def __str__(self): 71 | # return self 72 | 73 | 74 | class VerboseTranslatedStr(TranslatedStr): 75 | """ 76 | A translated string that gives information if a string isn't present. 77 | """ 78 | 79 | @classmethod 80 | def get_fallback_text(cls, content): 81 | """Default fallback function that returns an error message""" 82 | language = get_current_language() 83 | lang_name = language.display_name(language) 84 | lang_en_name = language.display_name() 85 | 86 | return language, content.get( 87 | language.to_tag(), 88 | _( 89 | "No translation of this field available in %(lang_name)s" 90 | " [%(lang_en_name)s]." 91 | ) 92 | % { 93 | "lang_name": lang_name, 94 | "lang_en_name": lang_en_name, 95 | }, 96 | ) 97 | 98 | 99 | class NextTranslatedStr(TranslatedStr, HTMLTranslationMixin): 100 | """ 101 | A translated string that falls back based on the order of preferred languages in the app. 102 | """ 103 | 104 | @classmethod 105 | def get_fallback_text(cls, content): 106 | """Fallback that checks each language consecutively""" 107 | for lang in get_languages(): 108 | if lang.to_tag() in content: 109 | return lang, content[lang.to_tag()] 110 | 111 | return None, "" 112 | -------------------------------------------------------------------------------- /garnett/utils.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional 2 | 3 | import langcodes.tag_parser 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from langcodes import Language 7 | 8 | from garnett.context import _ctx_language, _ctx_force_blank 9 | 10 | 11 | def lang_param(): 12 | return getattr(settings, "GARNETT_QUERY_PARAMETER_NAME", "glang") 13 | 14 | 15 | def get_default_language(): 16 | setting = getattr(settings, "GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE", "en-AU") 17 | if callable(setting): 18 | default = setting() 19 | else: 20 | default = setting 21 | 22 | if isinstance(default, Language): 23 | return default 24 | elif isinstance(default, str): 25 | return Language.get(default) 26 | else: 27 | raise ImproperlyConfigured( 28 | "GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE must be a string or callable that returns a string or `Language` object" 29 | ) 30 | 31 | 32 | def codes_to_langs(content: dict) -> dict: 33 | return {Language.get(lang): text for lang, text in content.items()} 34 | 35 | 36 | def get_property_name() -> str: 37 | return getattr(settings, "GARNETT_TRANSLATIONS_PROPERTY_NAME", "translations") 38 | 39 | 40 | def normalise_language_code(langcode): 41 | return Language.get(langcode).to_tag() 42 | 43 | 44 | def normalise_language_codes(value): 45 | return {normalise_language_code(lang): val for lang, val in value.items()} 46 | 47 | 48 | def is_valid_language(language: Union[str, Language]) -> bool: 49 | if isinstance(language, Language): 50 | language = language 51 | if isinstance(language, str): 52 | try: 53 | language = Language.get(language) 54 | except langcodes.tag_parser.LanguageTagError: 55 | return False 56 | return language in get_languages() 57 | 58 | 59 | def get_current_language() -> Language: 60 | lang = _ctx_language.get(None) 61 | if not lang: 62 | return get_default_language() 63 | return lang 64 | 65 | 66 | def get_current_language_code() -> str: 67 | return get_current_language().to_tag() 68 | 69 | 70 | def get_current_blank_override() -> bool: 71 | return _ctx_force_blank.get(False) 72 | 73 | 74 | def get_safe_language(lang_code: str) -> Optional[Language]: 75 | """Return language if language for lang code exists, otherwise none""" 76 | try: 77 | return Language.get(lang_code) 78 | except langcodes.tag_parser.LanguageTagError: 79 | return None 80 | 81 | 82 | def validate_language_list(langs) -> List[Language]: 83 | """ 84 | Validate and clean a potential list of languages. 85 | This may return an empty list if the provided languages are invalid 86 | """ 87 | if type(langs) is not list: 88 | return [] 89 | 90 | languages = [] 91 | for lang_code in langs: 92 | if language := get_safe_language(lang_code): 93 | languages.append(language) 94 | 95 | if languages: 96 | return languages 97 | 98 | 99 | def get_languages() -> List[Language]: 100 | langs = getattr( 101 | settings, "GARNETT_TRANSLATABLE_LANGUAGES", [get_default_language()] 102 | ) 103 | if callable(langs): 104 | langs = langs() 105 | 106 | languages = validate_language_list(langs) 107 | 108 | if not languages: 109 | raise ImproperlyConfigured( 110 | "GARNETT_TRANSLATABLE_LANGUAGES must be a list of languages or a callable that returns a list of languages" 111 | ) 112 | return languages 113 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-garnett" 3 | version = "0.5.3" 4 | description = "Simple translatable Django fields" 5 | authors = ["Aristotle Metadata Enterprises"] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | packages = [ 9 | {include = "garnett"} 10 | ] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.12" 14 | django = ">=4.1" 15 | langcodes = "~3.3.0" 16 | language-data = "~1.0.1" 17 | 18 | [tool.poetry.dev-dependencies] 19 | black = "~20.8b1" 20 | coverage = "~5.3.1" 21 | dj-database-url = "~0.5.0" 22 | psycopg2 = "~2.8.6" 23 | mock = "~4.0.3" 24 | djangorestframework = "~3.15" 25 | drf_yasg = ">=1.20.0" 26 | django-reversion = ">=5.1.0" 27 | django-reversion-compare = ">=0.18.1" 28 | django-filter = ">=21.1" 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.black] 35 | exclude = ''' 36 | ^/tests/library_app/migrations/* # exclude test app migrations 37 | ''' 38 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export PYTHONPATH=$PYTHONPATH:/app:/app/tests/:/usr/src/app:/usr/src/app/tests/ 4 | export DJANGO_SETTINGS_MODULE=library_app.settings 5 | 6 | django-admin migrate 7 | 8 | python ./tests/library_app/utils.py 9 | 10 | django-admin runserver 0.0.0.0:$PORT -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.12 -------------------------------------------------------------------------------- /tests/library_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/tests/library_app/__init__.py -------------------------------------------------------------------------------- /tests/library_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from reversion_compare.admin import CompareVersionAdmin 3 | from library_app.models import Book 4 | import reversion 5 | 6 | reversion.register(Book) 7 | 8 | 9 | class BookAdmin(CompareVersionAdmin): 10 | pass 11 | 12 | 13 | admin.site.register(Book, BookAdmin) 14 | -------------------------------------------------------------------------------- /tests/library_app/api/generators.py: -------------------------------------------------------------------------------- 1 | from drf_yasg.generators import OpenAPISchemaGenerator 2 | 3 | 4 | class LibrarySchemaGenerator(OpenAPISchemaGenerator): 5 | """ 6 | This is required due to how the api urls are setup 7 | and should be removed in future 8 | """ 9 | 10 | prefix = "/api" 11 | 12 | def get_endpoints(self, request): 13 | endpoints = super().get_endpoints(request) 14 | fixed_endpoints = {} 15 | for key in endpoints.keys(): 16 | new_key = self.prefix + key 17 | fixed_endpoints[new_key] = endpoints[key] 18 | 19 | return fixed_endpoints 20 | -------------------------------------------------------------------------------- /tests/library_app/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from library_app import models 3 | from garnett.ext.drf import TranslatableSerializerMixin 4 | 5 | 6 | class BookSerializer(TranslatableSerializerMixin, serializers.ModelSerializer): 7 | class Meta: 8 | model = models.Book 9 | fields = "__all__" 10 | -------------------------------------------------------------------------------- /tests/library_app/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views, generators 3 | 4 | from rest_framework import permissions 5 | from drf_yasg import openapi 6 | from drf_yasg.views import get_schema_view 7 | 8 | 9 | app_name = "library_app_api" 10 | 11 | schema_view = get_schema_view( 12 | openapi.Info( 13 | title="Library API", 14 | default_version="v1", 15 | description="Library API", 16 | license=openapi.License(name="BSD License"), 17 | ), 18 | generator_class=generators.LibrarySchemaGenerator, 19 | public=True, 20 | permission_classes=(permissions.AllowAny,), 21 | urlconf="library_app.api.urls", 22 | ) 23 | 24 | 25 | urlpatterns = [ 26 | path("", schema_view.with_ui("swagger", cache_timeout=0), name="schema"), 27 | path("book", views.ListCreateBookAPIView.as_view(), name="list_create_book"), 28 | path( 29 | "book/", 30 | views.RetrieveUpdateBookAPIView.as_view(), 31 | name="retrieve_update_book", 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /tests/library_app/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly 2 | from rest_framework import generics 3 | from django.http import Http404 4 | from django.shortcuts import get_object_or_404 5 | from library_app.models import Book 6 | from library_app.api.serializers import BookSerializer 7 | from django_filters.rest_framework import DjangoFilterBackend 8 | 9 | 10 | class ListCreateBookAPIView(generics.ListCreateAPIView): 11 | """The base implementation of the list and create view""" 12 | 13 | permission_classes = (IsAuthenticatedOrReadOnly,) 14 | serializer_class = BookSerializer 15 | filter_backends = [DjangoFilterBackend] 16 | filterset_fields = ["title", "author"] 17 | 18 | def get_queryset(self): 19 | return Book.objects.all() 20 | 21 | 22 | class RetrieveUpdateBookAPIView(generics.RetrieveUpdateAPIView): 23 | """The base implementation of the retrieve and update view""" 24 | 25 | permission_classes = (IsAuthenticated,) 26 | serializer_class = BookSerializer 27 | 28 | def get_queryset(self): 29 | return Book.objects.all() 30 | 31 | def get_object(self) -> Book: 32 | queryset = self.get_queryset() 33 | 34 | # Lookup by uuid or id depending on url parameters 35 | filters = {} 36 | identifier = self.kwargs["item_id"] 37 | if identifier.isdigit(): 38 | filters["id"] = identifier 39 | else: 40 | raise Http404 41 | 42 | obj = get_object_or_404(queryset, **filters) 43 | 44 | # Check object against permission classes 45 | # May raise a permission denied 46 | self.check_object_permissions(self.request, obj) 47 | 48 | return obj 49 | -------------------------------------------------------------------------------- /tests/library_app/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from garnett.mixins import TranslatedQuerySetMixin 3 | 4 | 5 | class BookQuerySet(TranslatedQuerySetMixin, QuerySet): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/library_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2022-01-11 10:10 2 | 3 | from django.db import migrations, models 4 | from django.contrib.postgres.operations import TrigramExtension 5 | import functools 6 | import garnett.fields 7 | import library_app.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Book", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("number_of_pages", models.PositiveIntegerField()), 30 | ( 31 | "title", 32 | models.CharField( 33 | max_length=250, 34 | validators=[library_app.models.validate_length], 35 | help_text="The name for a book. (Multilingal field)", 36 | ), 37 | ), 38 | ( 39 | "author", 40 | models.TextField( 41 | default="Anon", 42 | help_text="The name of the person who wrote the book (Single language field)", 43 | ), 44 | ), 45 | ( 46 | "description", 47 | models.TextField( 48 | help_text="Short details about a book. (Multilingal field)", 49 | ), 50 | ), 51 | ("category", models.JSONField(blank=True, null=True)), 52 | ("other_info", library_app.models.CustomTestingField(blank=True, default='')) 53 | ], 54 | ), 55 | migrations.CreateModel( 56 | name="DefaultBook", 57 | fields=[ 58 | ( 59 | "id", 60 | models.AutoField( 61 | auto_created=True, 62 | primary_key=True, 63 | serialize=False, 64 | verbose_name="ID", 65 | ), 66 | ), 67 | ("number_of_pages", models.PositiveIntegerField()), 68 | ( 69 | "title", 70 | garnett.fields.TranslatedField( 71 | models.CharField(blank=True, default="DEFAULT TITLE"), 72 | default=functools.partial( 73 | garnett.fields.translatable_default, 74 | *("DEFAULT TITLE",), 75 | **{} 76 | ), 77 | fallback=None, 78 | ), 79 | ), 80 | ( 81 | "author", 82 | garnett.fields.TranslatedField( 83 | models.CharField( 84 | blank=True, default=library_app.models.default_author 85 | ), 86 | default=functools.partial( 87 | garnett.fields.translatable_default, 88 | *(library_app.models.default_author,), 89 | **{} 90 | ), 91 | fallback=None, 92 | ), 93 | ), 94 | ( 95 | "description", 96 | garnett.fields.TranslatedField( 97 | models.CharField(blank=True, default=""), 98 | default=functools.partial( 99 | garnett.fields.translatable_default, *("",), **{} 100 | ), 101 | fallback=None, 102 | ), 103 | ), 104 | ], 105 | ), 106 | TrigramExtension(), 107 | ] 108 | -------------------------------------------------------------------------------- /tests/library_app/migrations/0002_make_translatable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2022-01-11 10:13 2 | 3 | from django.db import migrations, models 4 | import garnett.fields 5 | import library_app.models 6 | from garnett.migrate import step_1_safe_encode_content, step_2_safe_prepare_translations 7 | 8 | 9 | model_fields = { 10 | "book": ["title", "description", "other_info"], 11 | } 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [ 17 | ("library_app", "0001_initial"), 18 | ] 19 | 20 | operations = [ 21 | step_1_safe_encode_content("library_app", model_fields), 22 | migrations.AlterField( 23 | model_name="book", 24 | name="description", 25 | field=garnett.fields.TranslatedField( 26 | models.TextField( 27 | help_text="Short details about a book. (Multilingal field)" 28 | ), 29 | fallback=None, 30 | help_text="Short details about a book. (Multilingal field)", 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="book", 35 | name="title", 36 | field=garnett.fields.TranslatedField( 37 | models.CharField( 38 | max_length=250, validators=[library_app.models.validate_length] 39 | ), 40 | fallback=library_app.models.TitleTranslatedStr, 41 | help_text="The name for a book. (Multilingal field)", 42 | ), 43 | ), 44 | migrations.AlterField( 45 | model_name="book", 46 | name="other_info", 47 | field=garnett.fields.TranslatedField( 48 | library_app.models.CustomTestingField( 49 | blank=True, default='' 50 | ), 51 | fallback=None, 52 | ), 53 | ), 54 | step_2_safe_prepare_translations("library_app", model_fields), 55 | ] 56 | -------------------------------------------------------------------------------- /tests/library_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/tests/library_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/library_app/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from garnett import fields 6 | from garnett.translatedstr import TranslatedStr 7 | from garnett.utils import get_languages, get_current_language 8 | from library_app.managers import BookQuerySet 9 | 10 | 11 | RANDOM_STR = "f6e56ce9-cc87-45ac-8a19-8c34136e6f52" 12 | BLEACH_STR = "string to be replace" 13 | 14 | 15 | class CustomTestingField(models.TextField): 16 | def get_db_prep_value(self, value, connection, prepared=False): 17 | """ 18 | Note: If there is data migration when migrating to TranslatedField, the manually added 19 | step_1_safe_encode_content() function in the migration file will base64 encode the value in the field 20 | before get_db_prep_value is called. 21 | 22 | For example: 23 | old value = "this is a book" 24 | new value = '{"en": "dGhpcyBpcyBhIGJvb2s="}' 25 | 26 | The new value is then parse to the get_db_prep_value() function. 27 | If custom get_db_prep_value() function is used you will need to make sure that the custom get_db_prep_value() function is 28 | not modifying the input value. 29 | 30 | Some examples, 31 | 1. if the custom get_db_prep_value() function append a fix str to all input value: 32 | input value = '{"en": "dGhpcyBpcyBhIGJvb2s="}' 33 | return value = '{"en": "dGhpcyBpcyBhIGJvb2s="}1234567' 34 | this would raise "django.db.utils.DataError: invalid input syntax for type json" because the return value 35 | from the custom get_db_prep_value() function is not valid json 36 | 37 | 2. if the custom get_db_prep_value() function bleach certain substring (e.g dGhpcy) on the input value: 38 | input value = '{"en": "dGhpcyBpcyBhIGJvb2s="}' 39 | return value = '{"en": "BpcyBhIGJvb2s="}' 40 | this would modify the base64 value and decoding the modfied base64 value would return unexpected result 41 | """ 42 | if value is None: 43 | return super().get_db_prep_value(value, connection, prepared) 44 | bleached_value = value.replace(BLEACH_STR, RANDOM_STR) 45 | return super().get_db_prep_value(bleached_value, connection, prepared) 46 | 47 | 48 | def validate_length(value): 49 | if len(value) < 3: 50 | raise ValidationError(_("Title is too short")) 51 | 52 | 53 | class TitleTranslatedStr(TranslatedStr): 54 | """ 55 | A translated string that includes a nice HTML styled fallback in django templates. 56 | """ 57 | 58 | @classmethod 59 | def get_fallback_text(cls, content): 60 | if content.items(): 61 | for lang in get_languages(): 62 | if lang.to_tag() in content: 63 | value = content[lang.to_tag()] 64 | return (lang, f"{value}") 65 | else: 66 | return None, "No translations available for this book" 67 | 68 | def __html__(self) -> str: 69 | # Add leading [lang] wrapped in a span 70 | text = self 71 | if not self.is_fallback: 72 | return text 73 | elif not self.fallback_language: 74 | return "??" 75 | else: 76 | current_lang = get_current_language() 77 | lang = self.fallback_language 78 | return f""" 79 | 83 | [{lang.to_tag()}] 84 | {self} 85 | 86 | """ 87 | 88 | 89 | class Book(models.Model): 90 | objects = BookQuerySet.as_manager() 91 | 92 | number_of_pages = models.PositiveIntegerField() 93 | 94 | title = fields.Translated( 95 | models.CharField(max_length=250, validators=[validate_length]), 96 | fallback=TitleTranslatedStr, 97 | help_text=_("The name for a book. (Multilingal field)"), 98 | ) 99 | 100 | author = models.TextField( 101 | help_text=_( 102 | "The name of the person who wrote the book (Single language field)" 103 | ), 104 | default="Anon", 105 | ) 106 | 107 | description = fields.Translated( 108 | models.TextField(help_text=_("Short details about a book. (Multilingal field)")) 109 | ) 110 | 111 | category = models.JSONField(blank=True, null=True) 112 | 113 | other_info = fields.Translated(CustomTestingField(blank=True, default="")) 114 | 115 | def get_absolute_url(self): 116 | return f"/book/{self.pk}" 117 | 118 | def __str__(self): 119 | return f"Book {self.title}" 120 | 121 | 122 | def default_author(): 123 | return "John Jimson" 124 | 125 | 126 | class DefaultBook(models.Model): 127 | """A model used to test default on inner fields""" 128 | 129 | number_of_pages = models.PositiveIntegerField() 130 | title = fields.Translated(models.CharField(blank=True, default="DEFAULT TITLE")) 131 | author = fields.Translated(models.CharField(blank=True, default=default_author)) 132 | description = fields.Translated(models.CharField(blank=True, default="")) 133 | -------------------------------------------------------------------------------- /tests/library_app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | import dj_database_url 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | GARNETT_TRANSLATABLE_LANGUAGES = ["en", "en-AU", "de", "fr", "sjn", "tlh"] 20 | GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE = "en" 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = "plh(!v%donk%b%a7-_mu2*s*zx-t)4=ptxu*o^&f46v1w%4g#2" 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = ["localhost", "127.0.0.1", "django-garnett.herokuapp.com"] 32 | 33 | SERIALIZATION_MODULES = {"json": "garnett.serializers.json"} 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "garnett", 39 | "reversion", 40 | "reversion_compare", 41 | "django.contrib.admin", 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | "django.contrib.staticfiles", 46 | "django.contrib.messages", 47 | "django.contrib.postgres", # This is only needed for postgres lookups 48 | "django_filters", 49 | "library_app", 50 | "rest_framework", 51 | "drf_yasg", 52 | ] 53 | 54 | MIDDLEWARE = [ 55 | "django.middleware.security.SecurityMiddleware", 56 | "django.contrib.sessions.middleware.SessionMiddleware", 57 | "django.middleware.common.CommonMiddleware", 58 | "django.middleware.csrf.CsrfViewMiddleware", 59 | "django.contrib.auth.middleware.AuthenticationMiddleware", 60 | "django.contrib.messages.middleware.MessageMiddleware", 61 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 62 | "reversion.middleware.RevisionMiddleware", 63 | "garnett.middleware.TranslationContextNotFoundMiddleware", 64 | ] 65 | 66 | ROOT_URLCONF = "library_app.urls" 67 | 68 | TEMPLATES = [ 69 | { 70 | "BACKEND": "django.template.backends.django.DjangoTemplates", 71 | "DIRS": [], 72 | "APP_DIRS": True, 73 | "OPTIONS": { 74 | "context_processors": [ 75 | "django.template.context_processors.debug", 76 | "django.template.context_processors.request", 77 | "django.contrib.auth.context_processors.auth", 78 | "django.contrib.messages.context_processors.messages", 79 | "garnett.context_processors.languages", 80 | ], 81 | }, 82 | }, 83 | ] 84 | 85 | 86 | # Database 87 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 88 | 89 | DATABASES = { 90 | "default": dj_database_url.config( 91 | default="sqlite:///" + str(BASE_DIR / "db.sqlite3") 92 | ) 93 | } 94 | 95 | 96 | # Password validation 97 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 98 | 99 | AUTH_PASSWORD_VALIDATORS = [ 100 | { 101 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 105 | }, 106 | { 107 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 108 | }, 109 | { 110 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 111 | }, 112 | ] 113 | 114 | 115 | # Internationalization 116 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 117 | 118 | LANGUAGE_CODE = "en-us" 119 | 120 | TIME_ZONE = "UTC" 121 | 122 | USE_I18N = True 123 | 124 | USE_L10N = True 125 | 126 | USE_TZ = True 127 | 128 | 129 | # Static files (CSS, JavaScript, Images) 130 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 131 | 132 | STATIC_URL = "/static/" 133 | STATIC_ROOT = Path(BASE_DIR, "staticfiles") 134 | 135 | GARNETT_PATCH_REVERSION_COMPARE = True 136 | GARNETT_PATCH_DJANGO_FILTERS = True 137 | -------------------------------------------------------------------------------- /tests/library_app/templates/library_app/base.html: -------------------------------------------------------------------------------- 1 | {% load garnett %} 2 | 3 | 4 | 5 | 6 | 7 | 20 | 21 | 22 |
    23 | 24 | {% language_selector "cookie" %} 25 | 26 |
    27 |
    28 | 31 | 32 | Made with by 33 | 34 | 37 | Aristotle Metadata 38 | 39 | {% block content %} 40 | {% endblock %} 41 | 42 | 43 | -------------------------------------------------------------------------------- /tests/library_app/templates/library_app/book_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "library_app/base.html" %} 2 | {% load garnett %} 3 | {% block content %} 4 |

    {{ book.title }} (book.title)

    5 |

    By {{book.author}} 6 | (book.author) 7 |

    8 | 9 | 10 | 13 | 16 | 17 | 18 | 21 | 28 | 29 | 30 | 33 | 35 | 36 | 37 | 39 | 52 | 53 | 54 | 57 | 70 | 71 |
    Description: 11 |
    (book.description) 12 |
    14 | {{ book.description }} 15 |
    AKA: 19 |
    (book.title.translations) 20 |
    22 |
      23 | {% for lang, val in book.title.translations.items %} 24 |
    • {{ lang.display_name }}: {{val}}
    • 25 | {% endfor %} 26 |
    27 |
    Pages: 31 |
    (book.number_of_pages) 32 |
    {{ book.number_of_pages }} 34 |
    Extra details: 38 | 43 | 44 | {% for k, v in book.category.items %} 45 | 46 | 47 | 48 | 49 | {% endfor %} 50 |
    {{k}}:{{v}}
    51 |
    This book available in: 55 |
    (book.available_languages) 56 |
    61 |
      62 | {% for lang in book.available_languages %} 63 |
    • 64 | {{ lang.display_name }} 65 |
    • 66 | {% endfor %} 67 |
    68 | 69 |
    72 |

    73 | Regardless of which language is selected the properties shown as code 74 | above show the properties used to populate this django template. 75 |
    76 | Eg. book.title will return the 77 | name of the book in your selected langauge (if it is available). 78 |

    79 | {% endblock %} 80 | -------------------------------------------------------------------------------- /tests/library_app/templates/library_app/book_form.html: -------------------------------------------------------------------------------- 1 | {% extends "library_app/base.html" %} 2 | {% load garnett %} 3 | {% block content %} 4 |
    5 | {% csrf_token %} 6 | 7 | {{ form }} 8 |
    9 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /tests/library_app/templates/library_app/book_history.html: -------------------------------------------------------------------------------- 1 | {% extends "library_app/base.html" %} 2 | {% load garnett %} 3 | {% block content %} 4 | 5 | 19 |
    20 |
    21 | {% include "reversion-compare/compare_partial.html" %} 22 | {% include "reversion-compare/compare_links_partial.html" %} 23 | {% include "reversion-compare/action_list_partial.html" %} 24 |
    25 |
    26 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /tests/library_app/templates/library_app/book_list.html: -------------------------------------------------------------------------------- 1 | {% extends "library_app/base.html" %} 2 | {% load garnett %} 3 | {% block content %} 4 |

    5 | 6 | Garnett: 7 | Multilingual library for Django demonstration 8 |

    9 |

    Garnett is a library that allows you to add multilingual fields 10 | to Django models with very little changes to code. For example, 11 | the Books below have their title accessed in this template with 12 | book.title, but when you change the language the appropriate 13 | language is output. 14 |

    15 |

    Garnett was developed by the Aristotle Metadata development team. 16 | You can checkout the code at Github https://github.com/Aristotle-Metadata-Enterprises/django-garnett, 17 | or you can read more about why we developed it on our blog post: Why we built django-garnett for the Aristotle Metadata Registry. 18 |

    19 |

    20 | 21 | All content in here gets wiped periodically. You can test out creating and editing from the 22 | admin site by logging in with the username admin and the password admin. 23 |

    Here are all the books in our database

    24 |

    25 | Books are sorted alphabetically by their {{ garnett_current_language.display_name }} title. 26 | Where possible, it will show the title of the book in {{ garnett_current_language.display_name }}. 27 |
    28 | If a book doesn't have an {{ garnett_current_language.display_name }} title, 29 | this site has been configured to show the first title in the following 30 | order: 31 |

    32 | {% for l in garnett_languages %} 33 | {% if not forloop.first %}{% if forloop.last %} and {% else %}, {% endif %}{% endif %} 34 | {{ l.display_name }} 35 | {% endfor %} 36 |
    37 |

    38 | 39 | 40 | 41 | 44 | 47 | 50 | 53 | 56 | 57 | 58 | {% for book in object_list %} 59 | 60 | 61 | 66 | 67 | 73 | 75 | 76 | {% empty %} 77 | 78 | {% endfor %} 79 |
    ID 42 |
    book.id 43 |
    Title 45 |
    book.title 46 |
    Author 48 |
    book.author 49 |
    Available languages 51 |
    book.translations 52 |
    Number of pages 54 |
    book.number_of_pages 55 |
    {{ book.id }} 62 | 63 | {{ book.title }} 64 | 65 | {{ book.author }} 68 | {% for l in book.available_languages %} 69 | {% if not forloop.first %}{% if forloop.last %} and {% else %}, {% endif %}{% endif %} 70 | {{ l.display_name }} 71 | {% endfor%} 72 | {{book.number_of_pages}} 74 |
    There are no books.
    80 | {% endblock %} 81 | -------------------------------------------------------------------------------- /tests/library_app/urls.py: -------------------------------------------------------------------------------- 1 | """library_app URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | from library_app import views 20 | 21 | urlpatterns = [ 22 | path("", views.BookListView.as_view()), 23 | path("api/", include("library_app.api.urls", namespace="library_app_api")), 24 | path("admin/", admin.site.urls), 25 | path("book/", views.BookView.as_view()), 26 | path("book//edit", views.BookUpdateView.as_view(), name="update_book"), 27 | path("book//history", views.BookHistoryCompareView.as_view()), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/library_app/utils.py: -------------------------------------------------------------------------------- 1 | def clear_user(): 2 | from django.contrib.auth import get_user_model 3 | 4 | User = get_user_model() 5 | User.objects.all().delete() 6 | 7 | 8 | def load_user(): 9 | from django.contrib.auth import get_user_model 10 | 11 | User = get_user_model() 12 | User.objects.create_superuser("admin", "admin@example.com", "admin") 13 | 14 | 15 | def clear_books(): 16 | from library_app.models import Book 17 | 18 | Book.objects.all().delete() 19 | 20 | 21 | def load_books(): 22 | from library_app.models import Book 23 | 24 | """ 25 | Before you try to point it out, yes you are technically correct 26 | that Dewey Decimal Codes aren't actually numbers. Technically 27 | speaking, they are string codes that happen to be made of digits. 28 | 29 | You are very clever for pointing this out, but to test that our 30 | JSON lookups for translatables don't overload built in ones these 31 | are added in as decimals to help test this, and they thematically fit. 32 | 33 | Also, these examples are purposefully left with blanks or use inconsistent 34 | assignment to fields, because real world data is messy. 35 | Completing these so that all books are loaded uniformly should not be done. 36 | """ 37 | 38 | books = [ 39 | { 40 | "title": { 41 | "en": "The Princess Bride", 42 | "de": "Die Prinzessin Braut", 43 | "fr": "La Princesse à Marier", 44 | "es": "La Novia Princesa", 45 | }, 46 | "author": "S. Morgenstern", 47 | "description": "A gripping (but sometimes boring) tale of adventure! There is a much better abridged version published by W. Goldman", 48 | "category": {"dewey": 859}, 49 | "number_of_pages": 240, 50 | }, 51 | { 52 | "title": { 53 | "en": "The Grasshopper Lies Heavy", 54 | "de": "Die Heuschrecke liegt schwer", 55 | }, 56 | "author": "Hawthorne Abendsen", 57 | "description": "A tale of an alternate universe where the Allies won World War 2.", 58 | "category": {"dewey": 837}, 59 | "number_of_pages": 122, 60 | }, 61 | { 62 | "title": { 63 | "en": "Hamster Huey and the Gooey Kablooie", 64 | }, 65 | "author": "Mabel Barr", 66 | "description": "Do you think the townsfolk will ever find Hamster Huey's head?", 67 | "category": {"dewey": 817}, 68 | "number_of_pages": 101, 69 | }, 70 | { 71 | "title": {"en": "The Hitchhiker's Guide to the Galaxy"}, 72 | "author": "Megadodo Publications", 73 | "description": "The standard repository for all knowledge and wisdom", 74 | "category": {"dewey": 39}, # It should be 039 75 | "number_of_pages": 2147483647, # But its probably more 76 | }, 77 | { 78 | "title": { 79 | "tlh": "The Tragedy of Khamlet, Son of the Emperor of Qo'noS", 80 | "en": "The Tragedy of Hamlet, Prince of Denmark", 81 | }, 82 | "author": "Wil'yam Sheq'spir", 83 | "description": { 84 | "tlh": """qaDelmeH bov tuj pem vIlo'choHQo'. 85 | SoH 'IH 'ej belmoH law', 'oH belmoH puS. 86 | jar vagh tIpuq DIHo'bogh Sang SuS ro'. 87 | 'ej ratlhtaHmeH bov tuj leSpoH luvuS. 88 | 89 | rut tujqu' bochtaHvIS chal mIn Dun qu'. 90 | rut DotlhDaj SuD wov HurghmoHmeH, HuvHa'. 91 | 'ej reH Hoch 'IHvo' Sab Hoch 'IH, net tu'. 92 | 'u' He choHmo', San jochmo' joq quvHa'. 93 | """, 94 | "en": "A thoroughly inferior translation of the Klingon playwrights finest work.", 95 | }, 96 | "category": {"dewey": 822.33}, 97 | "number_of_pages": 104, 98 | }, 99 | { 100 | "title": { 101 | "de": "Wenn ist das Nunstück git und Slotermeyer?", 102 | }, 103 | "author": "M. Python", 104 | "description": {"de": "Ja! Beiherhund das Oder die Flipperwaldt gersput!"}, 105 | "category": { 106 | "warning": "DO NOT TRANSLATE - EXTREME RISK OF FATALITY", 107 | }, 108 | "number_of_pages": 1, 109 | }, 110 | ] 111 | 112 | # I feel like I'm about to lay an egg. 113 | # Book, book books, book book boookoook!! 114 | book_books = [] 115 | for book in books: 116 | # title = str(book['title']) 117 | # b = Book(**book) 118 | 119 | # import pdb; pdb.set_trace() 120 | # b.save() 121 | book_books += [Book(**book)] 122 | return Book.objects.bulk_create(book_books) 123 | 124 | 125 | def load_test_books(): 126 | from library_app.models import Book 127 | 128 | books = [ 129 | { 130 | "title": { 131 | "en": "A Guide to Python", 132 | "de": "Eine Anleitung zu Python", 133 | }, 134 | "author": "G. van Rossum", 135 | "description": "A good book on learning Python", 136 | "category": {"dewey": 222}, 137 | "number_of_pages": 100, 138 | }, 139 | { 140 | "title": { 141 | "en": "A Guide to Django", 142 | "de": "Ein Leitfaden für Django", 143 | }, 144 | "author": "A. Holovaty & S. Willison", 145 | "description": "A good book for learning about Django, a very good web framework written in Python.", 146 | "category": {"dewey": 222}, 147 | "number_of_pages": 100, 148 | }, 149 | { 150 | "title": { 151 | "en": "The Dummies Guide to building some usable Django apps.", 152 | "de": "Der Dummies-Leitfaden zum Erstellen einiger verwendbarer Django-Apps", 153 | }, 154 | "author": "S. Spencer", 155 | "description": "A book on how to cobble together functional apps in Django that work, and aren't terrible.", 156 | "category": {"dewey": 222}, 157 | "number_of_pages": 100, 158 | }, 159 | ] 160 | 161 | return Book.objects.bulk_create(Book(**book) for book in books) 162 | 163 | 164 | def prep_all(): 165 | clear_user() 166 | load_user() 167 | clear_books() 168 | load_books() 169 | load_test_books() 170 | 171 | 172 | if __name__ == "__main__": 173 | import os 174 | import django 175 | 176 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 177 | django.setup() 178 | 179 | prep_all() 180 | print("All clean") 181 | -------------------------------------------------------------------------------- /tests/library_app/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import DetailView, ListView, UpdateView 2 | from reversion_compare.views import HistoryCompareDetailView 3 | from garnett.utils import get_current_language_code 4 | 5 | from library_app.models import Book 6 | 7 | 8 | class BookView(DetailView): 9 | pk_url_kwarg = "book" 10 | model = Book 11 | 12 | 13 | class BookListView(ListView): 14 | model = Book 15 | 16 | def get_ordering(self): 17 | code = get_current_language_code() 18 | return f"title__{code}" 19 | 20 | 21 | class BookUpdateView(UpdateView): 22 | model = Book 23 | fields = "__all__" 24 | 25 | 26 | class BookHistoryCompareView(HistoryCompareDetailView): 27 | model = Book 28 | template_name = "library_app/book_history.html" 29 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_app.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/ext/README.md: -------------------------------------------------------------------------------- 1 | This directory holds additional code to improve compatibility with external libraries. -------------------------------------------------------------------------------- /tests/tests/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aristotle-Metadata-Enterprises/django-garnett/70f00f381a74831a5f99e08f01f568fa9fb98ffa/tests/tests/ext/__init__.py -------------------------------------------------------------------------------- /tests/tests/ext/test_drf.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | from library_app.models import Book 3 | from garnett.context import set_field_language 4 | 5 | from library_app.api.serializers import BookSerializer 6 | 7 | 8 | @override_settings(GARNETT_TRANSLATABLE_LANGUAGES=["en", "fr", "de"]) 9 | class TestSerializer(TestCase): 10 | """Test different language locales work""" 11 | 12 | def setUp(self): 13 | self.titles = { 14 | "en": "Hello", 15 | "fr": "Bonjour", 16 | "de": "Guten Tag", 17 | } 18 | with set_field_language("en"): 19 | self.book = Book.objects.create( 20 | title=self.titles, 21 | author="Some Guy", 22 | description="A book on saying hello", 23 | category={}, 24 | number_of_pages=20, 25 | ) 26 | 27 | self.title = Book._meta.get_field("title") 28 | 29 | def test_read_serializer(self): 30 | obj = BookSerializer().to_representation(self.book) 31 | self.assertEqual(obj["title"], "Hello") 32 | 33 | with set_field_language("en"): 34 | self.assertEqual(self.book.title, "Hello") 35 | 36 | with set_field_language("fr"): 37 | self.assertEqual(self.book.title, "Bonjour") 38 | 39 | with set_field_language("de"): 40 | self.assertEqual(self.book.title, "Guten Tag") 41 | 42 | def test_write_serializer(self): 43 | obj = BookSerializer().to_representation(self.book) 44 | new_titles = { 45 | "en": "Bye", 46 | "fr": "Au revoir", 47 | "de": "Aufweidersen", 48 | } 49 | 50 | with set_field_language("en"): 51 | obj["title"] = new_titles 52 | new_obj = BookSerializer(data=obj) 53 | new_obj.is_valid() 54 | new_obj.update(self.book, new_obj.validated_data) 55 | 56 | self.assertEqual(self.book.title, "Bye") 57 | self.assertEqual(self.book.translations.title, new_titles) 58 | 59 | with set_field_language("fr"): 60 | obj["title"] = "Bonjour" 61 | new_obj = BookSerializer(data=obj) 62 | new_obj.is_valid() 63 | new_obj.update(self.book, new_obj.validated_data) 64 | 65 | self.assertEqual(self.book.title, "Bonjour") 66 | self.assertEqual(self.book.translations.title["en"], "Bye") 67 | self.assertEqual(self.book.translations.title["de"], "Aufweidersen") 68 | -------------------------------------------------------------------------------- /tests/tests/ext/test_drf_filters.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | from django.urls import reverse 3 | from django.utils.http import urlencode 4 | from library_app.models import Book 5 | from garnett.context import set_field_language 6 | 7 | 8 | @override_settings(GARNETT_TRANSLATABLE_LANGUAGES=["en", "fr", "de"]) 9 | class TestSerializer(TestCase): 10 | """Test different language locales work""" 11 | 12 | def setUp(self): 13 | self.titles = { 14 | "en": "Hello", 15 | "fr": "Bonjour", 16 | "de": "Guten Tag", 17 | } 18 | with set_field_language("en"): 19 | self.book = Book.objects.create( 20 | title=self.titles, 21 | author="Some Guy", 22 | description="A book on saying hello", 23 | category={}, 24 | number_of_pages=20, 25 | ) 26 | 27 | self.title = Book._meta.get_field("title") 28 | 29 | def test_filter(self): 30 | response = self.client.get( 31 | reverse("library_app_api:list_create_book") 32 | + "?" 33 | + urlencode({"title": self.titles["en"]}), 34 | ) 35 | self.assertEqual(response.status_code, 200) 36 | self.assertEqual(len(response.data), 1) 37 | self.assertEqual(response.data[0]["id"], self.book.pk) 38 | 39 | response = self.client.get( 40 | reverse("library_app_api:list_create_book") 41 | + "?" 42 | + urlencode({"title": self.titles["fr"]}), 43 | ) 44 | self.assertEqual(response.status_code, 200) 45 | self.assertEqual(len(response.data), 0) 46 | 47 | response = self.client.get( 48 | reverse("library_app_api:list_create_book") 49 | + "?" 50 | + urlencode({"title": self.titles["fr"], "glang": "fr"}), 51 | ) 52 | self.assertEqual(response.status_code, 200) 53 | self.assertEqual(len(response.data), 1) 54 | self.assertEqual(response.data[0]["id"], self.book.pk) 55 | -------------------------------------------------------------------------------- /tests/tests/ext/test_reversion.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | import reversion 3 | from garnett.serializers.json import Deserializer 4 | 5 | from garnett.context import set_field_language 6 | from library_app.models import Book 7 | 8 | book_data = dict( 9 | title={ 10 | "en": "A good book", 11 | "de": "Eine gut buch", 12 | }, 13 | author="I. M. Nice", 14 | description="A book on how to be good, and stuff", 15 | category={"dewey": 222}, 16 | number_of_pages=100, 17 | ) 18 | 19 | 20 | class TestReversion(TestCase): 21 | def setUp(self): 22 | with set_field_language("en"), reversion.create_revision(): 23 | self.book = Book(**book_data) 24 | self.book.save() 25 | 26 | # Need this to prevent data persisting across tests 27 | self.book = Book.objects.get(pk=self.book.pk) 28 | 29 | def test_reversion_numbers(self): 30 | num_versions_before = reversion.models.Version.objects.get_for_object( 31 | self.book 32 | ).count() 33 | with reversion.create_revision(): 34 | new_title = { 35 | "en": "New title", 36 | "de": "Neuer titel", 37 | } 38 | with set_field_language("en"): 39 | self.book.title = new_title["en"] 40 | with set_field_language("de"): 41 | self.book.title = new_title["de"] 42 | 43 | self.book.save() 44 | self.book.refresh_from_db() 45 | 46 | num_versions_after = reversion.models.Version.objects.get_for_object( 47 | self.book 48 | ).count() 49 | self.assertEqual(num_versions_after, num_versions_before + 1) 50 | 51 | def test_reversion_content(self): 52 | version_before = reversion.models.Version.objects.get_for_object( 53 | self.book 54 | ).first() 55 | deserialised = list(Deserializer(version_before.serialized_data))[0].object 56 | self.assertEqual(deserialised.title_tsall, book_data["title"]) 57 | 58 | with reversion.create_revision(): 59 | new_title = { 60 | "en": "New title", 61 | "de": "Neuer titel", 62 | } 63 | with set_field_language("en"): 64 | self.book.title = new_title["en"] 65 | with set_field_language("de"): 66 | self.book.title = new_title["de"] 67 | 68 | self.book.save() 69 | self.book.refresh_from_db() 70 | 71 | version_after = reversion.models.Version.objects.get_for_object( 72 | self.book 73 | ).first() 74 | deserialised = list(Deserializer(version_after.serialized_data))[0].object 75 | self.assertEqual(deserialised.title_tsall, new_title) 76 | -------------------------------------------------------------------------------- /tests/tests/migration_test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import List, Tuple, Union 3 | 4 | from django.apps import apps as nowapps 5 | from django.db import connection 6 | from django.db.migrations.executor import MigrationExecutor 7 | 8 | 9 | # This is an object so it is not run as a test itself. See usage in main.test_migrations 10 | @unittest.skipIf( 11 | connection.vendor in ["microsoft", "mssql", "mariadb", "mysql", "sqlite"], 12 | "Migration tests are not supported by the current database", 13 | ) 14 | class MigrationsTestCase(object): 15 | """ 16 | Thanks to: https://www.caktusgroup.com/blog/2016/02/02/writing-unit-tests-django-migrations/ 17 | """ 18 | 19 | @property 20 | def app(self): 21 | return nowapps.get_containing_app_config(type(self).__module__).name 22 | 23 | migrate_from: Union[str, List[Tuple]] = None 24 | migrate_to: Union[str, List[Tuple]] = None 25 | 26 | def setUp(self): 27 | assert ( 28 | self.migrate_from and self.migrate_to 29 | ), "TestCase '{}' must define migrate_from and migrate_to properties".format( 30 | type(self).__name__ 31 | ) 32 | if type(self.migrate_from) is not list: 33 | self.migrate_from = [(self.app, self.migrate_from)] 34 | if type(self.migrate_to) is not list: 35 | self.migrate_to = [(self.app, self.migrate_to)] 36 | executor = MigrationExecutor(connection) 37 | # print('unmigrated: %s'%executor.loader.unmigrated_apps) 38 | # print('migrated: %s'%executor.loader.migrated_apps) 39 | 40 | # executor.loader.build_graph() # reload. 41 | old_apps = executor.loader.project_state(self.migrate_from).apps 42 | 43 | executor.migrate(self.migrate_from) 44 | 45 | self.setUpBeforeMigration(old_apps) 46 | 47 | # Run the migration to test 48 | executor = MigrationExecutor(connection) 49 | executor.loader.build_graph() # reload. 50 | executor.migrate(self.migrate_to) 51 | 52 | self.apps = executor.loader.project_state(self.migrate_to).apps 53 | 54 | def setUpBeforeMigration(self, apps): 55 | pass 56 | -------------------------------------------------------------------------------- /tests/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from .test_modelforms import BookFormTestBase 3 | 4 | 5 | class AdminBookFormTests(BookFormTestBase, TestCase): 6 | url_name = "admin:library_app_book_change" 7 | -------------------------------------------------------------------------------- /tests/tests/test_assignment.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import TestCase 3 | 4 | from garnett.context import set_field_language 5 | import garnett.exceptions 6 | from library_app.models import Book 7 | 8 | book_data = dict( 9 | title={ 10 | "en": "A good book", 11 | "de": "Eine gut buch", 12 | }, 13 | author="I. M. Nice", 14 | description="A book on how to be good, and stuff", 15 | category={"dewey": 222}, 16 | number_of_pages=100, 17 | ) 18 | 19 | 20 | class TestFieldAssignment(TestCase): 21 | def setUp(self): 22 | with set_field_language("en"): 23 | self.book = Book.objects.create(**book_data) 24 | 25 | # Need this to prevent data persisting across tests 26 | self.book.refresh_from_db() 27 | 28 | def test_assignment(self): 29 | en_new_title = "New title" 30 | de_new_title = "Neuer titel" 31 | with set_field_language("en"): 32 | self.book.title = en_new_title 33 | with set_field_language("de"): 34 | self.book.title = "Neuer titel" 35 | 36 | self.book.save() 37 | self.book.refresh_from_db() 38 | 39 | with set_field_language("en"): 40 | self.assertEqual(self.book.title, en_new_title) 41 | self.assertNotEqual(self.book.title, de_new_title) 42 | with set_field_language("de"): 43 | self.assertEqual(self.book.title, de_new_title) 44 | self.assertNotEqual(self.book.title, en_new_title) 45 | 46 | self.assertEqual( 47 | self.book.translations.title, 48 | { 49 | "en": en_new_title, 50 | "de": de_new_title, 51 | }, 52 | ) 53 | 54 | def test_dict_assignment(self): 55 | data = {"en": "Stuff", "de": "Zeug"} 56 | with set_field_language("en"): 57 | self.book.title = data 58 | self.book.save() 59 | self.book.refresh_from_db() 60 | 61 | self.assertEqual(self.book.title, data["en"]) 62 | 63 | with set_field_language("de"): 64 | self.assertEqual(self.book.title, data["de"]) 65 | 66 | def test_max_length_validation(self): 67 | with set_field_language("en"): 68 | self.book.title = "A short value" 69 | with set_field_language("de"): 70 | self.book.title = "A long value " + ("=" * 350) 71 | with self.assertRaises(ValidationError): 72 | self.book.clean_fields() 73 | 74 | def test_validate_bad_code(self): 75 | """Test that validation prevents saving not selected code""" 76 | # Try to save in swedish 77 | with set_field_language("sv"): 78 | self.book.title = "Swedish title" 79 | 80 | with self.assertRaises(ValidationError): 81 | self.book.clean_fields() 82 | 83 | def test_setter_validate_wrong_type(self): 84 | with set_field_language("en"): 85 | with self.assertRaises(TypeError): 86 | self.book.title = 700 87 | 88 | def test_validate_bad_json_value(self): 89 | """Make sure we can't save a non dict to _tsall""" 90 | self.book.title_tsall = 100 91 | 92 | with self.assertRaises(garnett.exceptions.LanguageStructureError): 93 | self.book.clean_fields() 94 | 95 | def test_validate_bad_value_type(self): 96 | """Make sure tsall dict must be string to string""" 97 | self.book.title_tsall = { 98 | "en": 100, 99 | "fr": "good", 100 | } 101 | 102 | with set_field_language("en"), self.assertRaises(ValidationError) as err: 103 | # English language will fail 104 | self.book.clean_fields() 105 | self.assertEqual(err.exception, 'Invalid value for language "en"') 106 | with set_field_language("fr"), self.assertRaises(ValidationError) as err: 107 | self.book.clean_fields() 108 | self.assertEqual(err.exception, 'Invalid value for language "en"') 109 | 110 | 111 | class TestQuerysetAssignment(TestCase): 112 | def test_qs_create_from_dict(self): 113 | book = Book.objects.create(**book_data) 114 | self.assertEqual(book.translations.title, book_data["title"]) 115 | self.assertNotEqual(book.translations.description, book_data["description"]) 116 | self.assertEqual( 117 | book.translations.description, {"en": book_data["description"]} 118 | ) 119 | with set_field_language("en"): 120 | self.assertEqual(book.title, book_data["title"]["en"]) 121 | self.assertEqual(book.description, book_data["description"]) 122 | 123 | def test_qs_create_from_strings(self): 124 | book_data = dict( 125 | title="A good book", 126 | author="I. M. Nice", 127 | description="A book on how to be good, and stuff", 128 | category={"dewey": 222}, 129 | number_of_pages=100, 130 | ) 131 | book = Book.objects.create(**book_data) 132 | self.assertNotEqual(book.translations.title, book_data["title"]) 133 | self.assertNotEqual(book.translations.description, book_data["description"]) 134 | self.assertEqual(book.translations.title, {"en": book_data["title"]}) 135 | self.assertEqual( 136 | book.translations.description, {"en": book_data["description"]} 137 | ) 138 | with set_field_language("en"): 139 | self.assertEqual(book.title, book_data["title"]) 140 | self.assertEqual(book.description, book_data["description"]) 141 | 142 | def test_qs_bulk_create_from_dict(self): 143 | Book.objects.bulk_create([Book(**book_data)]) 144 | book = Book.objects.first() 145 | self.assertEqual(book.translations.title, book_data["title"]) 146 | self.assertNotEqual(book.translations.description, book_data["description"]) 147 | self.assertEqual( 148 | book.translations.description, {"en": book_data["description"]} 149 | ) 150 | with set_field_language("en"): 151 | self.assertEqual(book.title, book_data["title"]["en"]) 152 | self.assertEqual(book.description, book_data["description"]) 153 | 154 | def test_qs_bulk_create_from_strings(self): 155 | book_data = dict( 156 | title="A good book", 157 | author="I. M. Nice", 158 | description="A book on how to be good, and stuff", 159 | category={"dewey": 222}, 160 | number_of_pages=100, 161 | ) 162 | Book.objects.bulk_create([Book(**book_data)]) 163 | book = Book.objects.first() 164 | self.assertNotEqual(book.translations.title, book_data["title"]) 165 | self.assertNotEqual(book.translations.description, book_data["description"]) 166 | self.assertEqual(book.translations.title, {"en": book_data["title"]}) 167 | self.assertEqual( 168 | book.translations.description, {"en": book_data["description"]} 169 | ) 170 | with set_field_language("en"): 171 | self.assertEqual(book.title, book_data["title"]) 172 | self.assertEqual(book.description, book_data["description"]) 173 | 174 | 175 | class TestContext(TestCase): 176 | def test_nesting_context(self): 177 | with set_field_language("de"): 178 | book = Book.objects.create(**book_data.copy()) 179 | with set_field_language("en"): 180 | with set_field_language("de"): 181 | book.title = "de-title" 182 | with set_field_language("en"): 183 | book.description = "en-description" 184 | book.description = "de-description" 185 | book.title = "en-title" 186 | 187 | self.assertEqual(book.translations.title, {"en": "en-title", "de": "de-title"}) 188 | self.assertEqual( 189 | book.translations.description, 190 | {"en": "en-description", "de": "de-description"}, 191 | ) 192 | book.save() 193 | book.refresh_from_db() 194 | self.assertEqual(book.translations.title, {"en": "en-title", "de": "de-title"}) 195 | self.assertEqual( 196 | book.translations.description, 197 | {"en": "en-description", "de": "de-description"}, 198 | ) 199 | -------------------------------------------------------------------------------- /tests/tests/test_database_lookups.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | from django.db.models.functions import Lower 3 | from django.test import TestCase 4 | from garnett.expressions import L 5 | from garnett.patch import apply_patches, revert_patches 6 | 7 | from unittest import skipIf, skipUnless 8 | 9 | from garnett.context import set_field_language 10 | from library_app.models import Book 11 | 12 | 13 | class TestLanguageLookups(TestCase): 14 | @set_field_language("en") 15 | def setUp(self): 16 | self.book_data = dict( 17 | title={ 18 | "en": "A good book", 19 | "de": "Eine gut buch", 20 | }, 21 | author="I. M. Nice", 22 | description="A book on how to be good, and stuff", 23 | category={"dewey": 222}, 24 | number_of_pages=100, 25 | ) 26 | Book.objects.create(**self.book_data) 27 | 28 | def test_HasLang(self): 29 | books = Book.objects.all() 30 | self.assertTrue(books.filter(title__has_lang="en").exists()) 31 | self.assertTrue(books.filter(title__has_lang="de").exists()) 32 | self.assertFalse(books.filter(title__has_lang="fr").exists()) 33 | 34 | def test_HasLangs(self): 35 | books = Book.objects.all() 36 | self.assertTrue(books.filter(title__has_langs=["en", "de"]).exists()) 37 | self.assertTrue(books.filter(title__has_langs=["de"]).exists()) 38 | self.assertTrue(books.filter(title__has_langs=["en"]).exists()) 39 | self.assertFalse(books.filter(title__has_langs=["en", "de", "fr"]).exists()) 40 | self.assertFalse(books.filter(title__has_langs=["en", "fr"]).exists()) 41 | self.assertFalse(books.filter(title__has_langs=["fr"]).exists()) 42 | 43 | 44 | class TestLookups(TestCase): 45 | @set_field_language("en") 46 | def setUp(self): 47 | self.book_data = dict( 48 | title={ 49 | "en": "A good book", 50 | "de": "Eine gut buch", 51 | }, 52 | author="I. M. Nice", 53 | description="A book on how to be good, and stuff", 54 | category={"dewey": 222}, 55 | number_of_pages=100, 56 | ) 57 | Book.objects.create(**self.book_data) 58 | 59 | @skipIf(connection.vendor == "mysql", "MariaDB uses case insensitive matching here") 60 | def test_exact(self): 61 | books = Book.objects.all() 62 | with set_field_language("en"): 63 | self.assertTrue( 64 | books.filter(title__en=self.book_data["title"]["en"]).exists() 65 | ) 66 | self.assertTrue(books.filter(title=self.book_data["title"]["en"]).exists()) 67 | self.assertFalse(books.filter(title=self.book_data["title"]["de"]).exists()) 68 | # An inexact match won't be returned as true 69 | self.assertFalse( 70 | books.filter(title=self.book_data["title"]["en"].upper()).exists() 71 | ) 72 | self.assertFalse(books.filter(title="A GOoD bOoK").exists()) 73 | 74 | with set_field_language("de"): 75 | self.assertFalse(books.filter(title=self.book_data["title"]["en"]).exists()) 76 | self.assertTrue(books.filter(title=self.book_data["title"]["de"]).exists()) 77 | 78 | @skipUnless(connection.vendor == "mysql", "Provide some coverage for MariaDB") 79 | def test_exact_mysql(self): 80 | books = Book.objects.all() 81 | with set_field_language("en"): 82 | self.assertTrue(books.filter(title=self.book_data["title"]["en"]).exists()) 83 | self.assertFalse(books.filter(title=self.book_data["title"]["de"]).exists()) 84 | # An inexact match shouldn't be returned as true, but is 85 | # We have this test so if this changes we will know. 86 | self.assertTrue( 87 | books.filter(title=self.book_data["title"]["en"].upper()).exists() 88 | ) 89 | 90 | with set_field_language("de"): 91 | self.assertFalse(books.filter(title=self.book_data["title"]["en"]).exists()) 92 | self.assertTrue(books.filter(title=self.book_data["title"]["de"]).exists()) 93 | 94 | def test_iexact(self): 95 | books = Book.objects.all() 96 | with set_field_language("en"): 97 | self.assertTrue( 98 | books.filter( 99 | title__iexact=self.book_data["title"]["en"].upper() 100 | ).exists() 101 | ) 102 | self.assertFalse( 103 | books.filter( 104 | title__iexact=self.book_data["title"]["de"].upper() 105 | ).exists() 106 | ) 107 | 108 | with set_field_language("de"): 109 | self.assertFalse( 110 | books.filter( 111 | title__iexact=self.book_data["title"]["en"].upper() 112 | ).exists() 113 | ) 114 | self.assertTrue( 115 | books.filter( 116 | title__iexact=self.book_data["title"]["de"].upper() 117 | ).exists() 118 | ) 119 | 120 | @skipIf(connection.vendor == "sqlite", "SQLite uses case insensitive matching") 121 | def test_contains(self): 122 | books = Book.objects.all() 123 | with set_field_language("en"): 124 | self.assertTrue(books.filter(title__contains="good").exists()) 125 | self.assertFalse(books.filter(title__contains="gut").exists()) 126 | # An inexact match shouldn't be returned 127 | # But it is. This isn't a deal breaker right now :/ 128 | self.assertFalse(books.filter(title__contains="GOOD").exists()) 129 | self.assertFalse(books.filter(title__contains="GUT").exists()) 130 | 131 | with set_field_language("de"): 132 | self.assertFalse(books.filter(title__contains="good").exists()) 133 | self.assertTrue(books.filter(title__contains="gut").exists()) 134 | 135 | @skipUnless(connection.vendor == "sqlite", "Provide some coverage for SQLite") 136 | def test_contains_sqlite(self): 137 | books = Book.objects.all() 138 | with set_field_language("en"): 139 | self.assertTrue(books.filter(title__contains="good").exists()) 140 | self.assertFalse(books.filter(title__contains="gut").exists()) 141 | # An inexact match shouldn't be returned 142 | # But it is. This isn't a deal breaker right now :/ 143 | # We have this test so if this changes we will know. 144 | self.assertTrue(books.filter(title__contains="GOOD").exists()) 145 | self.assertFalse(books.filter(title__contains="GUT").exists()) 146 | 147 | with set_field_language("de"): 148 | self.assertFalse(books.filter(title__contains="good").exists()) 149 | self.assertTrue(books.filter(title__contains="gut").exists()) 150 | 151 | def test_icontains(self): 152 | books = Book.objects.all() 153 | with set_field_language("en"): 154 | self.assertTrue(books.filter(title__icontains="good").exists()) 155 | self.assertFalse(books.filter(title__icontains="gut").exists()) 156 | self.assertTrue(books.filter(title__icontains="GOOD").exists()) 157 | 158 | with set_field_language("de"): 159 | self.assertFalse(books.filter(title__icontains="good").exists()) 160 | self.assertTrue(books.filter(title__icontains="gut").exists()) 161 | self.assertTrue(books.filter(title__icontains="GUT").exists()) 162 | 163 | @skipIf(connection.vendor == "sqlite", "SQLite uses case insensitive matching") 164 | def test_startswith(self): 165 | books = Book.objects.all() 166 | with set_field_language("en"): 167 | self.assertTrue(books.filter(title__startswith="A goo").exists()) 168 | self.assertFalse(books.filter(title__startswith="a goo").exists()) 169 | self.assertFalse(books.filter(title__startswith="Eine").exists()) 170 | 171 | with set_field_language("de"): 172 | self.assertFalse(books.filter(title__startswith="A good").exists()) 173 | self.assertTrue(books.filter(title__startswith="Eine gut").exists()) 174 | 175 | def test_istartswith(self): 176 | books = Book.objects.all() 177 | with set_field_language("en"): 178 | self.assertTrue(books.filter(title__istartswith="A go").exists()) 179 | self.assertTrue(books.filter(title__istartswith="a go").exists()) 180 | self.assertFalse(books.filter(title__istartswith="Eine").exists()) 181 | 182 | with set_field_language("de"): 183 | self.assertFalse(books.filter(title__istartswith="a go").exists()) 184 | self.assertTrue(books.filter(title__istartswith="eine g").exists()) 185 | 186 | @skipIf(connection.vendor == "sqlite", "SQLite uses case insensitive matching") 187 | def test_endswith(self): 188 | books = Book.objects.all() 189 | with set_field_language("en"): 190 | self.assertTrue(books.filter(title__endswith="book").exists()) 191 | self.assertFalse(books.filter(title__endswith="BOOK").exists()) 192 | self.assertFalse(books.filter(title__endswith="buch").exists()) 193 | 194 | with set_field_language("de"): 195 | self.assertFalse(books.filter(title__endswith="book").exists()) 196 | self.assertTrue(books.filter(title__endswith="buch").exists()) 197 | self.assertFalse(books.filter(title__endswith="BUCH").exists()) 198 | 199 | def test_iendswith(self): 200 | books = Book.objects.all() 201 | with set_field_language("en"): 202 | self.assertTrue(books.filter(title__iendswith="book").exists()) 203 | self.assertTrue(books.filter(title__iendswith="BOOK").exists()) 204 | self.assertFalse(books.filter(title__iendswith="BUCH").exists()) 205 | 206 | with set_field_language("de"): 207 | self.assertFalse(books.filter(title__iendswith="BOOK").exists()) 208 | self.assertTrue(books.filter(title__iendswith="BUCH").exists()) 209 | 210 | @skipIf(connection.vendor == "sqlite", "SQLite uses case insensitive matching") 211 | def test_regex(self): 212 | books = Book.objects.all() 213 | with set_field_language("en"): 214 | self.assertTrue(books.filter(title__regex="^A.+book$").exists()) 215 | self.assertFalse(books.filter(title__regex="^A.+BOOK$").exists()) 216 | self.assertFalse(books.filter(title__regex="^Ei.+buch$").exists()) 217 | 218 | with set_field_language("de"): 219 | self.assertFalse(books.filter(title__regex="^A.+book$").exists()) 220 | self.assertTrue(books.filter(title__regex="^Ei.+buch$").exists()) 221 | 222 | def test_iregex(self): 223 | books = Book.objects.all() 224 | with set_field_language("en"): 225 | self.assertTrue(books.filter(title__iregex="^A.+book$").exists()) 226 | self.assertTrue(books.filter(title__iregex="^A.+BOOK$").exists()) 227 | self.assertFalse(books.filter(title__iregex="^Ei.+buch$").exists()) 228 | 229 | with set_field_language("de"): 230 | self.assertFalse(books.filter(title__iregex="^A.+book$").exists()) 231 | self.assertTrue(books.filter(title__iregex="^Ei.+buch$").exists()) 232 | 233 | def test_languagelookups(self): 234 | # noqa: E731 235 | books = Book.objects.all() 236 | for lookup in [ 237 | "contains", 238 | "icontains", 239 | "endswith", 240 | "iendswith", 241 | "startswith", 242 | "istartswith", 243 | ]: 244 | en_str = "A good book" 245 | de_str = "Eine gut buch" 246 | case_sensitive = True 247 | if lookup.startswith("i"): 248 | case_sensitive = False 249 | en_str = en_str.upper() 250 | de_str = de_str.upper() 251 | if "starts" in lookup or "contains" in lookup: 252 | en_str = en_str[0:-2] 253 | de_str = de_str[0:-2] 254 | if "end" in lookup or "contains" in lookup: 255 | en_str = en_str[2:] 256 | de_str = de_str[2:] 257 | 258 | try: 259 | with set_field_language("en"): 260 | self.assertFalse( 261 | books.filter(**{f"title__en__{lookup}": de_str}).exists() 262 | ) 263 | self.assertTrue( 264 | books.filter(**{f"title__en__{lookup}": en_str}).exists() 265 | ) 266 | self.assertTrue( 267 | books.filter(**{f"title__de__{lookup}": de_str}).exists() 268 | ) 269 | 270 | # TODO: This test fails - maybe an issue with JSON contains in SQLite? 271 | from django.db import connection 272 | 273 | if case_sensitive and connection.vendor != "sqlite": 274 | self.assertFalse( 275 | books.filter( 276 | **{f"title__en__{lookup}": en_str.upper()} 277 | ).exists() 278 | ) 279 | self.assertFalse( 280 | books.filter( 281 | **{f"title__de__{lookup}": de_str.upper()} 282 | ).exists() 283 | ) 284 | 285 | with set_field_language("de"): 286 | self.assertFalse( 287 | books.filter(**{f"title__en__{lookup}": de_str}).exists() 288 | ) 289 | self.assertTrue( 290 | books.filter(**{f"title__en__{lookup}": en_str}).exists() 291 | ) 292 | self.assertTrue( 293 | books.filter(**{f"title__de__{lookup}": de_str}).exists() 294 | ) 295 | except: # noqa: E722 , pragma: no cover 296 | print(f"failed on {lookup} -- '{en_str}', '{de_str}'") 297 | raise 298 | 299 | @skipIf(connection.vendor == "mysql", "MariaDB has issues with JSON F lookups") 300 | def test_f_lookup(self): 301 | from garnett.expressions import LangF as F 302 | from django.db.models.functions import Upper 303 | 304 | self.book_data = dict( 305 | title={ 306 | "en": "Mr. Bob Bobbertson", 307 | "de": "Herr Bob Bobbertson", 308 | }, 309 | author="Mr. Bob Bobbertson", 310 | description="Mr. Bob Bobbertson's amazing self-titled autobiography", 311 | category={ 312 | "dewey": 222, 313 | "subject": "Mr. Bob Bobbertson", 314 | }, 315 | number_of_pages=100, 316 | ) 317 | Book.objects.create(**self.book_data) 318 | 319 | books = Book.objects.all() 320 | 321 | self.assertTrue( # Author match 322 | books.filter(description__istartswith=F("author")).exists() 323 | ) 324 | 325 | with set_field_language("en"): 326 | annotated = books.annotate(en_title=F("title"))[0] 327 | self.assertEqual(annotated.title, annotated.en_title) 328 | annotated = books.annotate(en_title=F("title__xyz"))[0] 329 | self.assertEqual(annotated.en_title, None) 330 | 331 | self.assertTrue( # Author=Title match 332 | books.filter(author=F("title")).exists() 333 | ) 334 | 335 | self.assertTrue( # Title=Author match 336 | books.filter(title__en__iexact=F("author")).exists() 337 | ) 338 | self.assertTrue( # Title=Author match 339 | books.filter(title__exact=F("author")).exists() 340 | ) 341 | self.assertTrue( # Title=Author match 342 | books.filter(title=F("author")).exists() 343 | ) 344 | self.assertFalse( # Title=Author match 345 | books.filter(title=Upper(F("author"))).exists() 346 | ) 347 | self.assertTrue( # Title=Author match 348 | books.filter(title__en__iexact=F("author")).exists() 349 | ) 350 | self.assertTrue( # Title=Author match 351 | books.filter(title__en__exact=F("author")).exists() 352 | ) 353 | self.assertTrue( # Description matches Author 354 | books.filter(description__istartswith=F("author")).exists() 355 | ) 356 | self.assertTrue( # Description en matches Author 357 | books.filter(description__en__istartswith=F("author")).exists() 358 | ) 359 | self.assertTrue( # Description starts with Title 360 | books.filter(description__istartswith=F("title")).exists() 361 | ) 362 | 363 | 364 | class TestValuesList(TestCase): 365 | @set_field_language("en") 366 | def setUp(self): 367 | apply_patches() 368 | 369 | self.book_data = dict( 370 | title={ 371 | "en": "A good book", 372 | "de": "Eine gut buch", 373 | }, 374 | author="I. M. Nice", 375 | description="A book on how to be good, and stuff", 376 | category={"dewey": 222}, 377 | number_of_pages=100, 378 | ) 379 | Book.objects.create(**self.book_data) 380 | 381 | def tearDown(self): 382 | revert_patches() 383 | 384 | def test_values(self): 385 | books = Book.objects.all() 386 | with set_field_language("en"): 387 | self.assertEqual("A good book", books.values("title__en")[0]["title__en"]) 388 | self.assertEqual("A good book", books.values("title")[0]["title"]) 389 | 390 | with set_field_language("de"): 391 | self.assertEqual("Eine gut buch", books.values("title")[0]["title"]) 392 | self.assertEqual("Eine gut buch", books.values("title")[0]["title"]) 393 | self.assertEqual("A good book", books.values("title__en")[0]["title__en"]) 394 | 395 | def test_values_list(self): 396 | books = Book.objects.all() 397 | with set_field_language("en"): 398 | self.assertEqual( 399 | "A good book", books.values_list("title__en", flat=True)[0] 400 | ) 401 | self.assertEqual("A good book", books.values_list("title", flat=True)[0]) 402 | 403 | with set_field_language("de"): 404 | self.assertEqual("Eine gut buch", books.values("title")[0]["title"]) 405 | self.assertEqual("Eine gut buch", books.values_list("title", flat=True)[0]) 406 | self.assertEqual( 407 | "A good book", books.values_list("title__en", flat=True)[0] 408 | ) 409 | 410 | 411 | class TestExpressions(TestCase): 412 | """Test queries using language lookup expression""" 413 | 414 | def setUp(self): 415 | with set_field_language("en"): 416 | self.book = Book.objects.create( 417 | title={ 418 | "en": "Testing for dummies", 419 | "de": "Testen auf Dummies", 420 | }, 421 | author="For dummies", 422 | description={ 423 | "en": "Testing but for dummies", 424 | "de": "Testen aber für Dummies", 425 | }, 426 | category={"cat": "book"}, 427 | number_of_pages=2, 428 | ) 429 | 430 | def test_order_by_translate_field(self): 431 | with set_field_language("en"): 432 | qs = Book.objects.order_by(L("title")) 433 | self.assertEqual(qs.count(), 1) 434 | self.assertEqual(qs[0].title, "Testing for dummies") 435 | 436 | def test_order_by_lower_translate_field(self): 437 | with set_field_language("en"): 438 | qs = Book.objects.order_by(Lower(L("title"))) 439 | self.assertEqual(qs.count(), 1) 440 | self.assertEqual(qs[0].title, "Testing for dummies") 441 | 442 | def test_annotate_translate_field(self): 443 | with set_field_language("en"): 444 | qs = Book.objects.annotate(foo=L("title")) 445 | self.assertEqual(qs.count(), 1) 446 | self.assertEqual(qs[0].foo, "Testing for dummies") 447 | 448 | def test_annotate_lower_translate_field(self): 449 | with set_field_language("en"): 450 | qs = Book.objects.annotate(foo=Lower(L("title"))) 451 | self.assertEqual(qs.count(), 1) 452 | self.assertEqual(qs[0].foo, "testing for dummies") 453 | 454 | 455 | @skipIf(connection.vendor == "sqlite", "JSONField contains isn't avaliable on sqlite") 456 | class TestJSONFieldLookups(TestCase): 457 | """Tests to ensure we are not messing with json field functionality""" 458 | 459 | def setUp(self): 460 | with set_field_language("en"): 461 | self.book = Book.objects.create( 462 | title="book", 463 | author="Book guy", 464 | description="cool book", 465 | category={ 466 | "data": { 467 | "is": "nested", 468 | } 469 | }, 470 | number_of_pages=1000, 471 | ) 472 | 473 | def test_root_contains(self): 474 | qs = Book.objects.filter(category__contains={"data": {"is": "nested"}}) 475 | self.assertCountEqual(qs, [self.book]) 476 | 477 | def test_sub_contains(self): 478 | qs = Book.objects.filter(category__data__contains={"is": "nested"}) 479 | self.assertCountEqual(qs, [self.book]) 480 | -------------------------------------------------------------------------------- /tests/tests/test_default.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from garnett.context import set_field_language 4 | from library_app.models import DefaultBook 5 | 6 | 7 | class DefaultTestCase(TestCase): 8 | """Test setting of default on translated field""" 9 | 10 | def test_default(self): 11 | """Test that default is returned by getter""" 12 | book = DefaultBook.objects.create(number_of_pages=100) 13 | self.assertEqual(book.title, "DEFAULT TITLE") 14 | 15 | def test_language_default(self): 16 | """Test that default creates dict using current language""" 17 | with set_field_language("fr"): 18 | book = DefaultBook.objects.create(number_of_pages=100) 19 | self.assertEqual(book.title, "DEFAULT TITLE") 20 | self.assertEqual(book.title_tsall, {"fr": "DEFAULT TITLE"}) 21 | 22 | def test_default_function(self): 23 | """Test that default is returned by getter when inner default is function""" 24 | book = DefaultBook.objects.create(number_of_pages=100) 25 | self.assertEqual(book.author, "John Jimson") 26 | 27 | def test_language_default_function(self): 28 | """Test that dict is correct when inner default is function""" 29 | with set_field_language("fr"): 30 | book = DefaultBook.objects.create(number_of_pages=100) 31 | self.assertEqual(book.author, "John Jimson") 32 | self.assertEqual(book.author_tsall, {"fr": "John Jimson"}) 33 | 34 | def test_default_deconstruct(self): 35 | """Make sure default callable is serialized properly""" 36 | title = DefaultBook._meta.get_field("title") 37 | kwargs = title.deconstruct()[3] 38 | self.assertIn("default", kwargs) 39 | self.assertTrue(callable(kwargs["default"])) 40 | 41 | def test_default_empty_string(self): 42 | """Test default works when empty string""" 43 | book = DefaultBook(number_of_pages=100) 44 | self.assertEqual(book.description, "") 45 | -------------------------------------------------------------------------------- /tests/tests/test_fallback.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from library_app.models import Book 4 | from garnett.translatedstr import VerboseTranslatedStr, NextTranslatedStr 5 | from garnett.context import set_field_language 6 | 7 | 8 | @override_settings(GARNETT_TRANSLATABLE_LANGUAGES=["en", "de", "fr"]) 9 | class TestFallbacks(TestCase): 10 | """Test fallback functions directly""" 11 | 12 | def setUp(self): 13 | with set_field_language("en"): 14 | self.book = Book.objects.create( 15 | title={"en": "The Book", "de": "Das Buch", "fr": "Le livre"}, 16 | author="Some Guy", 17 | description="A nice book", 18 | category={"dewey": 222}, 19 | number_of_pages=20, 20 | ) 21 | 22 | self.title = Book._meta.get_field("title") 23 | 24 | def test_next_language_fallback(self): 25 | self.book.title = {"de": "Das Buch", "fr": "Le livre"} 26 | self.book.save() 27 | 28 | result = NextTranslatedStr(self.book.title_tsall) 29 | 30 | self.assertEqual(result, "Das Buch") 31 | self.assertTrue(result.is_fallback) 32 | self.assertEqual(result.fallback_language.to_tag(), "de") 33 | 34 | def test_next_language_fallback_none(self): 35 | """Test next language fallback when none present""" 36 | self.book.title = {} 37 | self.book.save() 38 | 39 | result = NextTranslatedStr(self.book.title_tsall) 40 | self.assertEqual(result, "") 41 | self.assertTrue(result.is_fallback) 42 | self.assertEqual(result.fallback_language, None) 43 | 44 | def test_default_fallback(self): 45 | self.book.title = {"de": "Das Buch", "fr": "Le livre"} 46 | self.book.save() 47 | 48 | result = VerboseTranslatedStr(self.book.title_tsall) 49 | self.assertEqual( 50 | result, "No translation of this field available in English [English]." 51 | ) 52 | -------------------------------------------------------------------------------- /tests/tests/test_field.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from django.test import TestCase 4 | 5 | import garnett.exceptions 6 | from garnett.context import set_field_language 7 | from garnett.fields import TranslatedField 8 | from library_app.models import Book, RANDOM_STR, BLEACH_STR 9 | 10 | book_data = dict( 11 | title={ 12 | "en": "A good book", 13 | "de": "Eine gut buch", 14 | }, 15 | author="I. M. Nice", 16 | description="A book on how to be good, and stuff", 17 | category={"dewey": 222}, 18 | number_of_pages=100, 19 | ) 20 | 21 | 22 | class TestFieldAssignment(TestCase): 23 | def setUp(self): 24 | self.field = TranslatedField(models.TextField(), name="test_field") 25 | 26 | def test_validate_bad_code(self): 27 | """Test that validation prevents saving not selected code""" 28 | # Try to save in swedish 29 | with set_field_language("sv"), self.assertRaises(ValidationError): 30 | value = {"sv": "Swedish title"} 31 | self.field.clean(value, None) 32 | 33 | def test_validate_bad_json_value(self): 34 | """Make sure we can't save a non dict to the field""" 35 | value = 100 36 | 37 | with self.assertRaises(garnett.exceptions.LanguageStructureError): 38 | self.field.clean(value, None) 39 | 40 | def test_validate_bad_value_type(self): 41 | """Make sure tsall dict must be string to string""" 42 | value = { 43 | "en": 100, 44 | "fr": "good", 45 | } 46 | 47 | with set_field_language("en"), self.assertRaises(ValidationError) as err: 48 | # English language will fail 49 | self.field.clean(value, None) 50 | self.assertEqual(err.exception, 'Invalid value for language "en"') 51 | with set_field_language("fr"), self.assertRaises(ValidationError) as err: 52 | self.field.clean(value, None) 53 | self.assertEqual(err.exception, 'Invalid value for language "en"') 54 | 55 | def test_trigger_get_db_prep_save(self): 56 | content = "This is a book and this string to be replace" 57 | Book.objects.create( 58 | title={ 59 | "en": "A book", 60 | "de": "Eine Gut Buch", 61 | }, 62 | author="No one", 63 | description="No description", 64 | category={"dewey": 123}, 65 | number_of_pages=100, 66 | other_info=content, 67 | ) 68 | # Expect only one object is created 69 | books = Book.objects.all() 70 | self.assertEqual(len(books), 1) 71 | 72 | # Expect CustomTestingField to be bleached 73 | book = books[0] 74 | self.assertEqual(book.other_info, content.replace(BLEACH_STR, RANDOM_STR)) 75 | 76 | # Expect other fields will not be bleached 77 | self.assertNotIn(RANDOM_STR, book.title) 78 | self.assertNotIn(RANDOM_STR, book.author) 79 | self.assertNotIn(RANDOM_STR, book.description) 80 | -------------------------------------------------------------------------------- /tests/tests/test_html.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from garnett.expressions import L, LangF 5 | from garnett.context import set_field_language 6 | from garnett.fields import TranslatedField 7 | from library_app.models import Book, RANDOM_STR, BLEACH_STR 8 | 9 | book_data = dict( 10 | title={ 11 | "en": "A good book", 12 | "de": "Eine gut buch", 13 | }, 14 | author="I. M. Nice", 15 | description={ 16 | "en": "A book on how to be good, and stuff", 17 | "fr": "Un livre sur la façon d'être bon, et tout", 18 | }, 19 | category={"dewey": 222}, 20 | number_of_pages=100, 21 | ) 22 | 23 | 24 | class TestHTMLRender(TestCase): 25 | def setUp(self): 26 | with set_field_language("en"): 27 | self.book = Book(**book_data) 28 | self.book.save() 29 | 30 | # Need this to prevent data persisting across tests 31 | self.book = Book.objects.get(pk=self.book.pk) 32 | 33 | def test_field_html_render(self): 34 | with set_field_language("en"): 35 | self.assertTrue("[en]" not in self.book.title.__html__()) 36 | self.assertEqual(self.book.title.__html__(), self.book.title) 37 | 38 | with set_field_language("de"): 39 | self.assertTrue("[de]" not in self.book.title.__html__()) 40 | self.assertEqual(self.book.title.__html__(), self.book.title) 41 | 42 | with set_field_language("fr"): 43 | tag = self.book.title.fallback_language.to_tag() 44 | self.assertTrue(f"[{tag}]" in self.book.title.__html__()) 45 | self.assertTrue(self.book.title_tsall[tag] in self.book.title.__html__()) 46 | -------------------------------------------------------------------------------- /tests/tests/test_instantiation.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from garnett.context import set_field_language 4 | from library_app.models import Book 5 | 6 | book_data = dict( 7 | title={ 8 | "en": "A good book", 9 | "de": "Eine gut buch", 10 | }, 11 | author="I. M. Nice", 12 | description="A book on how to be good, and stuff", 13 | category={"dewey": 222}, 14 | number_of_pages=100, 15 | ) 16 | 17 | 18 | class TestInstantiation(TestCase): 19 | def setUp(self): 20 | with set_field_language("en"): 21 | self.book = Book(**book_data) 22 | self.book.save() 23 | 24 | # Need this to prevent data persisting across tests 25 | self.book = Book.objects.get(pk=self.book.pk) 26 | 27 | def test_creation(self): 28 | with set_field_language("en"): 29 | self.assertEqual(self.book.title, book_data["title"]["en"]) 30 | self.assertEqual(self.book.description, book_data["description"]) 31 | self.assertEqual(self.book.author, book_data["author"]) 32 | self.assertEqual(self.book.category, book_data["category"]) 33 | self.assertEqual(self.book.number_of_pages, book_data["number_of_pages"]) 34 | 35 | def test_assignment(self): 36 | en_new_title = "New title" 37 | de_new_title = "Neuer titel" 38 | with set_field_language("en"): 39 | self.book.title = en_new_title 40 | with set_field_language("de"): 41 | self.book.title = "Neuer titel" 42 | 43 | self.book.save() 44 | self.book.refresh_from_db() 45 | 46 | with set_field_language("en"): 47 | self.assertEqual(self.book.title, en_new_title) 48 | self.assertNotEqual(self.book.title, de_new_title) 49 | with set_field_language("de"): 50 | self.assertEqual(self.book.title, de_new_title) 51 | self.assertNotEqual(self.book.title, en_new_title) 52 | 53 | self.assertEqual( 54 | self.book.translations.title, 55 | { 56 | "en": en_new_title, 57 | "de": de_new_title, 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/tests/test_locales.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from library_app.models import Book 4 | from garnett.context import set_field_language 5 | from garnett.utils import normalise_language_codes 6 | 7 | 8 | @override_settings(GARNETT_TRANSLATABLE_LANGUAGES=["en", "en-AU", "en-US"]) 9 | class TestFallbacks(TestCase): 10 | """Test different language locales work""" 11 | 12 | def setUp(self): 13 | self.titles = { 14 | "en": "Hello friend!", 15 | "en-au": "G'Day mate!", 16 | "en-Us": "Howdy partner!", 17 | } 18 | with set_field_language("en"): 19 | self.book = Book.objects.create( 20 | title=self.titles, 21 | author="Some Guy", 22 | description="A book on saying hello", 23 | category={}, 24 | number_of_pages=20, 25 | ) 26 | 27 | self.title = Book._meta.get_field("title") 28 | 29 | def test_normalisation(self): 30 | self.assertEqual( 31 | self.book.translations.title, normalise_language_codes(self.titles) 32 | ) 33 | 34 | def test_locale(self): 35 | with set_field_language("en"): 36 | self.assertTrue(self.book.title.startswith("Hello")) 37 | 38 | with set_field_language("en-US"): 39 | self.assertTrue(self.book.title.startswith("Howdy")) 40 | self.book.title = "Howdy pal!" 41 | self.book.save() 42 | 43 | with set_field_language("en-au"): 44 | self.assertTrue(self.book.title.startswith("G'Day")) 45 | 46 | self.assertEqual( 47 | self.book.translations.title, 48 | normalise_language_codes( 49 | { 50 | "en": "Hello friend!", 51 | "en-au": "G'Day mate!", 52 | "en-Us": "Howdy pal!", 53 | } 54 | ), 55 | ) 56 | -------------------------------------------------------------------------------- /tests/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase, RequestFactory 2 | from django.http import HttpResponse, Http404 3 | 4 | from garnett.middleware import ( 5 | TranslationContextMiddleware, 6 | TranslationContextNotFoundMiddleware, 7 | TranslationCacheMiddleware, 8 | ) 9 | from garnett.utils import get_current_language_code 10 | 11 | 12 | class TestTranslationContextMiddleware(TestCase): 13 | def setUp(self): 14 | self.factory = RequestFactory() 15 | self.middleware = TranslationContextMiddleware(lambda r: HttpResponse("Nice")) 16 | self.not_found_middleware = TranslationContextNotFoundMiddleware( 17 | lambda r: HttpResponse("Nice") 18 | ) 19 | 20 | def test_sets_language_context(self): 21 | c = Client() 22 | response = c.get("/") 23 | self.assertEqual(response.status_code, 200) 24 | self.assertTrue("garnett_languages" in response.context.keys()) 25 | self.assertTrue("garnett_current_language" in response.context.keys()) 26 | 27 | def test_translation_middleware_sets_language(self): 28 | def test_view(request): 29 | lang = get_current_language_code() 30 | self.assertEqual(lang, "de") 31 | return HttpResponse("Nice") 32 | 33 | middleware = TranslationContextMiddleware(test_view) 34 | middleware(self.factory.get("/home?glang=de")) 35 | 36 | def test_not_found_middleware_valid_lang(self): 37 | """Test that the not found middleware returns response when given valid language""" 38 | response = self.not_found_middleware(self.factory.get("/home?glang=de")) 39 | self.assertEqual(response.content, b"Nice") 40 | 41 | def test_not_found_middleware_invalid_lang(self): 42 | """Test that the not found middleware 404's when given invalid language""" 43 | with self.assertRaises(Http404): 44 | self.not_found_middleware(self.factory.get("/home?glang=notalang")) 45 | 46 | 47 | class TestTranslationCacheMiddleware(TestCase): 48 | def setUp(self): 49 | self.middleware = TranslationCacheMiddleware(lambda r: HttpResponse("Nice")) 50 | self.factory = RequestFactory() 51 | 52 | def test_cache_middleware_sets_session_value(self): 53 | request = self.factory.get("/home") 54 | request.garnett_language = "fr" 55 | request.session = {} 56 | self.middleware(request) 57 | self.assertIn("GARNETT_LANGUAGE_CODE", request.session) 58 | self.assertEqual(request.session["GARNETT_LANGUAGE_CODE"], "fr") 59 | 60 | def test_cache_middleware_fails_safely(self): 61 | """Test that the cache middleware still returns response if no session""" 62 | response = self.middleware(self.factory.get("/home")) 63 | self.assertEqual(response.content, b"Nice") 64 | -------------------------------------------------------------------------------- /tests/tests/test_migration.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.apps import apps 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from garnett import migrate 7 | from garnett.fields import TranslatedField 8 | from garnett.context import set_field_language 9 | 10 | from . import migration_test_utils 11 | 12 | 13 | class TestMigrationHandlers(TestCase): 14 | def setUp(self): 15 | self.before_value = '{"not_real":"This is just some json someone hsa stored in a field - isn\'t that wierd?!"}' 16 | self.current_language = "en" 17 | self.after_value = {self.current_language: self.before_value} 18 | 19 | def test_1_and_2_forwards(self): 20 | step_1 = migrate.update_safe_encode_content_forwards( 21 | self.current_language, self.before_value 22 | ) 23 | step_1 = json.loads(step_1) # Mock loading back from the database 24 | step_2 = migrate.update_safe_prepare_translations_forwards( 25 | self.current_language, step_1 26 | ) 27 | self.assertEqual(step_2, self.after_value) 28 | 29 | def test_1_and_2_backwards(self): 30 | step_1 = migrate.update_safe_prepare_translations_backwards( 31 | self.current_language, self.after_value 32 | ) 33 | step_1 = json.dumps(step_1) # Mock loading back from the database 34 | step_2 = migrate.update_safe_encode_content_backwards( 35 | self.current_language, step_1 36 | ) 37 | self.assertEqual(step_2, self.before_value) 38 | 39 | def test_1_forwards_and_backwards(self): 40 | step_1 = migrate.update_safe_encode_content_forwards( 41 | self.current_language, self.before_value 42 | ) 43 | step_2 = migrate.update_safe_encode_content_backwards( 44 | self.current_language, step_1 45 | ) 46 | self.assertEqual(step_2, self.before_value) 47 | 48 | def test_2_backwards_and_forwards(self): 49 | step_1 = migrate.update_safe_prepare_translations_backwards( 50 | self.current_language, self.after_value 51 | ) 52 | step_2 = migrate.update_safe_prepare_translations_forwards( 53 | self.current_language, step_1 54 | ) 55 | self.assertEqual(step_2, self.after_value) 56 | 57 | 58 | class TestDataMigration(migration_test_utils.MigrationsTestCase, TestCase): 59 | migrate_from = "0001_initial" 60 | migrate_to = "0002_make_translatable" 61 | app = "library_app" 62 | 63 | def setUpBeforeMigration(self, apps): 64 | Book = apps.get_model("library_app", "Book") 65 | self.title = "Before the migration, a string's story" 66 | self.description = "A moving tale of data was changed when going from place to place - but staying still!" 67 | self.book1 = Book.objects.create( 68 | title=self.title, description=self.description, number_of_pages=1 69 | ) 70 | self.book1.refresh_from_db() 71 | 72 | # Make sure that the field we are reading is a regular CharField 73 | self.assertEqual(type(Book._meta.get_field("title")), models.CharField) 74 | self.assertEqual(self.book1.title, self.title) 75 | self.assertEqual(self.book1.description, self.description) 76 | 77 | def test_title_and_description(self): 78 | Book = apps.get_model("library_app", "Book") 79 | self.assertEqual(type(Book._meta.get_field("title")), TranslatedField) 80 | 81 | self.book1 = Book.objects.get(pk=self.book1.pk) 82 | 83 | self.assertEqual(self.book1.title_tsall, {"en": self.title}) 84 | self.assertEqual(self.book1.description_tsall, {"en": self.description}) 85 | 86 | with set_field_language("en"): 87 | self.assertEqual(self.book1.title, self.title) 88 | self.assertEqual(self.book1.description, self.description) 89 | -------------------------------------------------------------------------------- /tests/tests/test_modelforms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | from garnett.context import set_field_language 4 | from library_app.models import Book 5 | 6 | 7 | book_data = dict( 8 | title={ 9 | "en": "A good book", 10 | "de": "Eine gut buch", 11 | }, 12 | author="I. M. Nice", 13 | description="A book on how to be good, and stuff", 14 | category={"dewey": 222}, 15 | number_of_pages=100, 16 | ) 17 | 18 | 19 | class BookFormTestBase: 20 | url_name = "update_book" 21 | 22 | def setUp(self): 23 | with set_field_language("en"): 24 | self.book = Book.objects.create(**book_data) 25 | 26 | def test_book_updates(self): 27 | new_title = "A better book" 28 | data = book_data.copy() 29 | data["title"] = new_title 30 | self.client.post( 31 | reverse(self.url_name, args=[self.book.pk]) + "?glang=en", 32 | data=data, 33 | ) 34 | 35 | self.book.refresh_from_db() 36 | 37 | with set_field_language("en"): 38 | self.assertTrue(self.book.title, new_title) 39 | self.assertTrue(self.book.description, data["description"]) 40 | with set_field_language("de"): 41 | self.assertTrue(self.book.title, book_data["title"]["de"]) 42 | 43 | def test_book_updates_in_german(self): 44 | new_title = "Das better book" 45 | data = book_data.copy() 46 | data["title"] = new_title 47 | data["description"] = "Ist gud" 48 | self.client.post( 49 | reverse(self.url_name, args=[self.book.pk]) + "?glang=de", 50 | data=data, 51 | ) 52 | 53 | self.book.refresh_from_db() 54 | 55 | with set_field_language("de"): 56 | self.assertTrue(self.book.title, new_title) 57 | self.assertTrue(self.book.description, data["description"]) 58 | with set_field_language("de"): 59 | # Test to ensure that the English title hasn't changed 60 | self.assertTrue(self.book.title, book_data["title"]["en"]) 61 | self.assertTrue(self.book.description, book_data["description"]) 62 | 63 | 64 | class BookFormTests(BookFormTestBase, TestCase): 65 | url_name = "update_book" 66 | -------------------------------------------------------------------------------- /tests/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from garnett.expressions import L, LangF 5 | from garnett.context import set_field_language 6 | from garnett.fields import TranslatedField 7 | from library_app.models import Book, RANDOM_STR, BLEACH_STR 8 | 9 | book_data = dict( 10 | title={ 11 | "en": "A good book", 12 | "de": "Eine gut buch", 13 | }, 14 | author="I. M. Nice", 15 | description={ 16 | "en": "A book on how to be good, and stuff", 17 | "fr": "Un livre sur la façon d'être bon, et tout", 18 | }, 19 | category={"dewey": 222}, 20 | number_of_pages=100, 21 | ) 22 | 23 | 24 | class TestModelChanges(TestCase): 25 | def setUp(self): 26 | with set_field_language("en"): 27 | self.book = Book(**book_data) 28 | self.book.save() 29 | 30 | # Need this to prevent data persisting across tests 31 | self.book = Book.objects.get(pk=self.book.pk) 32 | 33 | def test_available_languages(self): 34 | self.assertEqual( 35 | sorted([l.to_tag() for l in self.book.available_languages]), 36 | ["de", "en", "fr"], 37 | ) 38 | 39 | 40 | class TestQuerySet(TestCase): 41 | def setUp(self): 42 | with set_field_language("en"): 43 | self.book = Book(**book_data) 44 | self.book.save() 45 | 46 | # Need this to prevent data persisting across tests 47 | self.book = Book.objects.get(pk=self.book.pk) 48 | 49 | def test_values(self): 50 | with set_field_language("en"): 51 | titles = list(Book.objects.all().values(L("title"))) 52 | self.assertEqual(titles, [{"title": book_data["title"]["en"]}]) 53 | with set_field_language("de"): 54 | titles = list(Book.objects.all().values(L("title"))) 55 | self.assertEqual(titles, [{"title": book_data["title"]["de"]}]) 56 | -------------------------------------------------------------------------------- /tests/tests/test_pg_search.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | from django.test import TestCase 3 | 4 | from unittest import skipUnless 5 | 6 | from garnett.context import set_field_language 7 | from garnett.lookups import LangTrigramSimilarity 8 | from library_app.models import Book 9 | 10 | from django.contrib.postgres.search import TrigramSimilarity 11 | 12 | 13 | def similar_qs(qs, field, text): 14 | return qs.annotate(similarity=TrigramSimilarity(field, text)) 15 | 16 | 17 | def lang_similar_qs(qs, field, text): 18 | return qs.annotate(similarity=LangTrigramSimilarity(field, text)) 19 | 20 | 21 | @skipUnless(connection.vendor == "postgresql", "Search only works on Postgres") 22 | class TestPGSearchLookups(TestCase): 23 | @set_field_language("en") 24 | def setUp(self): 25 | self.book_data = dict( 26 | title={ 27 | "en": "A good book", 28 | "de": "Eine gut buch", 29 | "sjn": "A man parv", 30 | }, 31 | author="I. M. Nice", 32 | description="A book on how to be good, and stuff", 33 | category={"dewey": 222}, 34 | number_of_pages=100, 35 | ) 36 | Book.objects.create(**self.book_data) 37 | 38 | def test_search(self): 39 | books = Book.objects.all() 40 | with set_field_language("en"): 41 | self.assertTrue(books.filter(title__search="book").exists()) 42 | self.assertFalse(books.filter(title__search="buch").exists()) 43 | 44 | with set_field_language("de"): 45 | self.assertFalse(books.filter(title__search="book").exists()) 46 | self.assertTrue(books.filter(title__search="buch").exists()) 47 | 48 | def test_trigram(self): 49 | books = Book.objects.all() 50 | 51 | # Testing a regular field to make sure trigrams are enabled 52 | similar = similar_qs(books, "author", "I. R. Mice") 53 | self.assertTrue(similar.filter(similarity__gt=0.3).exists()) 54 | self.assertTrue(books.filter(author__trigram_similar="nice").exists()) 55 | 56 | # Testing a translation field to make sure trigrams work 57 | with set_field_language("en"): 58 | self.assertTrue(books.filter(title__trigram_similar="bood").exists()) 59 | self.assertFalse(books.filter(title__trigram_similar="buck").exists()) 60 | self.assertFalse(books.filter(title__trigram_similar="ein").exists()) 61 | 62 | self.assertTrue(lang_similar_qs(books, "title", "bood").exists()) 63 | # We can't get this to 0 because there is some overlap between German and English 64 | self.assertTrue( 65 | 0.2 > lang_similar_qs(books, "title", "ein gut buck").first().similarity 66 | ) 67 | 68 | with set_field_language("de"): 69 | self.assertFalse(books.filter(title__trigram_similar="bood").exists()) 70 | self.assertTrue(books.filter(title__icontains="buch").exists()) 71 | self.assertTrue(books.filter(title__trigram_similar="ein buck").exists()) 72 | self.assertTrue(books.filter(title__trigram_similar="eime buch").exists()) 73 | 74 | def test_trigram_similarity(self): 75 | books = Book.objects.all() 76 | with set_field_language("en"): 77 | en_similarity = ( 78 | lang_similar_qs(books, "title", "eine gut buck").first().similarity 79 | ) 80 | with set_field_language("de"): 81 | de_similarity = ( 82 | lang_similar_qs(books, "title", "eine gut buck").first().similarity 83 | ) 84 | 85 | self.assertTrue(de_similarity > en_similarity) 86 | -------------------------------------------------------------------------------- /tests/tests/test_requests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | from mock import Mock 3 | 4 | from garnett.middleware import get_language_from_request 5 | 6 | 7 | class TestUtils(TestCase): 8 | def test_get_language_from_request(self): 9 | request = Mock() 10 | request.GET = {"glang": "en"} 11 | request.COOKIES = {"GARNETT_LANGUAGE_CODE": "de"} 12 | request.META = {"HTTP_X_GARNETT_LANGUAGE_CODE": "fr"} 13 | with override_settings( 14 | GARNETT_REQUEST_LANGUAGE_SELECTORS=[ 15 | "garnett.selectors.cookie", 16 | "garnett.selectors.query", 17 | "garnett.selectors.header", 18 | ] 19 | ): 20 | self.assertTrue(get_language_from_request(request), "en") 21 | with override_settings( 22 | GARNETT_REQUEST_LANGUAGE_SELECTORS=[ 23 | "garnett.selectors.cookie", 24 | "garnett.selectors.query", 25 | "garnett.selectors.header", 26 | ] 27 | ): 28 | self.assertTrue(get_language_from_request(request), "de") 29 | 30 | with override_settings( 31 | GARNETT_REQUEST_LANGUAGE_SELECTORS=[ 32 | "garnett.selectors.header", 33 | "garnett.selectors.cookie", 34 | "garnett.selectors.query", 35 | ] 36 | ): 37 | self.assertTrue(get_language_from_request(request), "fr") 38 | -------------------------------------------------------------------------------- /tests/tests/test_update.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from garnett.context import set_field_language 4 | from library_app.models import Book 5 | 6 | 7 | class TestUpdates(TestCase): 8 | def setUp(self): 9 | with set_field_language("en"): 10 | self.book = Book.objects.create( 11 | title={ 12 | "en": "A good book", 13 | "de": "Eine Gut Buch", 14 | }, 15 | author="Someone", 16 | description="Its ok i guess", 17 | category={"dewey": 123}, 18 | number_of_pages=100, 19 | ) 20 | 21 | def test_update(self): 22 | """Test queryset .update on translated field with dict""" 23 | # Have to update with a dict instead of assigning a string with a language selected 24 | # as .update does a SQL UPDATE directly 25 | new_title = {"en": "New English Title", "de": "Eine Gut Buch"} 26 | Book.objects.filter(pk=self.book.pk).update(title=new_title) 27 | 28 | self.book.refresh_from_db() 29 | with set_field_language("en"): 30 | self.assertEqual(self.book.title, "New English Title") 31 | self.assertEqual(self.book.title_tsall, new_title) 32 | 33 | def test_bulk_update(self): 34 | """Test bulk_update on a translated field""" 35 | 36 | with set_field_language("en"): 37 | # Create second book 38 | book2 = Book.objects.create( 39 | title={ 40 | "en": "A bad book", 41 | "de": "Eine Gut Buch", 42 | }, 43 | author="Someone", 44 | description="Its not very good", 45 | category={"dewey": 123}, 46 | number_of_pages=100, 47 | ) 48 | 49 | self.assertEqual(Book.objects.count(), 2) 50 | # Bulk update title on the 2 books 51 | updated = [] 52 | for book in Book.objects.all(): 53 | book.title = "New English Title" 54 | updated.append(book) 55 | Book.objects.bulk_update(updated, ["title"]) 56 | 57 | # Check that just the english title was updated 58 | self.book.refresh_from_db() 59 | self.assertEqual( 60 | self.book.title_tsall, 61 | {"en": "New English Title", "de": "Eine Gut Buch"}, 62 | ) 63 | 64 | book2.refresh_from_db() 65 | self.assertEqual( 66 | book2.title_tsall, 67 | {"en": "New English Title", "de": "Eine Gut Buch"}, 68 | ) 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | toxworkdir=/tmp/tox 3 | skipsdist = True 4 | envlist = 5 | dj-{42,50,51} #-db-{pg,sqlite,maria} 6 | 7 | [testenv] 8 | passenv = 9 | DATABASE_URL 10 | 11 | setenv = 12 | DJANGO_SETTINGS_MODULE = library_app.settings 13 | 14 | dj-42: DJANGO_VERSION="==4.2.0" 15 | dj-42: DJANGO_REVERSION_VERSION=">=5.1" 16 | dj-42: DJANGO_REVERSION_COMPARE_VERSION=">=0.18.1" 17 | 18 | dj-50: DJANGO_VERSION="~=5.0.0" 19 | dj-50: DJANGO_REVERSION_VERSION=">=5.1" 20 | dj-50: DJANGO_REVERSION_COMPARE_VERSION=">=0.18.1" 21 | 22 | dj-51: DJANGO_VERSION="~=5.1.0" 23 | dj-51: DJANGO_REVERSION_VERSION=">=5.1" 24 | dj-51: DJANGO_REVERSION_COMPARE_VERSION=">=0.18.1" 25 | 26 | deps = 27 | -e . 28 | coverage 29 | setuptools>34.0 30 | psycopg2 31 | mysqlclient>1.4.0 32 | 33 | # From poetry 34 | dj-database-url~=0.5.0 35 | djangorestframework~=3.15 36 | drf_yasg>=1.20.0 37 | django-filter>=21.1 38 | mock~=4.0.3 39 | 40 | commands = 41 | pip install "django{env:DJANGO_VERSION}" "django-reversion{env:DJANGO_REVERSION_VERSION}" "django-reversion-compare{env:DJANGO_REVERSION_COMPARE_VERSION}" 42 | coverage run --append --source=. ./tests/manage.py test tests -v 2 43 | --------------------------------------------------------------------------------