diff --git a/README.rst b/README.rst index 4271b4c..ec56f0b 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,6 @@ In the pipeline We're working on making this easier to setup and use. Any feedback is apreciated. Here's a short list of things we're working to improve: * Make it unnecesarry to add anything to your `INSTALLED_APPS`. -* Make it unnecesarry to modify your migrations manually to enable the PostgreSQL HStore extension. Installation ------------ @@ -45,6 +44,17 @@ Installation 'localized_fields' ] +3. Set the database engine to ``localized_fields.db_backend``: + + .. code-block:: python + + DATABASES = { + 'default': { + ... + 'ENGINE': 'localized_fields.db_backend' + } + } + 3. Set ``LANGUAGES` and `LANGUAGE_CODE`` in your settings: .. code-block:: python @@ -72,23 +82,6 @@ Inherit your model from ``LocalizedModel`` and declare fields on your model as ` class MyModel(LocalizedModel): title = LocalizedField() - -Create your migrations using ``python manage.py makemigrations``. Open the generated migration in your favorite editor and setup the HStore extension before the first ``CreateModel`` or ``AddField`` operation by adding a migration with the `HStoreExtension` operation. For example: - -.. code-block:: python - - from django.contrib.postgres.operations import HStoreExtension - - class Migration(migrations.Migration): - ... - - operations = [ - HStoreExtension(), - ... - ] - -Then apply the migration using ``python manage.py migrate``. - ``django-localized-fields`` integrates with Django's i18n system, in order for certain languages to be available you have to correctly configure the ``LANGUAGES`` and ``LANGUAGE_CODE`` settings: .. code-block:: python @@ -100,6 +93,8 @@ Then apply the migration using ``python manage.py migrate``. ('ro', 'Romanian') ) +All the ``LocalizedField`` you define now will be available in the configured languages. + Basic usage ^^^^^^^^^^^ .. code-block:: python @@ -141,22 +136,70 @@ You can also explicitly set a value in a certain language: Constraints ^^^^^^^^^^^ -By default, the following constraints apply to a ``LocalizedField``: -* Only the default language is ``required``. The other languages are optional and can be ``NULL``. -* If ``null=True`` is specified on the ``LocalizedField``, then none of the languages are required. +**Required/Optional** + +At the moment, it is not possible to select two languages to be marked as required. The constraint is **not** enforced on a database level. + +* Make the primary language **required** and the others optional (this is the **default**): + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(required=True) + +* Make all languages optional: + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(null=True) + +**Uniqueness** + +By default the values stored in a ``LocalizedField`` are *not unique*. You can enforce uniqueness for certain languages. This uniqueness constraint is enforced on a database level using a ``UNIQUE 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 import get_language_codes + + class MyModel(models.Model): + title = LocalizedField(uniqueness=get_language_codes()) + +* Enforce uniqueness for one ore more languages **together** (similar to Django's ``unique_together``): + + .. code-block:: python + + class MyModel(models.Model): + title = LocalizedField(uniqueness=[('en', 'ro')]) + +* Enforce uniqueness for **all** languages **together**: + + .. code-block:: python + + from localized_fields import get_language_codes + + class MyModel(models.Model): + title = LocalizedField(uniqueness=[(*get_language_codes())]) -At the moment it is *not* possible to specifically instruct ``LocalizedField`` to mark certain languages as required or optional. Other fields ^^^^^^^^^^^^ Besides ``LocalizedField``, there's also: * ``LocalizedAutoSlugField`` - Automatically creates a slug for every language from the specified field. Depends upon: - * django-autoslug + Automatically creates a slug for every language from the specified field. - Currently only supports `populate_from`. Example usage: + Currently only supports ``populate_from``. Example usage: .. code-block:: python diff --git a/localized_fields/db_backend/__init__.py b/localized_fields/db_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localized_fields/db_backend/base.py b/localized_fields/db_backend/base.py new file mode 100644 index 0000000..cf74fab --- /dev/null +++ b/localized_fields/db_backend/base.py @@ -0,0 +1,176 @@ +import importlib + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db.backends.postgresql.base import \ + DatabaseWrapper as Psycopg2DatabaseWrapper + + +def _get_backend_base(): + """Gets the base class for the custom database back-end. + + This should be the Django PostgreSQL back-end. However, + some people are already using a custom back-end from + another package. We are nice people and expose an option + that allows them to configure the back-end we base upon. + + As long as the specified base eventually also has + the PostgreSQL back-end as a base, then everything should + work as intended. + """ + base_class_name = getattr( + settings, + 'LOCALIZED_FIELDS_DB_BACKEND_BASE', + 'django.db.backends.postgresql' + ) + + base_class_module = importlib.import_module(base_class_name + '.base') + base_class = getattr(base_class_module, 'DatabaseWrapper', None) + + if not base_class: + raise ImproperlyConfigured(( + '\'%s\' is not a valid database back-end.' + ' The module does not define a DatabaseWrapper class.' + ' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.' + )) + + if isinstance(base_class, Psycopg2DatabaseWrapper): + raise ImproperlyConfigured(( + '\'%s\' is not a valid database back-end.' + ' It does inherit from the PostgreSQL back-end.' + ' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.' + )) + + return base_class + + +def _get_schema_editor_base(): + """Gets the base class for the schema editor. + + We have to use the configured base back-end's + schema editor for this.""" + return _get_backend_base().SchemaEditorClass + + +class SchemaEditor(_get_schema_editor_base()): + """Custom schema editor for hstore indexes. + + This allows us to put UNIQUE constraints for + localized fields.""" + + sql_hstore_unique_create = "CREATE UNIQUE INDEX {name} ON {table}{using} ({columns}){extra}" + sql_hstore_unique_drop = "DROP INDEX IF EXISTS {name}" + + @staticmethod + def _hstore_unique_name(model, field, keys): + """Gets the name for a UNIQUE INDEX that applies + to one or more keys in a hstore field. + + Arguments: + model: + The model the field is a part of. + + field: + The hstore field to create a + UNIQUE INDEX for. + + key: + The name of the hstore key + to create the name for. + + This can also be a tuple + of multiple names. + + Returns: + The name for the UNIQUE index. + """ + postfix = '_'.join(keys) + return '{table_name}_{field_name}_unique_{postfix}'.format( + table_name=model._meta.db_table, + field_name=field.column, + postfix=postfix + ) + + def _drop_hstore_unique(self, model, field, keys): + """Drops a UNIQUE constraint for the specified hstore keys.""" + + name = self._hstore_unique_name(model, field, keys) + sql = self.sql_hstore_unique_drop.format(name=name) + self.execute(sql) + + def _create_hstore_unique(self, model, field, keys): + """Creates a UNIQUE constraint for the specified hstore keys.""" + + name = self._hstore_unique_name(model, field, keys) + columns = [ + '(%s->\'%s\')' % (field.column, key) + for key in keys + ] + sql = self.sql_hstore_unique_create.format( + name=name, + table=model._meta.db_table, + using='', + columns=','.join(columns), + extra='' + ) + self.execute(sql) + + def _update_hstore_constraints(self, model, old_field, new_field): + """Updates the UNIQUE constraints for the specified field.""" + + old_uniqueness = getattr(old_field, 'uniqueness', None) + new_uniqueness = getattr(new_field, 'uniqueness', None) + + def _compose_keys(constraint): + if isinstance(constraint, str): + return [constraint] + + return constraint + + # drop any old uniqueness constraints + if old_uniqueness: + for keys in old_uniqueness: + self._drop_hstore_unique( + model, + old_field, + _compose_keys(keys) + ) + + # (re-)create uniqueness constraints + if new_uniqueness: + for keys in new_uniqueness: + self._create_hstore_unique( + model, + old_field, + _compose_keys(keys) + ) + + def _alter_field(self, model, old_field, new_field, *args, **kwargs): + """Ran when the configuration on a field changed.""" + + super()._alter_field( + model, old_field, new_field, + *args, **kwargs + ) + + self._update_hstore_constraints(model, old_field, new_field) + + +class DatabaseWrapper(_get_backend_base()): + """Wraps the standard PostgreSQL database back-end. + + Overrides the schema editor with our custom + schema editor and makes sure the `hstore` + extension is enabled.""" + + SchemaEditorClass = SchemaEditor + + def prepare_database(self): + """Ran to prepare the configured database. + + This is where we enable the `hstore` extension + if it wasn't enabled yet.""" + + super().prepare_database() + with self.cursor() as cursor: + cursor.execute('CREATE EXTENSION IF NOT EXISTS hstore') diff --git a/localized_fields/fields/localized_autoslug_field.py b/localized_fields/fields/localized_autoslug_field.py index 7476f70..2f2a245 100644 --- a/localized_fields/fields/localized_autoslug_field.py +++ b/localized_fields/fields/localized_autoslug_field.py @@ -4,7 +4,6 @@ from django import forms from django.conf import settings from django.utils.text import slugify -from ..forms import LocalizedFieldForm from .localized_field import LocalizedField from .localized_value import LocalizedValue diff --git a/localized_fields/fields/localized_field.py b/localized_fields/fields/localized_field.py index 156e631..39566c7 100644 --- a/localized_fields/fields/localized_field.py +++ b/localized_fields/fields/localized_field.py @@ -14,11 +14,13 @@ class LocalizedField(HStoreField): Meta = None - def __init__(self, *args, **kwargs): + def __init__(self, *args, uniqueness=None, **kwargs): """Initializes a new instance of :see:LocalizedValue.""" super(LocalizedField, self).__init__(*args, **kwargs) + self.uniqueness = uniqueness + @staticmethod def from_db_value(value, *_): """Turns the specified database value into its Python @@ -157,3 +159,14 @@ class LocalizedField(HStoreField): defaults.update(kwargs) return super().formfield(**defaults) + + def deconstruct(self): + """Gets the values to pass to :see:__init__ when + re-creating this object.""" + + values = super(LocalizedField, self).deconstruct() + values[3].update({ + 'uniqueness': self.uniqueness + }) + + return values diff --git a/localized_fields/hstore_index.py b/localized_fields/hstore_index.py index 334de3d..352d03f 100644 --- a/localized_fields/hstore_index.py +++ b/localized_fields/hstore_index.py @@ -1,3 +1,5 @@ +"""This module is unused, but should be contributed to Django.""" + from typing import List from django.db import models