├── tests ├── __init__.py ├── data.py ├── test_file_widget.py ├── test_query_set.py ├── test_model.py ├── fake_model.py ├── test_file_field_form.py ├── test_bulk.py ├── test_form.py ├── test_isnull.py ├── test_admin.py ├── test_widget.py ├── test_expressions.py ├── test_bleach_field.py ├── test_lookups.py ├── test_float_field.py ├── test_file_field.py ├── test_boolean_field.py ├── test_value.py ├── test_integer_field.py └── test_field.py ├── docs ├── .gitignore ├── source │ ├── _static │ │ └── django_admin_widget.png │ ├── conf.py │ ├── releases.rst │ ├── django_admin.rst │ ├── index.rst │ ├── filtering.rst │ ├── querying.rst │ ├── settings.rst │ ├── saving.rst │ ├── localized_value.rst │ ├── installation.rst │ ├── constraints.rst │ ├── fields.rst │ └── quick_start.rst ├── Makefile └── make.bat ├── localized_fields ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── fields │ ├── text_field.py │ ├── char_field.py │ ├── __init__.py │ ├── bleach_field.py │ ├── float_field.py │ ├── boolean_field.py │ ├── uniqueslug_field.py │ ├── integer_field.py │ ├── file_field.py │ ├── autoslug_field.py │ └── field.py ├── models.py ├── templates │ └── localized_fields │ │ ├── admin │ │ └── widget.html │ │ └── multiwidget.html ├── apps.py ├── expressions.py ├── util.py ├── mixins.py ├── admin.py ├── static │ └── localized_fields │ │ ├── localized-fields-admin.css │ │ └── localized-fields-admin.js ├── descriptor.py ├── widgets.py ├── lookups.py ├── forms.py └── value.py ├── pyproject.toml ├── .coveragerc ├── MANIFEST.in ├── .readthedocs.yml ├── pytest.ini ├── manage.py ├── setup.cfg ├── .pylintrc ├── .gitignore ├── tox.ini ├── LICENSE.md ├── settings.py ├── README.md ├── setup.py └── .circleci └── config.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /localized_fields/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 80 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = localized_fields/* 3 | omit = *migrations*, *tests* 4 | -------------------------------------------------------------------------------- /docs/source/_static/django_admin_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SectorLabs/django-localized-fields/HEAD/docs/source/_static/django_admin_widget.png -------------------------------------------------------------------------------- /localized_fields/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "localized_fields.apps.LocalizedFieldsConfig" 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include localized_fields/static * 4 | recursive-include localized_fields/templates * 5 | recursive-exclude tests * 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | builder: html 5 | configuration: docs/source/conf.py 6 | 7 | python: 8 | version: 3.7 9 | install: 10 | - requirements: requirements/docs.txt 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=settings 3 | testpaths=tests 4 | addopts=-m "not benchmark" 5 | junit_family=legacy 6 | filterwarnings= 7 | ignore::DeprecationWarning:localized_fields.fields.autoslug_field 8 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault( 7 | 'DJANGO_SETTINGS_MODULE', 8 | 'settings' 9 | ) 10 | 11 | from django.core.management import execute_from_command_line 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import sphinx_rtd_theme 2 | 3 | project = 'django-localized-fields' 4 | copyright = '2019, Sector Labs' 5 | author = 'Sector Labs' 6 | extensions = ["sphinx_rtd_theme"] 7 | templates_path = ['_templates'] 8 | exclude_patterns = [] 9 | html_theme = "sphinx_rtd_theme" 10 | html_static_path = ['_static'] 11 | -------------------------------------------------------------------------------- /localized_fields/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-08-27 08:05 2 | 3 | from django.contrib.postgres.operations import HStoreExtension 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [] 10 | 11 | operations = [HStoreExtension()] 12 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_init_values() -> dict: 5 | """Gets a test dictionary containing a key for every language.""" 6 | 7 | keys = {} 8 | 9 | for lang_code, lang_name in settings.LANGUAGES: 10 | keys[lang_code] = "value in %s" % lang_name 11 | 12 | return keys 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E252,E501,W503 3 | exclude = env,.tox,.git,config/settings,*/migrations/*,*/static/CACHE/*,docs,node_modules 4 | 5 | [isort] 6 | line_length=80 7 | multi_line_output=3 8 | lines_between_types=1 9 | include_trailing_comma=True 10 | not_skip=__init__.py 11 | known_standard_library=dataclasses 12 | known_third_party=django_bleach,bleach,pytest 13 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=120 3 | 4 | [MESSAGES CONTROL] 5 | disable=missing-docstring,invalid-name,unnecessary-lambda,too-few-public-methods,pointless-string-statement 6 | 7 | [DESIGN] 8 | max-parents=14 9 | 10 | [TYPECHECK] 11 | generated-members=REQUEST,acl_users,aq_parent,"[a-zA-Z]+_set{1,2}",save,delete 12 | ignored-classes=WSGIRequest,ManyToManyField,QuerySet 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore virtual environments 2 | env/ 3 | .env/ 4 | venv/ 5 | 6 | # Ignore Python byte code cache 7 | *.pyc 8 | __pycache__ 9 | .cache/ 10 | 11 | # Ignore coverage reports 12 | .coverage 13 | reports/ 14 | 15 | # Ignore build results 16 | *.egg-info/ 17 | dist/ 18 | build/ 19 | pip-wheel-metadata 20 | 21 | # Ignore stupid .DS_Store 22 | .DS_Store 23 | 24 | # Ignore PyCharm 25 | .idea/ 26 | 27 | # Ignore tox environments 28 | .tox/ 29 | -------------------------------------------------------------------------------- /localized_fields/fields/text_field.py: -------------------------------------------------------------------------------- 1 | from ..forms import LocalizedTextFieldForm 2 | from .char_field import LocalizedCharField 3 | 4 | 5 | class LocalizedTextField(LocalizedCharField): 6 | def formfield(self, **kwargs): 7 | """Gets the form field associated with this field.""" 8 | 9 | defaults = {"form_class": LocalizedTextFieldForm} 10 | 11 | defaults.update(kwargs) 12 | return super().formfield(**defaults) 13 | -------------------------------------------------------------------------------- /localized_fields/fields/char_field.py: -------------------------------------------------------------------------------- 1 | from ..forms import LocalizedCharFieldForm 2 | from ..value import LocalizedStringValue 3 | from .field import LocalizedField 4 | 5 | 6 | class LocalizedCharField(LocalizedField): 7 | attr_class = LocalizedStringValue 8 | 9 | def formfield(self, **kwargs): 10 | """Gets the form field associated with this field.""" 11 | defaults = {"form_class": LocalizedCharFieldForm} 12 | 13 | defaults.update(kwargs) 14 | return super().formfield(**defaults) 15 | -------------------------------------------------------------------------------- /docs/source/releases.rst: -------------------------------------------------------------------------------- 1 | Releases 2 | ======== 3 | 4 | v6.0 5 | ---- 6 | 7 | Breaking changes 8 | **************** 9 | 10 | * Removes support for Python 3.6 and earlier. 11 | * Removes support for PostgreSQL 9.6 and earlier. 12 | * Sets ``LOCALIZED_FIELDS_EXPERIMENTAL`` to ``True`` by default. 13 | 14 | Bug fixes 15 | ********* 16 | 17 | * Fixes a bug where ``LocalizedIntegerField`` could not be used in ``order_by``. 18 | 19 | Other 20 | ***** 21 | 22 | * ``LocalizedValue.translate()`` can now takes an optional ``language`` parameter. 23 | -------------------------------------------------------------------------------- /localized_fields/models.py: -------------------------------------------------------------------------------- 1 | from psqlextra.models import PostgresModel 2 | 3 | from .mixins import AtomicSlugRetryMixin 4 | 5 | 6 | class LocalizedModel(AtomicSlugRetryMixin, PostgresModel): 7 | """Turns a model into a model that contains LocalizedField's. 8 | 9 | For basic localisation functionality, it isn't needed to inherit 10 | from LocalizedModel. However, for certain features, this is required. 11 | 12 | It is definitely needed for :see:LocalizedUniqueSlugField, unless you 13 | manually inherit from AtomicSlugRetryMixin. 14 | """ 15 | 16 | class Meta: 17 | abstract = True 18 | -------------------------------------------------------------------------------- /docs/source/django_admin.rst: -------------------------------------------------------------------------------- 1 | Django Admin 2 | ------------ 3 | 4 | To display ``LocalizedFields`` as a tab bar in Django Admin; inherit your admin model class from ``LocalizedFieldsAdminMixin``: 5 | 6 | .. code-block:: python 7 | 8 | from django.contrib import admin 9 | from myapp.models import MyLocalizedModel 10 | 11 | from localized_fields.admin import LocalizedFieldsAdminMixin 12 | 13 | class MyLocalizedModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin): 14 | """Any admin options you need go here""" 15 | 16 | admin.site.register(MyLocalizedModel, MyLocalizedModelAdmin) 17 | 18 | 19 | .. image:: _static/django_admin_widget.png 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36-dj{20,21,22,30,31,32}, py37-dj{20,21,22,30,31,32}, py38-dj{20,21,22,30,31,32,40,41,42}, py39-dj{30,31,32,40,41,42}, py310-dj{32,40,41,42,50}, py311-dj{42,50} 3 | 4 | [testenv] 5 | deps = 6 | dj20: Django>=2.0,<2.1 7 | dj21: Django>=2.1,<2.2 8 | dj22: Django>=2.2,<2.3 9 | dj30: Django>=3.0,<3.0.2 10 | dj31: Django>=3.1,<3.2 11 | dj32: Django>=3.2,<4.0 12 | dj40: Django>=4.0,<4.1 13 | dj41: Django>=4.1,<4.2 14 | dj42: Django>=4.2,<5.0 15 | dj50: Django>=5.0,<5.1 16 | .[test] 17 | setenv = 18 | DJANGO_SETTINGS_MODULE=settings 19 | passenv = DATABASE_URL 20 | commands = python setup.py test 21 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome 2 | ======= 3 | 4 | ``django-localized-fields`` is a Django library that provides fields to store localized content (content in various languages) in a PostgreSQL database. It does this by utilizing the PostgreSQL ``hstore`` type, which is available in Django as ``HStoreField`` since Django 1.10. 5 | 6 | This package requires Python 3.6 or newer, Django 2.0 or newer and PostgreSQL 10 or newer. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :caption: Overview 11 | 12 | installation 13 | quick_start 14 | constraints 15 | fields 16 | saving 17 | querying 18 | filtering 19 | localized_value 20 | django_admin 21 | settings 22 | releases 23 | -------------------------------------------------------------------------------- /localized_fields/templates/localized_fields/admin/widget.html: -------------------------------------------------------------------------------- 1 | {% with widget_id=widget.attrs.id %} 2 |
3 | 10 | {% for widget in widget.subwidgets %} 11 |
12 | {% include widget.template_name %} 13 |
14 | {% endfor %} 15 |
16 | {% endwith %} 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /localized_fields/templates/localized_fields/multiwidget.html: -------------------------------------------------------------------------------- 1 | {% with widget_id=widget.attrs.id %} 2 |
3 | 10 | {% for widget in widget.subwidgets %} 11 |
12 | {% include widget.template_name %} 13 |
14 | {% endfor %} 15 |
16 | {% endwith %} 17 | -------------------------------------------------------------------------------- /localized_fields/apps.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.apps import AppConfig 4 | from django.conf import settings 5 | 6 | from . import lookups 7 | from .fields import LocalizedField 8 | from .lookups import LocalizedLookupMixin 9 | 10 | 11 | class LocalizedFieldsConfig(AppConfig): 12 | name = "localized_fields" 13 | 14 | def ready(self): 15 | if getattr(settings, "LOCALIZED_FIELDS_EXPERIMENTAL", True): 16 | for _, clazz in inspect.getmembers(lookups): 17 | if not inspect.isclass(clazz) or clazz is LocalizedLookupMixin: 18 | continue 19 | 20 | if issubclass(clazz, LocalizedLookupMixin): 21 | LocalizedField.register_lookup(clazz) 22 | -------------------------------------------------------------------------------- /localized_fields/expressions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils import translation 3 | from psqlextra import expressions 4 | 5 | 6 | class LocalizedRef(expressions.HStoreRef): 7 | """Expression that selects the value in a field only in the currently 8 | active language.""" 9 | 10 | def __init__(self, name: str, lang: str = None): 11 | """Initializes a new instance of :see:LocalizedRef. 12 | 13 | Arguments: 14 | name: 15 | The field/column to select from. 16 | 17 | lang: 18 | The language to get the field/column in. 19 | If not specified, the currently active language 20 | is used. 21 | """ 22 | 23 | language = lang or translation.get_language() or settings.LANGUAGE_CODE 24 | super().__init__(name, language) 25 | -------------------------------------------------------------------------------- /localized_fields/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from .autoslug_field import LocalizedAutoSlugField 2 | from .boolean_field import LocalizedBooleanField 3 | from .char_field import LocalizedCharField 4 | from .field import LocalizedField 5 | from .file_field import LocalizedFileField 6 | from .float_field import LocalizedFloatField 7 | from .integer_field import LocalizedIntegerField 8 | from .text_field import LocalizedTextField 9 | from .uniqueslug_field import LocalizedUniqueSlugField 10 | 11 | __all__ = [ 12 | "LocalizedField", 13 | "LocalizedAutoSlugField", 14 | "LocalizedUniqueSlugField", 15 | "LocalizedCharField", 16 | "LocalizedTextField", 17 | "LocalizedFileField", 18 | "LocalizedIntegerField", 19 | "LocalizedFloatField", 20 | "LocalizedBooleanField", 21 | ] 22 | 23 | try: 24 | from .bleach_field import LocalizedBleachField 25 | 26 | __all__ += ["LocalizedBleachField"] 27 | except ImportError: 28 | pass 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /localized_fields/util.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django.conf import settings 4 | 5 | 6 | def get_language_codes() -> List[str]: 7 | """Gets a list of all available language codes. 8 | 9 | This looks at your project's settings.LANGUAGES 10 | and returns a flat list of the configured 11 | language codes. 12 | 13 | Arguments: 14 | A flat list of all availble language codes 15 | in your project. 16 | """ 17 | 18 | return [lang_code for lang_code, _ in settings.LANGUAGES] 19 | 20 | 21 | def resolve_object_property(obj, path: str): 22 | """Resolves the value of a property on an object. 23 | 24 | Is able to resolve nested properties. For example, 25 | a path can be specified: 26 | 27 | 'other.beer.name' 28 | 29 | Raises: 30 | AttributeError: 31 | In case the property could not be resolved. 32 | 33 | Returns: 34 | The value of the specified property. 35 | """ 36 | 37 | value = obj 38 | for path_part in path.split("."): 39 | value = getattr(value, path_part) 40 | 41 | return value 42 | -------------------------------------------------------------------------------- /tests/test_file_widget.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from localized_fields.value import LocalizedFileValue 4 | from localized_fields.widgets import LocalizedFileWidget 5 | 6 | 7 | class LocalizedFileWidgetTestCase(TestCase): 8 | """Tests the workings of the :see:LocalizedFiledWidget class.""" 9 | 10 | @staticmethod 11 | def test_get_context(): 12 | """Tests whether the :see:get_context correctly handles 'required' 13 | attribute, separately for each subwidget.""" 14 | 15 | widget = LocalizedFileWidget() 16 | widget.widgets[0].is_required = True 17 | widget.widgets[1].is_required = True 18 | widget.widgets[2].is_required = False 19 | context = widget.get_context( 20 | name="test", 21 | value=LocalizedFileValue(dict(en="test")), 22 | attrs=dict(required=True), 23 | ) 24 | assert "required" not in context["widget"]["subwidgets"][0]["attrs"] 25 | assert context["widget"]["subwidgets"][1]["attrs"]["required"] 26 | assert "required" not in context["widget"]["subwidgets"][2]["attrs"] 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 SectorLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/filtering.rst: -------------------------------------------------------------------------------- 1 | Filtering localized content 2 | =========================== 3 | 4 | .. note:: 5 | 6 | All examples below assume a model declared like this: 7 | 8 | .. code-block:: python 9 | 10 | from localized_fields.models import LocalizedModel 11 | from localized_fields.fields import LocalizedField 12 | 13 | 14 | class MyModel(LocalizedModel): 15 | title = LocalizedField() 16 | 17 | 18 | Active language 19 | ---------------- 20 | 21 | .. code-block:: python 22 | 23 | from django.utils import translation 24 | 25 | # filter in english 26 | translation.activate("en") 27 | MyModel.objects.filter(title="test") 28 | 29 | # filter in dutch 30 | translation.activate("nl") 31 | MyModel.objects.filter(title="test") 32 | 33 | 34 | Specific language 35 | ----------------- 36 | 37 | .. code-block:: python 38 | 39 | MyModel.objects.filter(title__en="test") 40 | MyModel.objects.filter(title__nl="test") 41 | 42 | # do it dynamically, where the language code is a var 43 | lang_code = "nl" 44 | MyModel.objects.filter(**{"title_%s" % lang_code: "test"}) 45 | -------------------------------------------------------------------------------- /tests/test_query_set.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from localized_fields.fields import LocalizedField 4 | 5 | from .fake_model import get_fake_model 6 | 7 | 8 | class LocalizedQuerySetTestCase(TestCase): 9 | """Tests query sets with models containing :see:LocalizedField.""" 10 | 11 | Model = None 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | """Creates the test models in the database.""" 16 | 17 | super(LocalizedQuerySetTestCase, cls).setUpClass() 18 | 19 | cls.Model = get_fake_model({"title": LocalizedField()}) 20 | 21 | @classmethod 22 | def test_assign_raw_dict(cls): 23 | inst = cls.Model() 24 | inst.title = dict(en="Bread", ro="Paine") 25 | inst.save() 26 | 27 | inst = cls.Model.objects.get(pk=inst.pk) 28 | assert inst.title.en == "Bread" 29 | assert inst.title.ro == "Paine" 30 | 31 | @classmethod 32 | def test_assign_raw_dict_update(cls): 33 | inst = cls.Model.objects.create(title=dict(en="Bread", ro="Paine")) 34 | 35 | cls.Model.objects.update(title=dict(en="Beer", ro="Bere")) 36 | 37 | inst = cls.Model.objects.get(pk=inst.pk) 38 | assert inst.title.en == "Beer" 39 | assert inst.title.ro == "Bere" 40 | -------------------------------------------------------------------------------- /localized_fields/mixins.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import transaction 3 | from django.db.utils import IntegrityError 4 | 5 | 6 | class AtomicSlugRetryMixin: 7 | """Makes :see:LocalizedUniqueSlugField work by retrying upon violation of 8 | the UNIQUE constraint.""" 9 | 10 | def save(self, *args, **kwargs): 11 | """Saves this model instance to the database.""" 12 | 13 | max_retries = getattr(settings, "LOCALIZED_FIELDS_MAX_RETRIES", 100) 14 | 15 | if not hasattr(self, "retries"): 16 | self.retries = 0 17 | 18 | with transaction.atomic(): 19 | try: 20 | return super().save(*args, **kwargs) 21 | except IntegrityError as ex: 22 | # this is as retarded as it looks, there's no 23 | # way we can put the retry logic inside the slug 24 | # field class... we can also not only catch exceptions 25 | # that apply to slug fields... so yea.. this is as 26 | # retarded as it gets... i am sorry :( 27 | if "slug" not in str(ex): 28 | raise ex 29 | 30 | if self.retries >= max_retries: 31 | raise ex 32 | 33 | self.retries += 1 34 | return self.save() 35 | -------------------------------------------------------------------------------- /localized_fields/admin.py: -------------------------------------------------------------------------------- 1 | from . import widgets 2 | from .fields import ( 3 | LocalizedBooleanField, 4 | LocalizedCharField, 5 | LocalizedField, 6 | LocalizedFileField, 7 | LocalizedTextField, 8 | ) 9 | 10 | FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = { 11 | LocalizedField: {"widget": widgets.AdminLocalizedFieldWidget}, 12 | LocalizedCharField: {"widget": widgets.AdminLocalizedCharFieldWidget}, 13 | LocalizedTextField: {"widget": widgets.AdminLocalizedFieldWidget}, 14 | LocalizedFileField: {"widget": widgets.AdminLocalizedFileFieldWidget}, 15 | LocalizedBooleanField: {"widget": widgets.AdminLocalizedBooleanFieldWidget}, 16 | } 17 | 18 | 19 | class LocalizedFieldsAdminMixin: 20 | """Mixin for making the fancy widgets work in Django Admin.""" 21 | 22 | class Media: 23 | css = {"all": ("localized_fields/localized-fields-admin.css",)} 24 | 25 | js = ( 26 | "admin/js/jquery.init.js", 27 | "localized_fields/localized-fields-admin.js", 28 | ) 29 | 30 | def __init__(self, *args, **kwargs): 31 | """Initializes a new instance of :see:LocalizedFieldsAdminMixin.""" 32 | 33 | super().__init__(*args, **kwargs) 34 | overrides = FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS.copy() 35 | overrides.update(self.formfield_overrides) 36 | self.formfield_overrides = overrides 37 | -------------------------------------------------------------------------------- /localized_fields/static/localized_fields/localized-fields-admin.css: -------------------------------------------------------------------------------- 1 | .localized-fields-widget { 2 | display: inline-block; 3 | } 4 | 5 | .localized-fields-widget.tabs { 6 | display: block; 7 | margin: 0; 8 | border-bottom: 1px solid #eee; 9 | } 10 | 11 | .localized-fields-widget.tabs .localized-fields-widget.tab { 12 | display: inline-block; 13 | margin-left: 5px; 14 | border: 1px solid #79aec8; 15 | border-bottom: none; 16 | border-radius: 4px; 17 | border-bottom-left-radius: 0; 18 | border-bottom-right-radius: 0; 19 | background: #79aec8; 20 | color: #fff; 21 | font-weight: 400; 22 | opacity: 0.5; 23 | } 24 | 25 | .localized-fields-widget.tabs .localized-fields-widget.tab:first-child { 26 | margin-left: 0; 27 | } 28 | 29 | .localized-fields-widget.tabs .localized-fields-widget.tab:hover { 30 | background: #417690; 31 | border-color: #417690; 32 | opacity: 1; 33 | } 34 | 35 | .localized-fields-widget.tabs .localized-fields-widget.tab label { 36 | padding: 5px 10px; 37 | display: inline; 38 | text-decoration: none; 39 | color: #fff; 40 | width: initial; 41 | cursor: pointer; 42 | } 43 | 44 | .localized-fields-widget.tabs .localized-fields-widget.tab.active, 45 | .localized-fields-widget.tabs .localized-fields-widget.tab.active:hover { 46 | background: #79aec8; 47 | border-color: #79aec8; 48 | opacity: 1; 49 | } 50 | 51 | .localized-fields-widget p.file-upload { 52 | margin-left: 0; 53 | } 54 | -------------------------------------------------------------------------------- /docs/source/querying.rst: -------------------------------------------------------------------------------- 1 | Querying localized content 2 | ========================== 3 | 4 | .. note:: 5 | 6 | All examples below assume a model declared like this: 7 | 8 | .. code-block:: python 9 | 10 | from localized_fields.models import LocalizedModel 11 | from localized_fields.fields import LocalizedField 12 | 13 | 14 | class MyModel(LocalizedModel): 15 | title = LocalizedField() 16 | 17 | 18 | Active language 19 | --------------- 20 | 21 | Only need a value in a specific language? Use the ``LocalizedRef`` expression to query a value in the currently active language: 22 | 23 | .. code-block:: python 24 | 25 | from localized_fields.expressions import LocalizedRef 26 | 27 | MyModel.objects.create(title=dict(en="Hello", nl="Hallo")) 28 | 29 | translation.activate("nl") 30 | english_title = ( 31 | MyModel 32 | .objects 33 | .annotate(title=LocalizedRef("title")) 34 | .values_list("title", flat=True) 35 | .first() 36 | ) 37 | 38 | print(english_title) # prints "Hallo" 39 | 40 | 41 | Specific language 42 | ----------------- 43 | 44 | .. code-block:: python 45 | 46 | from localized_fields.expressions import LocalizedRef 47 | 48 | result = ( 49 | MyModel 50 | .objects 51 | .values( 52 | 'title__en', 53 | 'title__nl', 54 | ) 55 | .first() 56 | ) 57 | 58 | print(result['title__en']) 59 | print(result['title__nl']) 60 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from localized_fields.fields import LocalizedField 4 | from localized_fields.value import LocalizedValue 5 | 6 | from .fake_model import get_fake_model 7 | 8 | 9 | class LocalizedModelTestCase(TestCase): 10 | """Tests whether the :see:LocalizedModel class.""" 11 | 12 | TestModel = None 13 | 14 | @classmethod 15 | def setUpClass(cls): 16 | """Creates the test model in the database.""" 17 | 18 | super(LocalizedModelTestCase, cls).setUpClass() 19 | 20 | cls.TestModel = get_fake_model({"title": LocalizedField()}) 21 | 22 | @classmethod 23 | def test_defaults(cls): 24 | """Tests whether all :see:LocalizedField fields are assigned an empty 25 | :see:LocalizedValue instance when the model is instanitiated.""" 26 | 27 | obj = cls.TestModel() 28 | 29 | assert isinstance(obj.title, LocalizedValue) 30 | 31 | @classmethod 32 | def test_model_init_kwargs(cls): 33 | """Tests whether all :see:LocalizedField fields are assigned an empty 34 | :see:LocalizedValue instance when the model is instanitiated.""" 35 | data = { 36 | "title": { 37 | "en": "english_title", 38 | "ro": "romanian_title", 39 | "nl": "dutch_title", 40 | } 41 | } 42 | obj = cls.TestModel(**data) 43 | 44 | assert isinstance(obj.title, LocalizedValue) 45 | assert obj.title.en == "english_title" 46 | assert obj.title.ro == "romanian_title" 47 | assert obj.title.nl == "dutch_title" 48 | -------------------------------------------------------------------------------- /tests/fake_model.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.postgres.operations import HStoreExtension 4 | from django.db import connection, migrations 5 | from django.db.migrations.executor import MigrationExecutor 6 | 7 | from localized_fields.models import LocalizedModel 8 | 9 | 10 | def define_fake_model(fields=None, model_base=LocalizedModel, meta_options={}): 11 | name = str(uuid.uuid4()).replace("-", "")[:8] 12 | 13 | attributes = { 14 | "app_label": "tests", 15 | "__module__": __name__, 16 | "__name__": name, 17 | "Meta": type("Meta", (object,), meta_options), 18 | } 19 | 20 | if fields: 21 | attributes.update(fields) 22 | model = type(name, (model_base,), attributes) 23 | 24 | return model 25 | 26 | 27 | def get_fake_model(fields=None, model_base=LocalizedModel, meta_options={}): 28 | """Creates a fake model to use during unit tests.""" 29 | 30 | model = define_fake_model(fields, model_base, meta_options) 31 | 32 | class TestProject: 33 | def clone(self, *_args, **_kwargs): 34 | return self 35 | 36 | @property 37 | def apps(self): 38 | return self 39 | 40 | class TestMigration(migrations.Migration): 41 | operations = [HStoreExtension()] 42 | 43 | with connection.schema_editor() as schema_editor: 44 | migration_executor = MigrationExecutor(schema_editor.connection) 45 | migration_executor.apply_migration( 46 | TestProject(), TestMigration("eh", "postgres_extra") 47 | ) 48 | 49 | schema_editor.create_model(model) 50 | 51 | return model 52 | -------------------------------------------------------------------------------- /localized_fields/static/localized_fields/localized-fields-admin.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | var syncTabs = function(lang) { 3 | $('.localized-fields-widget.tab label:contains("'+lang+'")').each(function(){ 4 | $(this).parents('.localized-fields-widget[role="tabs"]').find('.localized-fields-widget.tab').removeClass('active'); 5 | $(this).parents('.localized-fields-widget.tab').addClass('active'); 6 | $(this).parents('.localized-fields-widget[role="tabs"]').children('.localized-fields-widget [role="tabpanel"]').hide(); 7 | $('#'+$(this).attr('for')).show(); 8 | }); 9 | } 10 | 11 | $(function (){ 12 | $('.localized-fields-widget [role="tabpanel"]').hide(); 13 | // set first tab as active 14 | $('.localized-fields-widget[role="tabs"]').each(function () { 15 | $(this).find('.localized-fields-widget.tab:first').addClass('active'); 16 | $('#'+$(this).find('.localized-fields-widget.tab:first label').attr('for')).show(); 17 | }); 18 | // try set active last selected tab 19 | if (window.sessionStorage) { 20 | var lang = window.sessionStorage.getItem('localized-field-lang'); 21 | if (lang) { 22 | syncTabs(lang); 23 | } 24 | } 25 | 26 | $('.localized-fields-widget.tab label').click(function(event) { 27 | event.preventDefault(); 28 | syncTabs(this.innerText); 29 | if (window.sessionStorage) { 30 | window.sessionStorage.setItem('localized-field-lang', this.innerText); 31 | } 32 | return false; 33 | }); 34 | }); 35 | })(django.jQuery) 36 | -------------------------------------------------------------------------------- /docs/source/settings.rst: -------------------------------------------------------------------------------- 1 | .. _LANGUAGES: https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-LANGUAGE_CODE 2 | .. _LANGUAGE_CODE: https://docs.djangoproject.com/en/2.2/ref/settings/#languages 3 | 4 | Settings 5 | ======== 6 | 7 | .. LOCALIZED_FIELDS_EXPERIMENTAL: 8 | 9 | * ``LOCALIZED_FIELDS_EXPERIMENTAL`` 10 | 11 | .. note:: 12 | 13 | Disabled in v5.x and earlier. Enabled by default since v6.0. 14 | 15 | When enabled: 16 | 17 | * ``LocalizedField`` will return ``None`` instead of an empty ``LocalizedValue`` if there is no database value. 18 | * ``LocalizedField`` lookups will by the currently active language instead of an exact match by dict. 19 | 20 | 21 | .. _LOCALIZED_FIELDS_FALLBACKS: 22 | 23 | * ``LOCALIZED_FIELDS_FALLBACKS`` 24 | 25 | List of language codes which define the order in which fallbacks should happen. If a value is not available in a specific language, we'll try to pick the value in the next language in the list. 26 | 27 | .. warning:: 28 | 29 | If this setting is not configured, the default behaviour is to fall back to the value in the **default language**. It is recommended to configure this setting to get predictible fallback behaviour that suits your use case. 30 | 31 | Use the same language codes as you used for configuring the `LANGUAGES`_ and `LANGUAGE_CODE`_ setting. 32 | 33 | .. code-block:: python 34 | 35 | LOCALIZED_FIELDS_FALLBACKS = { 36 | "en": ["nl", "ar"], # if trying to get EN, but not available, try NL and then AR 37 | "nl": ["en", "ar"], # if trying to get NL, but not available, try EN and then AR 38 | "ar": ["en", "nl"], # if trying to get AR, but not available, try EN and then NL 39 | } 40 | -------------------------------------------------------------------------------- /tests/test_file_field_form.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ValidationError 3 | from django.forms.widgets import FILE_INPUT_CONTRADICTION 4 | from django.test import TestCase 5 | 6 | from localized_fields.forms import LocalizedFileFieldForm 7 | 8 | 9 | class LocalizedFileFieldFormTestCase(TestCase): 10 | """Tests the workings of the :see:LocalizedFileFieldForm class.""" 11 | 12 | def test_clean(self): 13 | """Tests whether the :see:clean function is working properly.""" 14 | 15 | formfield = LocalizedFileFieldForm(required=True) 16 | with self.assertRaises(ValidationError): 17 | formfield.clean([]) 18 | with self.assertRaises(ValidationError): 19 | formfield.clean([], {"en": None}) 20 | with self.assertRaises(ValidationError): 21 | formfield.clean("badvalue") 22 | with self.assertRaises(ValidationError): 23 | value = [FILE_INPUT_CONTRADICTION] * len(settings.LANGUAGES) 24 | formfield.clean(value) 25 | 26 | formfield = LocalizedFileFieldForm(required=False) 27 | formfield.clean([""] * len(settings.LANGUAGES)) 28 | formfield.clean(["", ""], ["", ""]) 29 | 30 | def test_bound_data(self): 31 | """Tests whether the :see:bound_data function is returns correctly 32 | value.""" 33 | 34 | formfield = LocalizedFileFieldForm() 35 | assert formfield.bound_data([""], None) == [""] 36 | 37 | initial = dict([(lang, "") for lang, _ in settings.LANGUAGES]) 38 | value = [None] * len(settings.LANGUAGES) 39 | expected_value = [""] * len(settings.LANGUAGES) 40 | assert formfield.bound_data(value, initial) == expected_value 41 | -------------------------------------------------------------------------------- /tests/test_bulk.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField 5 | 6 | from .fake_model import get_fake_model 7 | 8 | 9 | class LocalizedBulkTestCase(TestCase): 10 | """Tests bulk operations with data structures provided by the django- 11 | localized-fields library.""" 12 | 13 | @staticmethod 14 | def test_localized_bulk_insert(): 15 | """Tests whether bulk inserts work properly when using a 16 | :see:LocalizedUniqueSlugField in the model.""" 17 | 18 | model = get_fake_model( 19 | { 20 | "name": LocalizedField(), 21 | "slug": LocalizedUniqueSlugField( 22 | populate_from="name", include_time=True 23 | ), 24 | "score": models.IntegerField(), 25 | } 26 | ) 27 | 28 | to_create = [ 29 | model( 30 | name={"en": "english name 1", "ro": "romanian name 1"}, score=1 31 | ), 32 | model( 33 | name={"en": "english name 2", "ro": "romanian name 2"}, score=2 34 | ), 35 | model( 36 | name={"en": "english name 3", "ro": "romanian name 3"}, score=3 37 | ), 38 | ] 39 | 40 | model.objects.bulk_create(to_create) 41 | assert model.objects.all().count() == 3 42 | 43 | for obj in to_create: 44 | obj_db = model.objects.filter( 45 | name__en=obj.name.en, name__ro=obj.name.ro, score=obj.score 46 | ).first() 47 | 48 | assert obj_db 49 | assert len(obj_db.slug.en) >= len(obj_db.name.en) 50 | assert len(obj_db.slug.ro) >= len(obj_db.name.ro) 51 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | import dj_database_url 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = True 6 | 7 | SECRET_KEY = 'this is my secret key' # NOQA 8 | 9 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 10 | 11 | DATABASES = { 12 | 'default': dj_database_url.config(default='postgres:///localized_fields'), 13 | } 14 | 15 | DATABASES['default']['ENGINE'] = 'psqlextra.backend' 16 | 17 | LANGUAGE_CODE = 'en' 18 | LANGUAGES = ( 19 | ('en', 'English'), 20 | ('ro', 'Romanian'), 21 | ('nl', 'Dutch') 22 | ) 23 | 24 | INSTALLED_APPS = ( 25 | 'django.contrib.auth', 26 | 'django.contrib.contenttypes', 27 | 'django.contrib.admin', 28 | 'django.contrib.messages', 29 | 'localized_fields', 30 | 'tests', 31 | ) 32 | 33 | TEMPLATES = [ 34 | { 35 | "BACKEND": "django.template.backends.django.DjangoTemplates", 36 | "DIRS": [], 37 | "APP_DIRS": True, 38 | "OPTIONS": { 39 | "context_processors": [ 40 | "django.template.context_processors.debug", 41 | "django.template.context_processors.request", 42 | "django.contrib.auth.context_processors.auth", 43 | "django.contrib.messages.context_processors.messages", 44 | ], 45 | }, 46 | }, 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.contrib.sessions.middleware.SessionMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | ] 54 | 55 | # See: https://github.com/psycopg/psycopg2/issues/1293 56 | if django.VERSION >= (3, 1): 57 | USE_TZ = True 58 | USE_I18N = True 59 | TIME_ZONE = 'UTC' 60 | 61 | # set to a lower number than the default, since 62 | # we want the tests to be fast, default is 100 63 | LOCALIZED_FIELDS_MAX_RETRIES = 3 64 | 65 | LOCALIZED_FIELDS_EXPERIMENTAL = False 66 | -------------------------------------------------------------------------------- /tests/test_form.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase 3 | 4 | from localized_fields.forms import LocalizedFieldForm 5 | 6 | 7 | class LocalizedFieldFormTestCase(TestCase): 8 | """Tests the workings of the :see:LocalizedFieldForm class.""" 9 | 10 | @staticmethod 11 | def test_init(): 12 | """Tests whether the constructor correctly creates a field for every 13 | language.""" 14 | # case required for specific language 15 | form = LocalizedFieldForm(required=[settings.LANGUAGE_CODE]) 16 | 17 | for (lang_code, _), field in zip(settings.LANGUAGES, form.fields): 18 | assert field.label == lang_code 19 | 20 | if lang_code == settings.LANGUAGE_CODE: 21 | assert field.required 22 | else: 23 | assert not field.required 24 | 25 | # case required for all languages 26 | form = LocalizedFieldForm(required=True) 27 | assert form.required 28 | for field in form.fields: 29 | assert field.required 30 | 31 | # case optional filling 32 | form = LocalizedFieldForm(required=False) 33 | assert not form.required 34 | for field in form.fields: 35 | assert not field.required 36 | 37 | # case required for any language 38 | form = LocalizedFieldForm(required=[]) 39 | assert form.required 40 | for field in form.fields: 41 | assert not field.required 42 | 43 | @staticmethod 44 | def test_compress(): 45 | """Tests whether the :see:compress function is working properly.""" 46 | 47 | input_value = [lang_name for _, lang_name in settings.LANGUAGES] 48 | output_value = LocalizedFieldForm().compress(input_value) 49 | 50 | for lang_code, lang_name in settings.LANGUAGES: 51 | assert output_value.get(lang_code) == lang_name 52 | -------------------------------------------------------------------------------- /tests/test_isnull.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | from django.test import TestCase 5 | 6 | from localized_fields.fields import LocalizedField 7 | from localized_fields.value import LocalizedValue 8 | 9 | from .fake_model import get_fake_model 10 | 11 | 12 | class LocalizedIsNullLookupsTestCase(TestCase): 13 | """Tests whether ref lookups properly work with.""" 14 | 15 | TestModel1 = None 16 | 17 | @classmethod 18 | def setUpClass(cls): 19 | """Creates the test model in the database.""" 20 | super(LocalizedIsNullLookupsTestCase, cls).setUpClass() 21 | cls.TestModel = get_fake_model( 22 | {"text": LocalizedField(null=True, required=[])} 23 | ) 24 | cls.TestModel.objects.create( 25 | text=LocalizedValue(dict(en="text_en", ro="text_ro", nl="text_nl")) 26 | ) 27 | cls.TestModel.objects.create( 28 | text=None, 29 | ) 30 | 31 | def test_isnull_lookup_valid_values(self): 32 | """Test whether isnull properly works with valid values.""" 33 | assert self.TestModel.objects.filter(text__isnull=True).exists() 34 | assert self.TestModel.objects.filter(text__isnull=False).exists() 35 | 36 | def test_isnull_lookup_null(self): 37 | """Test whether isnull crashes with None as value.""" 38 | 39 | with pytest.raises(ValueError): 40 | assert self.TestModel.objects.filter(text__isnull=None).exists() 41 | 42 | def test_isnull_lookup_string(self): 43 | """Test whether isnull properly works with string values on the 44 | corresponding Django version.""" 45 | if django.VERSION < (4, 0): 46 | assert self.TestModel.objects.filter(text__isnull="True").exists() 47 | else: 48 | with pytest.raises(ValueError): 49 | assert self.TestModel.objects.filter( 50 | text__isnull="True" 51 | ).exists() 52 | -------------------------------------------------------------------------------- /docs/source/saving.rst: -------------------------------------------------------------------------------- 1 | Saving localized content 2 | ======================== 3 | 4 | .. note:: 5 | 6 | All examples below assume a model declared like this: 7 | 8 | .. code-block:: python 9 | 10 | from localized_fields.models import LocalizedModel 11 | from localized_fields.fields import LocalizedField 12 | 13 | 14 | class MyModel(LocalizedModel): 15 | title = LocalizedField() 16 | 17 | 18 | Individual assignment 19 | ********************* 20 | 21 | .. code-block:: python 22 | 23 | obj = MyModel() 24 | obj.title.en = 'Hello' 25 | obj.title.nl = 'Hallo' 26 | obj.save() 27 | 28 | 29 | Individual dynamic assignment 30 | ***************************** 31 | 32 | .. code-block:: python 33 | 34 | obj = MyModel() 35 | obj.title.set('en', 'Hello') 36 | obj.title.set('nl', 'Hallo') 37 | obj.save() 38 | 39 | 40 | Multiple assignment 41 | ******************* 42 | 43 | .. code-block:: python 44 | 45 | obj = MyModel() 46 | obj.title = dict(en='Hello', nl='Hallo') 47 | obj.save() 48 | 49 | obj = MyModel(title=dict(en='Hello', nl='Hallo')) 50 | obj.save() 51 | 52 | obj = MyModel.objects.create(title=dict(en='Hello', nl='Hallo')) 53 | 54 | 55 | Default language assignment 56 | *************************** 57 | 58 | .. code-block:: python 59 | 60 | obj = MyModel() 61 | obj.title = 'Hello' # assumes value is in default language 62 | obj.save() 63 | 64 | obj = MyModel(title='Hello') # assumes value is in default language 65 | obj.save() 66 | 67 | obj = MyModel.objects.create(title='title') # assumes value is in default language 68 | 69 | 70 | Array assignment 71 | **************** 72 | 73 | .. code-block:: python 74 | 75 | obj = MyModel() 76 | obj.title = ['Hello', 'Hallo'] # order according to LANGUAGES 77 | obj.save() 78 | 79 | obj = MyModel(title=['Hello', 'Hallo']) # order according to LANGUAGES 80 | obj.save() 81 | 82 | obj = MyModel.objects.create(title=['Hello', 'Hallo']) # order according to LANGUAGES 83 | -------------------------------------------------------------------------------- /docs/source/localized_value.rst: -------------------------------------------------------------------------------- 1 | Working with localized values 2 | ============================= 3 | 4 | .. note:: 5 | 6 | All examples below assume a model declared like this: 7 | 8 | .. code-block:: python 9 | 10 | from localized_fields.models import LocalizedModel 11 | from localized_fields.fields import LocalizedField 12 | 13 | 14 | class MyModel(LocalizedModel): 15 | title = LocalizedField() 16 | 17 | Localized content is represented by ``localized_fields.value.LocalizedValue``. Which is essentially a dictionary where the key is the language and the value the content in the respective language. 18 | 19 | .. code-block:: python 20 | 21 | from localized_fields.value import LocalizedValue 22 | 23 | obj = MyModel.objects.first() 24 | assert isistance(obj.title, LocalizedValue) # True 25 | 26 | 27 | With fallback 28 | ------------- 29 | 30 | .. seealso:: 31 | 32 | Configure :ref:`LOCALIZED_FIELDS_FALLBACKS ` to control the fallback behaviour. 33 | 34 | 35 | Active language 36 | *************** 37 | 38 | .. code-block:: python 39 | 40 | # gets content in Arabic, falls back to next language 41 | # if not availble 42 | translation.activate('ar') 43 | obj.title.translate() 44 | 45 | # alternative: cast to string 46 | title_ar = str(obj.title) 47 | 48 | 49 | Specific language 50 | ***************** 51 | 52 | .. code-block:: python 53 | 54 | # gets content in Arabic, falls back to next language 55 | # if not availble 56 | obj.title.translate('ar') 57 | 58 | 59 | Without fallback 60 | ---------------- 61 | 62 | Specific language 63 | ***************** 64 | 65 | .. code-block:: python 66 | 67 | # gets content in Dutch, None if not available 68 | # no fallback to secondary languages here! 69 | obj.title.nl 70 | 71 | 72 | Specific language dynamically 73 | ***************************** 74 | 75 | .. code-block:: python 76 | 77 | # gets content in Dutch, None if not available 78 | # no fallback to secondary languages here! 79 | obj.title.get('nl') 80 | -------------------------------------------------------------------------------- /localized_fields/descriptor.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils import translation 3 | 4 | 5 | class LocalizedValueDescriptor: 6 | """The descriptor for the localized value attribute on the model instance. 7 | Returns a :see:LocalizedValue when accessed so you can do stuff like:: 8 | 9 | >>> from myapp.models import MyModel 10 | >>> instance = MyModel() 11 | >>> instance.value.en = 'English value' 12 | 13 | Assigns a strings to active language key in :see:LocalizedValue on 14 | assignment so you can do:: 15 | 16 | >>> from django.utils import translation 17 | >>> from myapp.models import MyModel 18 | 19 | >>> translation.activate('nl') 20 | >>> instance = MyModel() 21 | >>> instance.title = 'dutch title' 22 | >>> print(instance.title.nl) # prints 'dutch title' 23 | """ 24 | 25 | def __init__(self, field): 26 | """Initializes a new instance of :see:LocalizedValueDescriptor.""" 27 | 28 | self.field = field 29 | 30 | def __get__(self, instance, cls=None): 31 | if instance is None: 32 | return self 33 | 34 | # This is slightly complicated, so worth an explanation. 35 | # `instance.localizedvalue` needs to ultimately return some instance of 36 | # `LocalizedValue`, probably a subclass. 37 | 38 | # The instance dict contains whatever was originally assigned 39 | # in __set__. 40 | 41 | if self.field.name in instance.__dict__: 42 | value = instance.__dict__[self.field.name] 43 | elif not instance._state.adding: 44 | instance.refresh_from_db(fields=[self.field.name]) 45 | value = getattr(instance, self.field.name) 46 | else: 47 | value = None 48 | 49 | if value is None: 50 | attr = self.field.attr_class() 51 | instance.__dict__[self.field.name] = attr 52 | 53 | if isinstance(value, dict): 54 | attr = self.field.attr_class(value) 55 | instance.__dict__[self.field.name] = attr 56 | 57 | return instance.__dict__[self.field.name] 58 | 59 | def __set__(self, instance, value): 60 | if isinstance(value, str): 61 | language = translation.get_language() or settings.LANGUAGE_CODE 62 | self.__get__(instance).set( 63 | language, value 64 | ) # pylint: disable=no-member 65 | else: 66 | instance.__dict__[self.field.name] = value 67 | -------------------------------------------------------------------------------- /localized_fields/fields/bleach_field.py: -------------------------------------------------------------------------------- 1 | import html 2 | 3 | from django.conf import settings 4 | 5 | from .field import LocalizedField 6 | 7 | 8 | class LocalizedBleachField(LocalizedField): 9 | """Custom version of :see:BleachField that is actually a 10 | :see:LocalizedField.""" 11 | 12 | DEFAULT_SHOULD_ESCAPE = True 13 | 14 | def __init__(self, *args, escape=True, **kwargs): 15 | """Initializes a new instance of :see:LocalizedBleachField.""" 16 | 17 | self.escape = escape 18 | 19 | super().__init__(*args, **kwargs) 20 | 21 | def deconstruct(self): 22 | name, path, args, kwargs = super().deconstruct() 23 | 24 | if self.escape != self.DEFAULT_SHOULD_ESCAPE: 25 | kwargs["escape"] = self.escape 26 | 27 | return name, path, args, kwargs 28 | 29 | def pre_save(self, instance, add: bool): 30 | """Ran just before the model is saved, allows us to built the slug. 31 | 32 | Arguments: 33 | instance: 34 | The model that is being saved. 35 | 36 | add: 37 | Indicates whether this is a new entry 38 | to the database or an update. 39 | """ 40 | 41 | # the bleach library vendors dependencies and the html5lib 42 | # dependency is incompatible with python 3.9, until that's 43 | # fixed, you cannot use LocalizedBleachField with python 3.9 44 | # sympton: 45 | # ImportError: cannot import name 'Mapping' from 'collections' 46 | try: 47 | import bleach 48 | 49 | from django_bleach.utils import get_bleach_default_options 50 | except ImportError: 51 | raise UserWarning( 52 | "LocalizedBleachField is not compatible with Python 3.9 yet." 53 | ) 54 | 55 | localized_value = getattr(instance, self.attname) 56 | if not localized_value: 57 | return None 58 | 59 | for lang_code, _ in settings.LANGUAGES: 60 | value = localized_value.get(lang_code) 61 | if not value: 62 | continue 63 | 64 | cleaned_value = bleach.clean( 65 | value if self.escape else html.unescape(value), 66 | **get_bleach_default_options() 67 | ) 68 | 69 | localized_value.set( 70 | lang_code, 71 | cleaned_value if self.escape else html.unescape(cleaned_value), 72 | ) 73 | 74 | return localized_value 75 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | 1. Install the package from PyPi: 7 | 8 | .. code-block:: bash 9 | 10 | $ pip install django-localized-fields 11 | 12 | 2. Add ``django.contrib.postgres``, ``psqlextra`` and ``localized_fields`` to your ``INSTALLED_APPS``: 13 | 14 | .. code-block:: python 15 | 16 | INSTALLED_APPS = [ 17 | ... 18 | "django.contrib.postgres", 19 | "psqlextra", 20 | "localized_fields", 21 | ] 22 | 23 | 24 | 3. Set the database engine to ``psqlextra.backend``: 25 | 26 | .. code-block:: python 27 | 28 | DATABASES = { 29 | "default": { 30 | ... 31 | "ENGINE": "psqlextra.backend", 32 | ], 33 | } 34 | 35 | .. note:: 36 | 37 | Already using a custom back-end? Set ``POSTGRES_EXTRA_DB_BACKEND_BASE`` to your custom back-end. See django-postgres-extra's documentation for more details: `Using a custom database back-end `_. 38 | 39 | 4. Set ``LANGUAGES`` and ``LANGUAGE_CODE``: 40 | 41 | .. code-block:: python 42 | 43 | LANGUAGE_CODE = "en" # default language 44 | LANGUAGES = ( 45 | ("en", "English"), # default language 46 | ("ar", "Arabic"), 47 | ("ro", "Romanian"), 48 | ) 49 | 50 | 51 | .. warning:: 52 | 53 | Make sure that the language specified in ``LANGUAGE_CODE`` is the first language in the ``LANGUAGES`` list. Django and many third party packages assume that the default language is the first one in the list. 54 | 55 | 5. Apply migrations to enable the HStore extension: 56 | 57 | .. code-block:: bash 58 | 59 | $ python manage.py migrate 60 | 61 | .. note:: 62 | 63 | Migrations might fail to be applied if the PostgreSQL user applying the migration is not a super user. Enabling/creating extensions requires superuser permission. Not a superuser? Ask your database administrator to create the ``hstore`` extension on your PostgreSQL server manually using the following statement: 64 | 65 | .. code-block:: sql 66 | 67 | CREATE EXTENSION IF NOT EXISTS hstore; 68 | 69 | Then, fake apply the migration to tell Django that the migration was applied already: 70 | 71 | .. code-block:: bash 72 | 73 | python manage.py migrate localized_fields --fake 74 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib import admin 3 | from django.contrib.admin.checks import check_admin_app 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from localized_fields.admin import LocalizedFieldsAdminMixin 8 | from localized_fields.fields import LocalizedField 9 | from tests.fake_model import get_fake_model 10 | 11 | 12 | class LocalizedFieldsAdminMixinTestCase(TestCase): 13 | """Tests the :see:LocalizedFieldsAdminMixin class.""" 14 | 15 | TestModel = None 16 | TestRelModel = None 17 | 18 | @classmethod 19 | def setUpClass(cls): 20 | """Creates the test model in the database.""" 21 | 22 | super(LocalizedFieldsAdminMixinTestCase, cls).setUpClass() 23 | 24 | cls.TestRelModel = get_fake_model({"description": LocalizedField()}) 25 | cls.TestModel = get_fake_model( 26 | { 27 | "title": LocalizedField(), 28 | "rel": models.ForeignKey( 29 | cls.TestRelModel, on_delete=models.CASCADE 30 | ), 31 | } 32 | ) 33 | 34 | def tearDown(self): 35 | if admin.site.is_registered(self.TestModel): 36 | admin.site.unregister(self.TestModel) 37 | if admin.site.is_registered(self.TestRelModel): 38 | admin.site.unregister(self.TestRelModel) 39 | 40 | @classmethod 41 | def test_model_admin(cls): 42 | """Tests whether :see:LocalizedFieldsAdminMixin mixin are works with 43 | admin.ModelAdmin.""" 44 | 45 | @admin.register(cls.TestModel) 46 | class TestModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin): 47 | pass 48 | 49 | assert len(check_admin_app(apps.get_app_configs())) == 0 50 | 51 | @classmethod 52 | def test_stackedmodel_admin(cls): 53 | """Tests whether :see:LocalizedFieldsAdminMixin mixin are works with 54 | admin.StackedInline.""" 55 | 56 | class TestModelStackedInline( 57 | LocalizedFieldsAdminMixin, admin.StackedInline 58 | ): 59 | model = cls.TestModel 60 | 61 | @admin.register(cls.TestRelModel) 62 | class TestRelModelAdmin(admin.ModelAdmin): 63 | inlines = [TestModelStackedInline] 64 | 65 | assert len(check_admin_app(apps.get_app_configs())) == 0 66 | 67 | @classmethod 68 | def test_tabularmodel_admin(cls): 69 | """Tests whether :see:LocalizedFieldsAdminMixin mixin are works with 70 | admin.TabularInline.""" 71 | 72 | class TestModelTabularInline( 73 | LocalizedFieldsAdminMixin, admin.TabularInline 74 | ): 75 | model = cls.TestModel 76 | 77 | @admin.register(cls.TestRelModel) 78 | class TestRelModelAdmin(admin.ModelAdmin): 79 | inlines = [TestModelTabularInline] 80 | 81 | assert len(check_admin_app(apps.get_app_configs())) == 0 82 | -------------------------------------------------------------------------------- /docs/source/constraints.rst: -------------------------------------------------------------------------------- 1 | .. _unique_together: https://docs.djangoproject.com/en/2.2/ref/models/options/#unique-together 2 | 3 | Constraints 4 | =========== 5 | 6 | All constraints are enforced by PostgreSQL. Constraints can be applied to all fields documented in :ref:`Fields `. 7 | 8 | .. warning:: 9 | 10 | Don't forget to run ``python manage.py makemigrations`` after modifying constraints. 11 | 12 | Required/optional 13 | ----------------- 14 | 15 | 16 | * Default language required and all other languages optional: 17 | 18 | 19 | .. code-block:: python 20 | 21 | class MyModel(models.Model): 22 | title = LocalizedField() 23 | 24 | 25 | * All languages are optional and the field itself can be ``None``: 26 | 27 | .. code-block:: python 28 | 29 | class MyModel(models.Model): 30 | title = LocalizedField(blank=True, null=True, required=False) 31 | 32 | * All languages are optional but the field itself cannot be ``None``: 33 | 34 | .. code-block:: python 35 | 36 | class MyModel(models.Model): 37 | title = LocalizedField(blank=False, null=False, required=False) 38 | 39 | 40 | * Make specific languages required: 41 | 42 | .. code-block:: python 43 | 44 | class MyModel(models.Model): 45 | title = LocalizedField(blank=False, null=False, required=['en', 'ro']) 46 | 47 | 48 | * Make all languages required: 49 | 50 | 51 | .. code-block:: python 52 | 53 | class MyModel(models.Model): 54 | title = LocalizedField(blank=False, null=False, required=True) 55 | 56 | 57 | Uniqueness 58 | ---------- 59 | 60 | .. note:: 61 | 62 | Uniqueness is enforced by PostgreSQL by creating unique indexes on hstore keys. Keep this in mind when setting up unique constraints. If you already have a unique constraint in place, you do not have to add an additional index as uniqueness is enforced by creating an index. 63 | 64 | 65 | * Enforce uniqueness for one or more languages: 66 | 67 | .. code-block:: python 68 | 69 | class MyModel(models.Model): 70 | title = LocalizedField(uniqueness=['en', 'ro']) 71 | 72 | 73 | * Enforce uniqueness for all languages: 74 | 75 | .. code-block:: python 76 | 77 | from localized_fields.util import get_language_codes 78 | 79 | class MyModel(models.Model): 80 | title = LocalizedField(uniqueness=get_language_codes()) 81 | 82 | 83 | * Enforce uniqueness for one or more languages together: 84 | 85 | .. code-block:: python 86 | 87 | class MyModel(models.Model): 88 | title = LocalizedField(uniqueness=[('en', 'ro')]) 89 | 90 | This is similar to Django's `unique_together`_. 91 | 92 | 93 | * Enforce uniqueness for all languages together: 94 | 95 | .. code-block:: python 96 | 97 | from localized_fields.util import get_language_codes 98 | 99 | class MyModel(models.Model): 100 | title = LocalizedField(uniqueness=[(*get_language_codes())]) 101 | -------------------------------------------------------------------------------- /tests/test_widget.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.test import TestCase 5 | 6 | from localized_fields.value import LocalizedValue 7 | from localized_fields.widgets import LocalizedFieldWidget 8 | 9 | 10 | class LocalizedFieldWidgetTestCase(TestCase): 11 | """Tests the workings of the :see:LocalizedFieldWidget class.""" 12 | 13 | @staticmethod 14 | def test_widget_creation(): 15 | """Tests whether a widget is created for every language correctly.""" 16 | 17 | widget = LocalizedFieldWidget() 18 | assert len(widget.widgets) == len(settings.LANGUAGES) 19 | assert len(set(widget.widgets)) == len(widget.widgets) 20 | 21 | @staticmethod 22 | def test_decompress(): 23 | """Tests whether a :see:LocalizedValue instance can correctly be 24 | "decompressed" over the available widgets.""" 25 | 26 | localized_value = LocalizedValue() 27 | for lang_code, lang_name in settings.LANGUAGES: 28 | localized_value.set(lang_code, lang_name) 29 | 30 | widget = LocalizedFieldWidget() 31 | decompressed_values = widget.decompress(localized_value) 32 | 33 | for (lang_code, _), value in zip( 34 | settings.LANGUAGES, decompressed_values 35 | ): 36 | assert localized_value.get(lang_code) == value 37 | 38 | @staticmethod 39 | def test_decompress_none(): 40 | """Tests whether the :see:LocalizedFieldWidget correctly handles 41 | :see:None.""" 42 | 43 | widget = LocalizedFieldWidget() 44 | decompressed_values = widget.decompress(None) 45 | 46 | for _, value in zip(settings.LANGUAGES, decompressed_values): 47 | assert not value 48 | 49 | @staticmethod 50 | def test_get_context_required(): 51 | """Tests whether the :see:get_context correctly handles 'required' 52 | attribute, separately for each subwidget.""" 53 | 54 | widget = LocalizedFieldWidget() 55 | widget.widgets[0].is_required = True 56 | widget.widgets[1].is_required = False 57 | context = widget.get_context( 58 | name="test", value=LocalizedValue(), attrs=dict(required=True) 59 | ) 60 | assert context["widget"]["subwidgets"][0]["attrs"]["required"] 61 | assert "required" not in context["widget"]["subwidgets"][1]["attrs"] 62 | 63 | @staticmethod 64 | def test_get_context_langs(): 65 | """Tests whether the :see:get_context contains 'lang_code' and 66 | 'lang_name' attribute for each subwidget.""" 67 | 68 | widget = LocalizedFieldWidget() 69 | context = widget.get_context( 70 | name="test", value=LocalizedValue(), attrs=dict() 71 | ) 72 | subwidgets_context = context["widget"]["subwidgets"] 73 | for widget, context in zip(widget.widgets, subwidgets_context): 74 | assert "lang_code" in context 75 | assert "lang_name" in context 76 | assert widget.lang_code == context["lang_code"] 77 | assert widget.lang_name == context["lang_name"] 78 | 79 | @staticmethod 80 | def test_render(): 81 | """Tests whether the :see:LocalizedFieldWidget correctly render.""" 82 | 83 | widget = LocalizedFieldWidget() 84 | output = widget.render(name="title", value=None) 85 | assert bool(re.search(r"