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 |
4 | {% for widget in widget.subwidgets %}
5 |
6 |
7 |
8 | {% endfor %}
9 |
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"