mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-04-25 11:42:54 +03:00
Added custom back-end to allow uniqueness
This commit is contained in:
parent
ed1559ec31
commit
680383b636
93
README.rst
93
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:
|
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 add anything to your `INSTALLED_APPS`.
|
||||||
* Make it unnecesarry to modify your migrations manually to enable the PostgreSQL HStore extension.
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
@ -45,6 +44,17 @@ Installation
|
|||||||
'localized_fields'
|
'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:
|
3. Set ``LANGUAGES` and `LANGUAGE_CODE`` in your settings:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
@ -72,23 +82,6 @@ Inherit your model from ``LocalizedModel`` and declare fields on your model as `
|
|||||||
class MyModel(LocalizedModel):
|
class MyModel(LocalizedModel):
|
||||||
title = LocalizedField()
|
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:
|
``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
|
.. code-block:: python
|
||||||
@ -100,6 +93,8 @@ Then apply the migration using ``python manage.py migrate``.
|
|||||||
('ro', 'Romanian')
|
('ro', 'Romanian')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
All the ``LocalizedField`` you define now will be available in the configured languages.
|
||||||
|
|
||||||
Basic usage
|
Basic usage
|
||||||
^^^^^^^^^^^
|
^^^^^^^^^^^
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
@ -141,22 +136,70 @@ You can also explicitly set a value in a certain language:
|
|||||||
|
|
||||||
Constraints
|
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``.
|
**Required/Optional**
|
||||||
* If ``null=True`` is specified on the ``LocalizedField``, then none of the languages are required.
|
|
||||||
|
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
|
Other fields
|
||||||
^^^^^^^^^^^^
|
^^^^^^^^^^^^
|
||||||
Besides ``LocalizedField``, there's also:
|
Besides ``LocalizedField``, there's also:
|
||||||
|
|
||||||
* ``LocalizedAutoSlugField``
|
* ``LocalizedAutoSlugField``
|
||||||
Automatically creates a slug for every language from the specified field. Depends upon:
|
Automatically creates a slug for every language from the specified field.
|
||||||
* django-autoslug
|
|
||||||
|
|
||||||
Currently only supports `populate_from`. Example usage:
|
Currently only supports ``populate_from``. Example usage:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
0
localized_fields/db_backend/__init__.py
Normal file
0
localized_fields/db_backend/__init__.py
Normal file
176
localized_fields/db_backend/base.py
Normal file
176
localized_fields/db_backend/base.py
Normal file
@ -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')
|
@ -4,7 +4,6 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from ..forms import LocalizedFieldForm
|
|
||||||
from .localized_field import LocalizedField
|
from .localized_field import LocalizedField
|
||||||
from .localized_value import LocalizedValue
|
from .localized_value import LocalizedValue
|
||||||
|
|
||||||
|
@ -14,11 +14,13 @@ class LocalizedField(HStoreField):
|
|||||||
|
|
||||||
Meta = None
|
Meta = None
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, uniqueness=None, **kwargs):
|
||||||
"""Initializes a new instance of :see:LocalizedValue."""
|
"""Initializes a new instance of :see:LocalizedValue."""
|
||||||
|
|
||||||
super(LocalizedField, self).__init__(*args, **kwargs)
|
super(LocalizedField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.uniqueness = uniqueness
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_db_value(value, *_):
|
def from_db_value(value, *_):
|
||||||
"""Turns the specified database value into its Python
|
"""Turns the specified database value into its Python
|
||||||
@ -157,3 +159,14 @@ class LocalizedField(HStoreField):
|
|||||||
|
|
||||||
defaults.update(kwargs)
|
defaults.update(kwargs)
|
||||||
return super().formfield(**defaults)
|
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
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""This module is unused, but should be contributed to Django."""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
Loading…
x
Reference in New Issue
Block a user