diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/django_admin_widget.png b/docs/source/_static/django_admin_widget.png new file mode 100644 index 0000000..927afe1 Binary files /dev/null and b/docs/source/_static/django_admin_widget.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..faee4ff --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,10 @@ +import sphinx_rtd_theme + +project = 'django-localized-fields' +copyright = '2019, Sector Labs' +author = 'Sector Labs' +extensions = ["sphinx_rtd_theme"] +templates_path = ['_templates'] +exclude_patterns = [] +html_theme = "sphinx_rtd_theme" +html_static_path = ['_static'] diff --git a/docs/source/constraints.rst b/docs/source/constraints.rst new file mode 100644 index 0000000..4e2fc7c --- /dev/null +++ b/docs/source/constraints.rst @@ -0,0 +1,100 @@ +.. _unique_together: https://docs.djangoproject.com/en/2.2/ref/models/options/#unique-together + +Constraints +=========== + +All constraints are enforced by PostgreSQL. Constraints can be applied to all fields documented in :ref:`Fields `. + +.. warning:: + + Don't forget to run ``python manage.py makemigrations`` after modifying constraints. + +Required/optional +----------------- + + +* Default language required and all other languages optional: + + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField() + + +* All languages are optional and the field itself can be ``None``: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(blank=True, null=True, required=False) + +* All languages are optional but the field itself cannot be ``None``: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(blank=False, null=False, required=False) + + +* Make specific languages required: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(blank=False, null=False, required=['en', 'ro']) + + +* Make all languages required: + + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(blank=False, null=False, required=True) + + +Uniqueness +---------- + +.. note:: + + Uniqueness is enforced by PostgreSQL by creating unique indexes on hstore keys. Keep this is 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. + + +* Enforce uniqueness for one or more languages: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(uniqueness=['en', 'ro']) + + +* Enforce uniqueness for all languages: + + .. code-block:: python + + from localized_fields.util import get_language_codes + + class MyModel(models.Model): + title = LocalizedField(uniqueness=get_language_codes()) + + +* Enforce uniqueness for one or more languages together: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(uniqueness=[('en', 'ro')]) + + This is similar to Django's `unique_together`_. + + +* Enforce uniqueness for all languages together: + + .. code-block:: python + + from localized_fields.util import get_language_codes + + class MyModel(models.Model): + title = LocalizedField(uniqueness=[(*get_language_codes())]) diff --git a/docs/source/django_admin.rst b/docs/source/django_admin.rst new file mode 100644 index 0000000..4054b95 --- /dev/null +++ b/docs/source/django_admin.rst @@ -0,0 +1,19 @@ +Django Admin +------------ + +To display ``LocalizedFields`` as a tab bar in Django Admin; inherit your admin model class from ``LocalizedFieldsAdminMixin``: + +.. code-block:: python + + from django.contrib import admin + from myapp.models import MyLocalizedModel + + from localized_fields.admin import LocalizedFieldsAdminMixin + + class MyLocalizedModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin): + """Any admin options you need go here""" + + admin.site.register(MyLocalizedModel, MyLocalizedModelAdmin) + + +.. image:: _static/django_admin_widget.png diff --git a/docs/source/fields.rst b/docs/source/fields.rst new file mode 100644 index 0000000..2e872e7 --- /dev/null +++ b/docs/source/fields.rst @@ -0,0 +1,99 @@ +.. _django.db.models.CharField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#charfield +.. _django.db.models.TextField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#django.db.models.TextField +.. _django.db.models.IntegerField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#integerfield +.. _django.db.models.FileField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#filefield + +.. _fields: + +Localized fields +================ + +LocalizedField +-------------- + +Base localized fields. Stores content as strings of arbitrary lengths. + +.. code-block:: python + + from localized_fields.fields import LocalizedField + + +LocalizedCharField +------------------ + +Localized version of `django.db.models.CharField`_. + +Use this for single-line text content. Uses ```` when rendered as a widget. + +.. code-block:: python + + from localized_fields.fields import LocalizedCharField + +Follows the same convention as `django.db.models.CharField`_ to store empty strings for "no data" and not NULL. + + +LocalizedTextField +------------------ + +Localized version of `django.db.models.TextField`_. + +Use this for multi-line text content. + +.. code-block:: python + + from localized_fields.fields import LocalizedTextField + +Follows the same convention as `django.db.models.TextField`_ to store empty strings for "no data" and not NULL. + + +LocalizedFileField +------------------ + +Localized version of `django.db.models.FileField`_. + +.. code-block:: python + + from localized_fields.fields import LocalizedFileField + + def my_directory_path(instance, filename, lang): + # file will be uploaded to MEDIA_ROOT//_ + return '{0}/{0}_{1}'.format(lang, instance.id, filename) + + class MyModel(models.Model): + file1 = LocalizedFileField(upload_to='uploads/{lang}/') + file2 = LocalizedFileField(upload_to=my_directory_path) + + +The ``upload_to`` supports the ``{lang}}`` placeholder for string formatting or as function argument (in case if upload_to is a callable). + +In a template, you can access the files for different languages: + +.. code-block:: html + + {# For current active language: #} + + {{ model.file.url }} {# output file url #} + {{ model.file.name }} {# output file name #} + + {# Or get it in a specific language: #} + + {{ model.file.ro.url }} {# output file url for romanian language #} + {{ model.file.ro.name }} {# output file name for romanian language #} + +To get the file instance for the current language: + +.. code-block:: python + + model.file.localized() + + +LocalizedIntegerField +--------------------- + +Localized version of `django.db.models.IntegerField`_. + +.. code-block:: python + + from localized_fields.fields import LocalizedIntegerField + +Although the underlying PostgreSQL data type for ``LocalizedField`` is hstore (which only stores strings). ``LocalizedIntegerField`` takes care of making sure that input values are integers and casts the values back to integers when querying them. diff --git a/docs/source/filtering.rst b/docs/source/filtering.rst new file mode 100644 index 0000000..c67930b --- /dev/null +++ b/docs/source/filtering.rst @@ -0,0 +1,44 @@ +Filtering localized content +=========================== + +.. note:: + + All examples below assume a model declared like this: + + .. code-block:: python + + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField + + + class MyModel(LocalizedModel): + title = LocalizedField() + + +Active language +---------------- + +.. code-block:: python + + from django.utils import translation + + # filter in english + translation.activate("en") + MyModel.objects.filter(title="test") + + # filter in dutch + translation.activate("nl") + MyModel.objects.filter(title="test") + + +Specific language +----------------- + +.. code-block:: python + + MyModel.objects.filter(title__en="test") + MyModel.objects.filter(title__nl="test") + + # do it dynamically, where the language code is a var + lang_code = "nl" + MyModel.objects.filter(**{"title_%s" % lang_code: "test"}) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..980858a --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +Welcome +======= + +``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. + +This package requires Python 3.7 or newer, Django 2.0 or newer and PostgreSQL 10 or newer. + +.. toctree:: + :maxdepth: 2 + :caption: Overview + + installation + quick_start + constraints + fields + saving + querying + filtering + localized_value + django_admin + settings + releases diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..2a68347 --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,73 @@ +.. _installation: + +Installation +============ + +1. Install the package from PyPi: + + .. code-block:: bash + + $ pip install django-localized-fields + +2. Add ``django.contrib.postgres``, ``psqlextra`` and ``localized_fields`` to your ``INSTALLED_APPS``: + + .. code-block:: python + + INSTALLED_APPS = [ + ... + "django.contrib.postgres", + "psqlextra", + "localized_fields", + ] + + +3. Set the database engine to ``psqlextra.backend``: + + .. code-block:: python + + DATABASES = { + "default": { + ... + "ENGINE": "psqlextra.backend", + ], + } + + .. note:: + + 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 `_. + +4. Set ``LANGUAGES`` and ``LANGUAGE_CODE``: + + .. code-block:: python + + LANGUAGE_CODE = "en" # default language + LANGUAGES = ( + ("en", "English"), # default language + ("ar", "Arabic"), + ("ro", "Romanian"), + ) + + + .. warning:: + + 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. + +5. Apply migrations to enable the HStore extension: + + .. code-block:: bash + + $ python manage.py migrate + + .. note:: + + 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: + + .. code-block:: sql + + CREATE EXTENSION IF NOT EXISTS hstore; + + Then, fake apply the migration to tell Django that the migration was applied already: + + .. code-block:: bash + + python manage.py migrate localized_fields --fake diff --git a/docs/source/localized_value.rst b/docs/source/localized_value.rst new file mode 100644 index 0000000..c0efe2d --- /dev/null +++ b/docs/source/localized_value.rst @@ -0,0 +1,79 @@ +Working with localized values +============================= + +.. note:: + + All examples below assume a model declared like this: + + .. code-block:: python + + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField + + + class MyModel(LocalizedModel): + title = LocalizedField() + +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. + +.. code-block:: python + + from localized_fields.value import LocalizedValue + + obj = MyModel.objects.first() + assert isistance(obj.title, LocalizedValue) # True + + +With fallback +------------- + +.. seealso:: + + Configure :ref:`LOCALIZED_FIELDS_FALLBACKS ` to control the fallback behaviour. + + +Active language +*************** + +.. code-block:: python + + # gets content in Arabic, falls back to next language + # if not availble + translation.activate('ar') + obj.title.translate() + + # alternative: cast to string + title_ar = str(obj.title) + + +Specific language +***************** + +.. code-block:: python + + # gets content in Arabic, falls back to next language + # if not availble + obj.title.translate('ar') + + +Without fallback +---------------- + +Specific language +***************** + +.. code-block:: python + + # gets content in Dutch, None if not available + # no fallback to secondary languages here! + obj.title.nl + + +Specific language dynamically +***************************** + +.. code-block:: python + + # gets content in Dutch, None if not available + # no fallback to secondary languages here! + obj.title.get('nl') diff --git a/docs/source/querying.rst b/docs/source/querying.rst new file mode 100644 index 0000000..ef1cf9b --- /dev/null +++ b/docs/source/querying.rst @@ -0,0 +1,59 @@ +Querying localized content +========================== + +.. note:: + + All examples below assume a model declared like this: + + .. code-block:: python + + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField + + + class MyModel(LocalizedModel): + title = LocalizedField() + + +Active language +--------------- + +Only need a value in a specific language? Use the ``LocalizedRef`` expression to query a value in the currently active language: + +.. code-block:: python + + from localized_fields.expressions import LocalizedRef + + MyModel.objects.create(title=dict(en="Hello", nl="Hallo")) + + translation.activate("nl") + english_title = ( + MyModel + .objects + .annotate(title=LocalizedRef("title")) + .values_list("title", flat=True) + .first() + ) + + print(english_title) # prints "Hallo" + + +Specific language +----------------- + +.. code-block:: python + + from localized_fields.expressions import LocalizedRef + + result = ( + MyModel + .objects + .values( + 'title__en', + 'title__nl', + ) + .first() + ) + + print(result['title__en']) + print(result['title__nl']) diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst new file mode 100644 index 0000000..c12049f --- /dev/null +++ b/docs/source/quick_start.rst @@ -0,0 +1,140 @@ +.. _django.utils.translation.override: https://docs.djangoproject.com/en/2.2/ref/utils/#django.utils.translation.override +.. _django.db.models.TextField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#django.db.models.TextField +.. _LANGUAGES: https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-LANGUAGE_CODE +.. _LANGUAGE_CODE: https://docs.djangoproject.com/en/2.2/ref/settings/#languages + +Quick start +=========== + +.. warning:: + + This assumes you have followed the :ref:`Installation guide `. + +``django-localized-fields`` provides various model field types to store content in multiple languages. The most basic of them all is ``LocalizedField`` which stores text of arbitrary length (like `django.db.models.TextField`_). + +Declaring a model +----------------- + +.. code-block:: python + + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField + + + class MyModel(LocalizedModel): + title = LocalizedField() + + +This creates a model with one localized field. Inside the ``LocalizedField``, strings can be stored in multiple languages. There are more fields like this for different data types (integers, images etc). ``LocalizedField`` is the most basic of them all. + +Saving localized content +------------------------ + +You can now save text in ``MyModel.title`` in all languages you defined in the `LANGUAGES`_ setting. A short example: + +.. code-block:: python + + newobj = MyModel() + newobj.title.en = "Hello" + newobj.title.ar = "مرحبا" + newobj.title.nl = "Hallo" + newobj.save() + +There are various other ways of saving localized content. For example, all fields can be set at once by assigning a ``dict``: + +.. code-block:: python + + newobj = MyModel() + newobj.title = dict(en="Hello", ar="مرحبا", nl="Hallo") + newobj.save() + +This also works when using the ``create`` function: + +.. code-block:: python + + newobj = MyModel.objects.create(title=dict(en="Hello", ar="مرحبا", nl="Hallo")) + +Need to set the content dynamically? Use the ``set`` function: + +.. code-block:: python + + newobj = MyModel() + newobj.title.set("en", "Hello") + +.. note:: + + Localized field values (``localized_fields.value.LocalizedValue``) act like dictionaries. In fact, ``LocalizedValue`` extends ``dict``. Anything that works on a ``dict`` works on ``LocalizedValue``. + +Retrieving localized content +---------------------------- + +When querying, the currently active language is taken into account. If there is no active language set, the default language is returned (set by the `LANGUAGE_CODE`_ setting). + +.. code-block:: python + + from django.utils import translation + + obj = MyModel.objects.first() + + print(obj.title) # prints "Hello" + + translation.activate("ar") + print(obj.title) # prints "مرحبا" + str(obj.title) # same as printing, forces translation to active language + + translation.activate("nl") + print(obj.title) # prints "Hallo" + + +.. note:: + + Use `django.utils.translation.override`_ to change the language for just a block of code rather than setting the language globally: + + .. code-block:: python + + from django.utils import translation + + with translation.override("nl"): + print(obj.title) # prints "Hallo" + + +Fallback +******** + +If there is no content for the currently active language, a fallback kicks in where the content will be returned in the next language. The fallback order is controlled by the order set in the `LANGUAGES`_ setting. + +.. code-block:: python + + obj = MyModel.objects.create(dict(en="Hallo", ar="مرحبا")) + + translation.activate("nl") + print(obj.title) # prints "مرحبا" because there"s no content in NL + +.. seealso:: + + Use the :ref:`LOCALIZED_FIELDS_FALLBACKS ` setting to control the fallback behaviour. + + +Cast to str +*********** + +Want to get the value in the currently active language without casting to ``str``? (For null-able fields for example). Use the ``.translate()`` function: + +.. code-block:: python + + obj = MyModel.objects.create(dict(en="Hallo", ar="مرحبا")) + + str(obj.title) == obj.title.translate() # True + +.. note:: + + ``str(..)`` is guarenteed to return a string. If the value is ``None``, ``str(..)`` returns an empty string. ``translate()`` would return ``None``. This is because Python forces the ``__str__`` function to return a string. + + .. code-block:: python + + obj = MyModel.objects.create(dict(en="Hallo")) + + translation.activate('nl') + + str(obj.title) # "" + obj.title.translate() # None diff --git a/docs/source/releases.rst b/docs/source/releases.rst new file mode 100644 index 0000000..8027eab --- /dev/null +++ b/docs/source/releases.rst @@ -0,0 +1,17 @@ +Releases +======== + +v6.0 +---- + +Breaking changes +**************** + +* Removes support for Python 3.6 and earlier. +* Removes support for PostgreSQL 9.6 and earlier. +* Sets ``LOCALIZED_FIELDS_EXPERIMENTAL`` to ``True`` by default. + +Bug fixes +********* + +* Fixes a bug where ``LocalizedIntegerField`` could not be used in ``order_by``. diff --git a/docs/source/saving.rst b/docs/source/saving.rst new file mode 100644 index 0000000..d4f0def --- /dev/null +++ b/docs/source/saving.rst @@ -0,0 +1,82 @@ +Saving localized content +======================== + +.. note:: + + All examples below assume a model declared like this: + + .. code-block:: python + + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField + + + class MyModel(LocalizedModel): + title = LocalizedField() + + +Individual assignment +********************* + +.. code-block:: python + + obj = MyModel() + obj.title.en = 'Hello' + obj.title.nl = 'Hallo' + obj.save() + + +Individual dynamic assignment +***************************** + +.. code-block:: python + + obj = MyModel() + obj.title.set('en', 'Hello') + obj.title.set('nl', 'Hallo') + obj.save() + + +Multiple assignment +******************* + +.. code-block:: python + + obj = MyModel() + obj.title = dict(en='Hello', nl='Hallo') + obj.save() + + obj = MyModel(title=dict(en='Hello', nl='Hallo')) + obj.save() + + obj = MyModel.objects.create(title=dict(en='Hello', nl='Hallo')) + + +Default language assignment +*************************** + +.. code-block:: python + + obj = MyModel() + obj.title = 'Hello' # assumes value is in default language + obj.save() + + obj = MyModel(title='Hello') # assumes value is in default language + obj.save() + + obj = MyModel.objects.create(title='title') # assumes value is in default language + + +Array assignment +**************** + +.. code-block:: python + + obj = MyModel() + obj.title = ['Hello', 'Hallo'] # order according to LANGUAGES + obj.save() + + obj = MyModel(title=['Hello', 'Hallo']) # order according to LANGUAGES + obj.save() + + obj = MyModel.objects.create(title=['Hello', 'Hallo']) # order according to LANGUAGES diff --git a/docs/source/settings.rst b/docs/source/settings.rst new file mode 100644 index 0000000..7083775 --- /dev/null +++ b/docs/source/settings.rst @@ -0,0 +1,39 @@ +.. _LANGUAGES: https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-LANGUAGE_CODE +.. _LANGUAGE_CODE: https://docs.djangoproject.com/en/2.2/ref/settings/#languages + +Settings +======== + +.. LOCALIZED_FIELDS_EXPERIMENTAL: + +* ``LOCALIZED_FIELDS_EXPERIMENTAL`` + + .. note:: + + Disabled in v5.x and earlier. Enabled by default since v6.0. + + When enabled: + + * ``LocalizedField`` will return ``None`` instead of an empty ``LocalizedValue`` if there is no database value. + * ``LocalizedField`` lookups will by the currently active language instead of an exact match by dict. + + +.. _LOCALIZED_FIELDS_FALLBACKS: + +* ``LOCALIZED_FIELDS_FALLBACKS`` + + 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. + + .. warning:: + + 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. + + Use the same language codes as you used for configuring the `LANGUAGES`_ and `LANGUAGE_CODE`_ setting. + + .. code-block:: python + + LOCALIZED_FIELDS_FALLBACKS = { + "en": ["nl", "ar"], # if trying to get EN, but not available, try NL and then AR + "nl": ["en", "ar"], # if trying to get NL, but not available, try EN and then AR + "ar": ["en", "nl"], # if trying to get AR, but not available, try EN and then NL + }