mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-10-18 22:08:58 +03:00
Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
97c14fd2ba | ||
|
6cb4cdf52e | ||
|
bcb2ff0092 | ||
|
43a48403e9 | ||
|
f453c44a73 | ||
|
3cb8b04195 | ||
|
3f8fc77c4d | ||
|
9245c85e5d | ||
|
818a0a2fe3 | ||
|
c206005cae | ||
|
3850c34374 | ||
|
99c8830f10 | ||
|
d7bd217a90 | ||
|
b9a4d3be2c | ||
|
6d7a937eac | ||
|
2e9b83e49b | ||
|
679dcafef6 | ||
|
ad2ef34546 | ||
|
1317023160 | ||
|
ca6b1c88fa | ||
|
64c3c06612 | ||
|
b121dfc2d7 | ||
|
d529da8886 | ||
|
ca879087ea | ||
|
302a64a02c | ||
|
bb11253207 | ||
|
5db87763fb |
@@ -1,6 +1,3 @@
|
||||
[MASTER]
|
||||
load-plugins=pylint_common, pylint_django
|
||||
|
||||
[FORMAT]
|
||||
max-line-length=120
|
||||
|
||||
|
31
.scrutinizer.yml
Normal file
31
.scrutinizer.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
checks:
|
||||
python:
|
||||
code_rating: true
|
||||
duplicate_code: true
|
||||
tools:
|
||||
pylint:
|
||||
python_version: '3'
|
||||
config_file: .pylintrc
|
||||
filter:
|
||||
excluded_paths:
|
||||
- '*/tests/*'
|
||||
- '*/migrations/*'
|
||||
build:
|
||||
environment:
|
||||
python: '3.5.0'
|
||||
node: 'v6.2.0'
|
||||
variables:
|
||||
DJANGO_SETTINGS_MODULES: settings
|
||||
DATABASE_URL: postgres://scrutinizer:scrutinizer@localhost:5434/localized_fields
|
||||
postgresql: true
|
||||
redis: true
|
||||
dependencies:
|
||||
override:
|
||||
- 'pip install -r requirements/test.txt'
|
||||
tests:
|
||||
override:
|
||||
-
|
||||
command: coverage run manage.py test
|
||||
coverage:
|
||||
file: '.coverage'
|
||||
format: 'py-cc'
|
88
README.rst
88
README.rst
@@ -7,24 +7,14 @@ django-localized-fields
|
||||
.. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/coverage.png
|
||||
:target: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/
|
||||
|
||||
.. image:: https://travis-ci.com/SectorLabs/django-localized-fields.svg?token=sFgxhDFpypxkMcvhRoSz&branch=master
|
||||
:target: https://travis-ci.com/SectorLabs/django-localized-fields
|
||||
.. image:: https://img.shields.io/github/license/SectorLabs/django-localized-fields.svg
|
||||
|
||||
.. image:: https://badge.fury.io/py/django-localized-fields.svg
|
||||
:target: https://pypi.python.org/pypi/django-localized-fields
|
||||
|
||||
.. image:: https://img.shields.io/github/license/SectorLabs/django-localized-fields.svg
|
||||
|
||||
``django-localized-fields`` is an implementation of a field class for Django models that allows the field's value to be set in multiple languages. It does this by utilizing the ``hstore`` type (PostgreSQL specific), which is available as ``models.HStoreField`` in Django 1.10.
|
||||
|
||||
This package requires Python 3.5 or newer and Django 1.10 or newer.
|
||||
|
||||
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`.
|
||||
* Move generic PostgreSQL code to a separate package.
|
||||
This package requires Python 3.5 or newer, Django 1.10 or newer and PostgreSQL 9.6 or newer.
|
||||
|
||||
Installation
|
||||
------------
|
||||
@@ -45,14 +35,14 @@ Installation
|
||||
'localized_fields'
|
||||
]
|
||||
|
||||
3. Set the database engine to ``localized_fields.db_backend``:
|
||||
3. Set the database engine to ``psqlextra.backend``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
...
|
||||
'ENGINE': 'localized_fields.db_backend'
|
||||
'ENGINE': 'psqlextra.backend'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,14 +136,14 @@ At the moment, it is not possible to select two languages to be marked as requir
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField(required=True)
|
||||
|
||||
* Make all languages optional:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField(null=True)
|
||||
|
||||
**Uniqueness**
|
||||
@@ -164,7 +154,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField(uniqueness=['en', 'ro'])
|
||||
|
||||
* Enforce uniqueness for **all** languages:
|
||||
@@ -173,14 +163,14 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e
|
||||
|
||||
from localized_fields import get_language_codes
|
||||
|
||||
class MyModel(models.Model):
|
||||
class MyModel(LocalizedModel):
|
||||
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):
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField(uniqueness=[('en', 'ro')])
|
||||
|
||||
* Enforce uniqueness for **all** languages **together**:
|
||||
@@ -189,7 +179,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e
|
||||
|
||||
from localized_fields import get_language_codes
|
||||
|
||||
class MyModel(models.Model):
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField(uniqueness=[(*get_language_codes())])
|
||||
|
||||
|
||||
@@ -197,19 +187,29 @@ Other fields
|
||||
^^^^^^^^^^^^
|
||||
Besides ``LocalizedField``, there's also:
|
||||
|
||||
* ``LocalizedMagicSlugField``
|
||||
* ``LocalizedUniqueSlugField``
|
||||
Successor of ``LocalizedAutoSlugField`` that fixes concurrency issues and enforces
|
||||
uniqueness of slugs on a database level. Usage is the exact same:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from localized_fields.models import LocalizedModel
|
||||
from localized_fields.fields import (LocalizedField,
|
||||
LocalizedMagicSlugField)
|
||||
from localized_fields import (LocalizedModel,
|
||||
AtomicSlugRetryMixin,
|
||||
LocalizedField,
|
||||
LocalizedUniqueSlugField)
|
||||
|
||||
class MyModel(LocalizedModel):
|
||||
class MyModel(AtomicSlugRetryMixin, LocalizedModel):
|
||||
title = LocalizedField()
|
||||
slug = LocalizedMagicSlugField(populate_from='title')
|
||||
slug = LocalizedUniqueSlugField(populate_from='title')
|
||||
|
||||
By setting the option ``include_time=True``
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
slug = LocalizedUniqueSlugField(populate_from='title', include_time=True)
|
||||
|
||||
You can instruct the field to include a part of the current time into
|
||||
the resulting slug. This is useful if you're running into a lot of collisions.
|
||||
|
||||
* ``LocalizedAutoSlugField``
|
||||
Automatically creates a slug for every language from the specified field.
|
||||
@@ -218,15 +218,15 @@ Besides ``LocalizedField``, there's also:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from localized_fields.models import LocalizedModel
|
||||
from localized_fields.fields import (LocalizedField,
|
||||
LocalizedAutoSlugField)
|
||||
from localized_fields import (LocalizedModel,
|
||||
LocalizedField,
|
||||
LocalizedUniqueSlugField)
|
||||
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField()
|
||||
slug = LocalizedAutoSlugField(populate_from='title')
|
||||
|
||||
This implementation is **NOT** concurrency safe, prefer ``LocalizedMagicSlugField``.
|
||||
This implementation is **NOT** concurrency safe, prefer ``LocalizedUniqueSlugField``.
|
||||
|
||||
* ``LocalizedBleachField``
|
||||
Automatically bleaches the content of the field.
|
||||
@@ -236,9 +236,9 @@ Besides ``LocalizedField``, there's also:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from localized_fields.models import LocalizedModel
|
||||
from localized_fields.fields import (LocalizedField,
|
||||
LocalizedBleachField)
|
||||
from localized_fields import (LocalizedModel,
|
||||
LocalizedField,
|
||||
LocalizedBleachField)
|
||||
|
||||
class MyModel(LocalizedModel):
|
||||
title = LocalizedField()
|
||||
@@ -247,34 +247,26 @@ Besides ``LocalizedField``, there's also:
|
||||
Frequently asked questions (FAQ)
|
||||
--------------------------------
|
||||
|
||||
1. Why do I need to change the database back-end/engine?
|
||||
|
||||
We utilize PostgreSQL's `hstore` data type, which allows you to store key-value pairs in a column. In order to create `UNIQUE` constraints on specific key, we need to create a special type of index. We could do this without a custom database back-end, but it would require everyone to manually write their migrations. By using a custom database back-end, we added support for this. When changing the `uniqueness` constraint on a `LocalizedField`, our custom database back-end takes care of creating, updating and deleting these constraints/indexes in the database.
|
||||
|
||||
2. I am already using a custom database back-end, can I still use yours?
|
||||
|
||||
Yes. You can set the ``LOCALIZED_FIELDS_DB_BACKEND_BASE`` setting to your current back-end. This will instruct our custom database back-end to inherit from the database back-end you specified. **Warning**: this will only work if the base you specified indirectly inherits from the standard PostgreSQL database back-end.
|
||||
|
||||
3. Does this package work with Python 2?
|
||||
1. Does this package work with Python 2?
|
||||
|
||||
No. Only Python 3.5 or newer is supported. We're using type hints. These do not work well under older versions of Python.
|
||||
|
||||
4. Does this package work with Django 1.X?
|
||||
2. Does this package work with Django 1.X?
|
||||
|
||||
No. Only Django 1.10 or newer is supported. This is because we rely on Django's ``HStoreField``.
|
||||
|
||||
5. Does this package come with support for Django Admin?
|
||||
3. Does this package come with support for Django Admin?
|
||||
|
||||
Yes. Our custom fields come with a special form that will automatically be used in Django Admin if the field is of ``LocalizedField``.
|
||||
|
||||
7. Why should I pick this over any of the other translation packages out there?
|
||||
4. Why should I pick this over any of the other translation packages out there?
|
||||
|
||||
You should pick whatever you feel comfortable with. This package stores translations in your database without having to have translation tables. It however only works on PostgreSQL.
|
||||
|
||||
8. I am using PostgreSQL <8.4, can I use this?
|
||||
5. I am using PostgreSQL <9.6, can I use this?
|
||||
|
||||
No. The ``hstore`` data type was introduced in PostgreSQL 8.4.
|
||||
No. The ``hstore`` data type was introduced in PostgreSQL 9.6.
|
||||
|
||||
9. I am using this package. Can I give you some beer?
|
||||
6. I am using this package. Can I give you some beer?
|
||||
|
||||
Yes! If you're ever in the area of Cluj-Napoca, Romania, swing by :)
|
||||
|
@@ -1,18 +1,20 @@
|
||||
from .util import get_language_codes
|
||||
from .forms import LocalizedFieldForm, LocalizedFieldWidget
|
||||
from .fields import (LocalizedField, LocalizedBleachField,
|
||||
LocalizedAutoSlugField, LocalizedMagicSlugField)
|
||||
from .localized_value import LocalizedValue
|
||||
LocalizedAutoSlugField, LocalizedUniqueSlugField)
|
||||
from .mixins import AtomicSlugRetryMixin
|
||||
from .models import LocalizedModel
|
||||
from .localized_value import LocalizedValue
|
||||
|
||||
__all__ = [
|
||||
'get_language_codes',
|
||||
'LocalizedField',
|
||||
'LocalizedValue',
|
||||
'LocalizedAutoSlugField',
|
||||
'LocalizedMagicSlugField',
|
||||
'LocalizedUniqueSlugField',
|
||||
'LocalizedBleachField',
|
||||
'LocalizedFieldWidget',
|
||||
'LocalizedFieldForm',
|
||||
'LocalizedModel'
|
||||
'LocalizedModel',
|
||||
'AtomicSlugRetryMixin'
|
||||
]
|
||||
|
@@ -1,224 +0,0 @@
|
||||
import importlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.backends.postgresql.base import \
|
||||
DatabaseWrapper as Psycopg2DatabaseWrapper
|
||||
|
||||
from ..fields import LocalizedField
|
||||
|
||||
|
||||
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 _apply_hstore_constraints(self, method, model, field):
|
||||
"""Creates/drops UNIQUE constraints for a field."""
|
||||
|
||||
def _compose_keys(constraint):
|
||||
if isinstance(constraint, str):
|
||||
return [constraint]
|
||||
|
||||
return constraint
|
||||
|
||||
uniqueness = getattr(field, 'uniqueness', None)
|
||||
if not uniqueness:
|
||||
return
|
||||
|
||||
for keys in uniqueness:
|
||||
method(
|
||||
model,
|
||||
field,
|
||||
_compose_keys(keys)
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# drop any old uniqueness constraints
|
||||
if old_uniqueness:
|
||||
self._apply_hstore_constraints(
|
||||
self._drop_hstore_unique,
|
||||
model,
|
||||
old_field
|
||||
)
|
||||
|
||||
# (re-)create uniqueness constraints
|
||||
if new_uniqueness:
|
||||
self._apply_hstore_constraints(
|
||||
self._create_hstore_unique,
|
||||
model,
|
||||
new_field
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
is_old_field_localized = isinstance(old_field, LocalizedField)
|
||||
is_new_field_localized = isinstance(new_field, LocalizedField)
|
||||
|
||||
if is_old_field_localized or is_new_field_localized:
|
||||
self._update_hstore_constraints(model, old_field, new_field)
|
||||
|
||||
def create_model(self, model):
|
||||
"""Ran when a new model is created."""
|
||||
|
||||
super().create_model(model)
|
||||
|
||||
for field in model._meta.local_fields:
|
||||
if not isinstance(field, LocalizedField):
|
||||
continue
|
||||
|
||||
self._apply_hstore_constraints(
|
||||
self._create_hstore_unique,
|
||||
model,
|
||||
field
|
||||
)
|
||||
|
||||
def delete_model(self, model):
|
||||
"""Ran when a model is being deleted."""
|
||||
|
||||
super().delete_model(model)
|
||||
|
||||
for field in model._meta.local_fields:
|
||||
if not isinstance(field, LocalizedField):
|
||||
continue
|
||||
|
||||
self._apply_hstore_constraints(
|
||||
self._drop_hstore_unique,
|
||||
model,
|
||||
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')
|
@@ -1,12 +1,12 @@
|
||||
from .localized_field import LocalizedField
|
||||
from .localized_autoslug_field import LocalizedAutoSlugField
|
||||
from .localized_magicslug_field import LocalizedMagicSlugField
|
||||
from .localized_uniqueslug_field import LocalizedUniqueSlugField
|
||||
from .localized_bleach_field import LocalizedBleachField
|
||||
|
||||
|
||||
__all__ = [
|
||||
'LocalizedField',
|
||||
'LocalizedAutoSlugField',
|
||||
'LocalizedMagicSlugField',
|
||||
'LocalizedUniqueSlugField',
|
||||
'LocalizedBleachField',
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -16,6 +17,7 @@ class LocalizedAutoSlugField(LocalizedField):
|
||||
"""Initializes a new instance of :see:LocalizedAutoSlugField."""
|
||||
|
||||
self.populate_from = kwargs.pop('populate_from', None)
|
||||
self.include_time = kwargs.pop('include_time', False)
|
||||
|
||||
super(LocalizedAutoSlugField, self).__init__(
|
||||
*args,
|
||||
@@ -30,6 +32,7 @@ class LocalizedAutoSlugField(LocalizedField):
|
||||
LocalizedAutoSlugField, self).deconstruct()
|
||||
|
||||
kwargs['populate_from'] = self.populate_from
|
||||
kwargs['include_time'] = self.include_time
|
||||
return name, path, args, kwargs
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
@@ -76,6 +79,9 @@ class LocalizedAutoSlugField(LocalizedField):
|
||||
if not value:
|
||||
continue
|
||||
|
||||
if self.include_time:
|
||||
value += '-%s' % datetime.now().microsecond
|
||||
|
||||
def is_unique(slug: str, language: str) -> bool:
|
||||
"""Gets whether the specified slug is unique."""
|
||||
|
||||
|
@@ -1,8 +1,9 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import HStoreField
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from localized_fields import LocalizedFieldForm
|
||||
from psqlextra.fields import HStoreField
|
||||
|
||||
from ..localized_value import LocalizedValue
|
||||
|
||||
|
||||
@@ -14,13 +15,11 @@ class LocalizedField(HStoreField):
|
||||
|
||||
Meta = None
|
||||
|
||||
def __init__(self, *args, uniqueness=None, **kwargs):
|
||||
"""Initializes a new instance of :see:LocalizedValue."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initializes a new instance of :see:LocalizedField."""
|
||||
|
||||
super(LocalizedField, self).__init__(*args, **kwargs)
|
||||
|
||||
self.uniqueness = uniqueness
|
||||
|
||||
@staticmethod
|
||||
def from_db_value(value, *_):
|
||||
"""Turns the specified database value into its Python
|
||||
|
@@ -1,68 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
|
||||
from ..localized_value import LocalizedValue
|
||||
from .localized_autoslug_field import LocalizedAutoSlugField
|
||||
from ..util import get_language_codes
|
||||
|
||||
|
||||
class LocalizedMagicSlugField(LocalizedAutoSlugField):
|
||||
"""Automatically provides slugs for a localized
|
||||
field upon saving."
|
||||
|
||||
An improved version of :see:LocalizedAutoSlugField,
|
||||
which adds:
|
||||
|
||||
- Concurrency safety
|
||||
- Improved performance
|
||||
|
||||
When in doubt, use this over :see:LocalizedAutoSlugField.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initializes a new instance of :see:LocalizedMagicSlugField."""
|
||||
|
||||
self.populate_from = kwargs.pop('populate_from')
|
||||
kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes())
|
||||
|
||||
super(LocalizedAutoSlugField, self).__init__(
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def pre_save(self, instance, add: bool):
|
||||
"""Ran just before the model is saved, allows us to built
|
||||
the slug.
|
||||
|
||||
Arguments:
|
||||
instance:
|
||||
The model that is being saved.
|
||||
|
||||
add:
|
||||
Indicates whether this is a new entry
|
||||
to the database or an update.
|
||||
|
||||
Returns:
|
||||
The localized slug that was generated.
|
||||
"""
|
||||
|
||||
slugs = LocalizedValue()
|
||||
|
||||
for lang_code, _ in settings.LANGUAGES:
|
||||
value = self._get_populate_from_value(
|
||||
instance,
|
||||
self.populate_from,
|
||||
lang_code
|
||||
)
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
slug = slugify(value, allow_unicode=True)
|
||||
if instance.retries > 0:
|
||||
slug += '-%d' % instance.retries
|
||||
|
||||
slugs.set(lang_code, slug)
|
||||
|
||||
setattr(instance, self.name, slugs)
|
||||
return slugs
|
110
localized_fields/fields/localized_uniqueslug_field.py
Normal file
110
localized_fields/fields/localized_uniqueslug_field.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from ..util import get_language_codes
|
||||
from ..mixins import AtomicSlugRetryMixin
|
||||
from ..localized_value import LocalizedValue
|
||||
from .localized_autoslug_field import LocalizedAutoSlugField
|
||||
|
||||
|
||||
class LocalizedUniqueSlugField(LocalizedAutoSlugField):
|
||||
"""Automatically provides slugs for a localized
|
||||
field upon saving."
|
||||
|
||||
An improved version of :see:LocalizedAutoSlugField,
|
||||
which adds:
|
||||
|
||||
- Concurrency safety
|
||||
- Improved performance
|
||||
|
||||
When in doubt, use this over :see:LocalizedAutoSlugField.
|
||||
Inherit from :see:AtomicSlugRetryMixin in your model to
|
||||
make this field work properly.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initializes a new instance of :see:LocalizedUniqueSlugField."""
|
||||
|
||||
kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes())
|
||||
|
||||
super(LocalizedUniqueSlugField, self).__init__(
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self.populate_from = kwargs.pop('populate_from')
|
||||
self.include_time = kwargs.pop('include_time', False)
|
||||
|
||||
def deconstruct(self):
|
||||
"""Deconstructs the field into something the database
|
||||
can store."""
|
||||
|
||||
name, path, args, kwargs = super(
|
||||
LocalizedUniqueSlugField, self).deconstruct()
|
||||
|
||||
kwargs['populate_from'] = self.populate_from
|
||||
kwargs['include_time'] = self.include_time
|
||||
return name, path, args, kwargs
|
||||
|
||||
def pre_save(self, instance, add: bool):
|
||||
"""Ran just before the model is saved, allows us to built
|
||||
the slug.
|
||||
|
||||
Arguments:
|
||||
instance:
|
||||
The model that is being saved.
|
||||
|
||||
add:
|
||||
Indicates whether this is a new entry
|
||||
to the database or an update.
|
||||
|
||||
Returns:
|
||||
The localized slug that was generated.
|
||||
"""
|
||||
|
||||
if not isinstance(instance, AtomicSlugRetryMixin):
|
||||
raise ImproperlyConfigured((
|
||||
'Model \'%s\' does not inherit from AtomicSlugRetryMixin. '
|
||||
'Without this, the LocalizedUniqueSlugField will not work.'
|
||||
) % type(instance).__name__)
|
||||
|
||||
slugs = LocalizedValue()
|
||||
|
||||
for lang_code, _ in settings.LANGUAGES:
|
||||
value = self._get_populate_from_value(
|
||||
instance,
|
||||
self.populate_from,
|
||||
lang_code
|
||||
)
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
slug = slugify(value, allow_unicode=True)
|
||||
|
||||
# verify whether it's needed to re-generate a slug,
|
||||
# if not, re-use the same slug
|
||||
if instance.pk is not None:
|
||||
current_slug = getattr(instance, self.name).get(lang_code)
|
||||
if current_slug is not None:
|
||||
stripped_slug = current_slug[0:current_slug.rfind('-')]
|
||||
if slug == stripped_slug:
|
||||
slugs.set(lang_code, current_slug)
|
||||
continue
|
||||
|
||||
if self.include_time:
|
||||
slug += '-%d' % datetime.now().microsecond
|
||||
|
||||
if instance.retries > 0:
|
||||
# do not add another - if we already added time
|
||||
if not self.include_time:
|
||||
slug += '-'
|
||||
slug += '%d' % instance.retries
|
||||
|
||||
slugs.set(lang_code, slug)
|
||||
|
||||
setattr(instance, self.name, slugs)
|
||||
return slugs
|
@@ -76,6 +76,21 @@ class LocalizedValue:
|
||||
|
||||
return value or ''
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Compares :paramref:self to :paramref:other for
|
||||
equality.
|
||||
|
||||
Returns:
|
||||
True when :paramref:self is equal to :paramref:other.
|
||||
And False when they are not.
|
||||
"""
|
||||
|
||||
for lang_code, _ in settings.LANGUAGES:
|
||||
if self.get(lang_code) != other.get(lang_code):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __repr__(self): # pragma: no cover
|
||||
"""Gets a textual representation of this object."""
|
||||
|
||||
|
38
localized_fields/mixins.py
Normal file
38
localized_fields/mixins.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
|
||||
class AtomicSlugRetryMixin:
|
||||
"""Makes :see:LocalizedUniqueSlugField work by retrying upon
|
||||
violation of the UNIQUE constraint."""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Saves this model instance to the database."""
|
||||
|
||||
max_retries = getattr(
|
||||
settings,
|
||||
'LOCALIZED_FIELDS_MAX_RETRIES',
|
||||
100
|
||||
)
|
||||
|
||||
if not hasattr(self, 'retries'):
|
||||
self.retries = 0
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
return super().save(*args, **kwargs)
|
||||
except IntegrityError as ex:
|
||||
# this is as retarded as it looks, there's no
|
||||
# way we can put the retry logic inside the slug
|
||||
# field class... we can also not only catch exceptions
|
||||
# that apply to slug fields... so yea.. this is as
|
||||
# retarded as it gets... i am sorry :(
|
||||
if 'slug' not in str(ex):
|
||||
raise ex
|
||||
|
||||
if self.retries >= max_retries:
|
||||
raise ex
|
||||
|
||||
self.retries += 1
|
||||
return self.save()
|
@@ -1,12 +1,10 @@
|
||||
from django.db import models, transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.conf import settings
|
||||
from psqlextra.models import PostgresModel
|
||||
|
||||
from .fields import LocalizedField
|
||||
from .localized_value import LocalizedValue
|
||||
|
||||
|
||||
class LocalizedModel(models.Model):
|
||||
class LocalizedModel(PostgresModel):
|
||||
"""A model that contains localized fields."""
|
||||
|
||||
class Meta:
|
||||
@@ -34,33 +32,3 @@ class LocalizedModel(models.Model):
|
||||
value = LocalizedValue()
|
||||
|
||||
setattr(self, field.name, value)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Saves this model instance to the database."""
|
||||
|
||||
max_retries = getattr(
|
||||
settings,
|
||||
'LOCALIZED_FIELDS_MAX_RETRIES',
|
||||
100
|
||||
)
|
||||
|
||||
if not hasattr(self, 'retries'):
|
||||
self.retries = 0
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
return super(LocalizedModel, self).save(*args, **kwargs)
|
||||
except IntegrityError as ex:
|
||||
# this is as retarded as it looks, there's no
|
||||
# way we can put the retry logic inside the slug
|
||||
# field class... we can also not only catch exceptions
|
||||
# that apply to slug fields... so yea.. this is as
|
||||
# retarded as it gets... i am sorry :(
|
||||
if 'slug' not in str(ex):
|
||||
raise ex
|
||||
|
||||
if self.retries >= max_retries:
|
||||
raise ex
|
||||
|
||||
self.retries += 1
|
||||
return self.save()
|
||||
|
1
requirements/base.txt
Normal file
1
requirements/base.txt
Normal file
@@ -0,0 +1 @@
|
||||
django-postgres-extra==1.2
|
@@ -1,3 +1,5 @@
|
||||
-r base.txt
|
||||
|
||||
coverage==4.2
|
||||
Django==1.10.2
|
||||
django-autoslug==1.9.3
|
||||
|
@@ -11,7 +11,7 @@ DATABASES = {
|
||||
'default': dj_database_url.config(default='postgres:///localized_fields')
|
||||
}
|
||||
|
||||
DATABASES['default']['ENGINE'] = 'localized_fields.db_backend'
|
||||
DATABASES['default']['ENGINE'] = 'psqlextra.backend'
|
||||
|
||||
LANGUAGE_CODE = 'en'
|
||||
LANGUAGES = (
|
||||
|
5
setup.py
5
setup.py
@@ -7,7 +7,7 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme:
|
||||
|
||||
setup(
|
||||
name='django-localized-fields',
|
||||
version='2.5',
|
||||
version='3.3',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
license='MIT License',
|
||||
@@ -17,6 +17,9 @@ setup(
|
||||
author='Sector Labs',
|
||||
author_email='open-source@sectorlabs.ro',
|
||||
keywords=['django', 'localized', 'language', 'models', 'fields'],
|
||||
install_requires=[
|
||||
'django-postgres-extra>=1.3'
|
||||
],
|
||||
classifiers=[
|
||||
'Environment :: Web Environment',
|
||||
'Framework :: Django',
|
||||
|
@@ -2,7 +2,7 @@ from django.db import connection, migrations
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.contrib.postgres.operations import HStoreExtension
|
||||
|
||||
from localized_fields import LocalizedModel
|
||||
from localized_fields import LocalizedModel, AtomicSlugRetryMixin
|
||||
|
||||
|
||||
def define_fake_model(name='TestModel', fields=None):
|
||||
@@ -14,7 +14,7 @@ def define_fake_model(name='TestModel', fields=None):
|
||||
|
||||
if fields:
|
||||
attributes.update(fields)
|
||||
model = type(name, (LocalizedModel,), attributes)
|
||||
model = type(name, (AtomicSlugRetryMixin,LocalizedModel,), attributes)
|
||||
|
||||
return model
|
||||
|
||||
|
@@ -1,87 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.db import connection
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from localized_fields import LocalizedField, get_language_codes
|
||||
|
||||
from .fake_model import define_fake_model
|
||||
|
||||
|
||||
class DBBackendTestCase(TestCase):
|
||||
"""Tests the custom database back-end."""
|
||||
|
||||
@staticmethod
|
||||
def test_hstore_extension_enabled():
|
||||
"""Tests whether the `hstore` extension was
|
||||
enabled automatically."""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute((
|
||||
'SELECT count(*) FROM pg_extension '
|
||||
'WHERE extname = \'hstore\''
|
||||
))
|
||||
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
@classmethod
|
||||
def test_migration_create_drop_model(cls):
|
||||
"""Tests whether models containing a :see:LocalizedField
|
||||
with a `uniqueness` constraint get created properly,
|
||||
with the contraints in the database."""
|
||||
|
||||
model = define_fake_model('NewModel', {
|
||||
'title': LocalizedField(uniqueness=get_language_codes())
|
||||
})
|
||||
|
||||
# create the model in db and verify the indexes are being created
|
||||
with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute:
|
||||
with connection.schema_editor() as schema_editor:
|
||||
schema_editor.create_model(model)
|
||||
|
||||
create_index_calls = [
|
||||
call for call in execute.mock_calls if 'CREATE UNIQUE INDEX' in str(call)
|
||||
]
|
||||
|
||||
assert len(create_index_calls) == len(settings.LANGUAGES)
|
||||
|
||||
# delete the model in the db and verify the indexes are being deleted
|
||||
with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute:
|
||||
with connection.schema_editor() as schema_editor:
|
||||
schema_editor.delete_model(model)
|
||||
|
||||
drop_index_calls = [
|
||||
call for call in execute.mock_calls if 'DROP INDEX' in str(call)
|
||||
]
|
||||
|
||||
assert len(drop_index_calls) == len(settings.LANGUAGES)
|
||||
|
||||
@classmethod
|
||||
def test_migration_alter_field(cls):
|
||||
"""Tests whether the back-end correctly removes and
|
||||
adds `uniqueness` constraints when altering a :see:LocalizedField."""
|
||||
|
||||
define_fake_model('ExistingModel', {
|
||||
'title': LocalizedField(uniqueness=get_language_codes())
|
||||
})
|
||||
|
||||
app_config = apps.get_app_config('tests')
|
||||
|
||||
with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute:
|
||||
with connection.schema_editor() as schema_editor:
|
||||
dynmodel = app_config.get_model('ExistingModel')
|
||||
schema_editor.alter_field(
|
||||
dynmodel,
|
||||
dynmodel._meta.fields[1],
|
||||
dynmodel._meta.fields[1]
|
||||
)
|
||||
|
||||
index_calls = [
|
||||
call for call in execute.mock_calls
|
||||
if 'INDEX' in str(call) and 'title' in str(call)
|
||||
]
|
||||
|
||||
assert len(index_calls) == len(settings.LANGUAGES) * 2
|
@@ -100,6 +100,19 @@ class LocalizedValueTestCase(TestCase):
|
||||
translation.activate(language)
|
||||
assert str(localized_value) == value
|
||||
|
||||
@staticmethod
|
||||
def test_eq():
|
||||
"""Tests whether the __eq__ operator
|
||||
of :see:LocalizedValue works properly."""
|
||||
|
||||
a = LocalizedValue({'en': 'a', 'ar': 'b'})
|
||||
b = LocalizedValue({'en': 'a', 'ar': 'b'})
|
||||
|
||||
assert a == b
|
||||
|
||||
b.en = 'b'
|
||||
assert a != b
|
||||
|
||||
@staticmethod
|
||||
def test_str_fallback():
|
||||
"""Tests whether the :see:LocalizedValue
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import copy
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.db.utils import IntegrityError
|
||||
from django.utils.text import slugify
|
||||
|
||||
from localized_fields import (LocalizedField, LocalizedAutoSlugField,
|
||||
LocalizedMagicSlugField)
|
||||
LocalizedUniqueSlugField)
|
||||
from django.utils.text import slugify
|
||||
|
||||
from .fake_model import get_fake_model
|
||||
|
||||
@@ -31,10 +32,10 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
)
|
||||
|
||||
cls.MagicSlugModel = get_fake_model(
|
||||
'LocalizedMagicSlugFieldTestModel',
|
||||
'LocalizedUniqueSlugFieldTestModel',
|
||||
{
|
||||
'title': LocalizedField(),
|
||||
'slug': LocalizedMagicSlugField(populate_from='title')
|
||||
'slug': LocalizedUniqueSlugField(populate_from='title')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,7 +44,7 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
cls._test_populate(cls.AutoSlugModel)
|
||||
|
||||
@classmethod
|
||||
def test_populate_magic(cls):
|
||||
def test_populate_unique(cls):
|
||||
cls._test_populate(cls.MagicSlugModel)
|
||||
|
||||
@classmethod
|
||||
@@ -51,7 +52,7 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
cls._test_populate_multiple_languages(cls.AutoSlugModel)
|
||||
|
||||
@classmethod
|
||||
def test_populate_multiple_languages_magic(cls):
|
||||
def test_populate_multiple_languages_unique(cls):
|
||||
cls._test_populate_multiple_languages(cls.MagicSlugModel)
|
||||
|
||||
@classmethod
|
||||
@@ -59,14 +60,62 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
cls._test_unique_slug(cls.AutoSlugModel)
|
||||
|
||||
@classmethod
|
||||
def test_unique_slug_magic(cls):
|
||||
def test_unique_slug_unique(cls):
|
||||
cls._test_unique_slug(cls.MagicSlugModel)
|
||||
|
||||
def test_unique_slug_magic_max_retries(self):
|
||||
"""Tests whether the magic slug implementation doesn't
|
||||
@staticmethod
|
||||
def test_unique_slug_with_time():
|
||||
"""Tests whether the primary key is included in
|
||||
the slug when the 'use_pk' option is enabled."""
|
||||
|
||||
title = 'myuniquetitle'
|
||||
|
||||
PkModel = get_fake_model(
|
||||
'PkModel',
|
||||
{
|
||||
'title': LocalizedField(),
|
||||
'slug': LocalizedUniqueSlugField(populate_from='title', include_time=True)
|
||||
}
|
||||
)
|
||||
|
||||
obj = PkModel()
|
||||
obj.title.en = title
|
||||
obj.save()
|
||||
|
||||
assert obj.slug.en.startswith('%s-' % title)
|
||||
|
||||
@classmethod
|
||||
def test_uniue_slug_no_change(cls):
|
||||
"""Tests whether slugs are not re-generated if not needed."""
|
||||
|
||||
NoChangeSlugModel = get_fake_model(
|
||||
'NoChangeSlugModel',
|
||||
{
|
||||
'title': LocalizedField(),
|
||||
'slug': LocalizedUniqueSlugField(populate_from='title', include_time=True)
|
||||
}
|
||||
)
|
||||
|
||||
title = 'myuniquetitle'
|
||||
|
||||
obj = NoChangeSlugModel()
|
||||
obj.title.en = title
|
||||
obj.title.nl = title
|
||||
obj.save()
|
||||
|
||||
old_slug_en = copy.deepcopy(obj.slug.en)
|
||||
old_slug_nl = copy.deepcopy(obj.slug.nl)
|
||||
obj.title.nl += 'beer'
|
||||
obj.save()
|
||||
|
||||
assert old_slug_en == obj.slug.en
|
||||
assert old_slug_nl != obj.slug.nl
|
||||
|
||||
def test_unique_slug_unique_max_retries(self):
|
||||
"""Tests whether the unique slug implementation doesn't
|
||||
try to find a slug forever and gives up after a while."""
|
||||
|
||||
title = 'mymagictitle'
|
||||
title = 'myuniquetitle'
|
||||
|
||||
obj = self.MagicSlugModel()
|
||||
obj.title.en = title
|
||||
@@ -83,7 +132,7 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
cls._test_unique_slug_utf(cls.AutoSlugModel)
|
||||
|
||||
@classmethod
|
||||
def test_unique_slug_utf_magic(cls):
|
||||
def test_unique_slug_utf_unique(cls):
|
||||
cls._test_unique_slug_utf(cls.MagicSlugModel)
|
||||
|
||||
@classmethod
|
||||
@@ -91,16 +140,16 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
cls._test_deconstruct(LocalizedAutoSlugField)
|
||||
|
||||
@classmethod
|
||||
def test_deconstruct_magic(cls):
|
||||
cls._test_deconstruct(LocalizedMagicSlugField)
|
||||
def test_deconstruct_unique(cls):
|
||||
cls._test_deconstruct(LocalizedUniqueSlugField)
|
||||
|
||||
@classmethod
|
||||
def test_formfield_auto(cls):
|
||||
cls._test_formfield(LocalizedAutoSlugField)
|
||||
|
||||
@classmethod
|
||||
def test_formfield_magic(cls):
|
||||
cls._test_formfield(LocalizedMagicSlugField)
|
||||
def test_formfield_unique(cls):
|
||||
cls._test_formfield(LocalizedUniqueSlugField)
|
||||
|
||||
@staticmethod
|
||||
def _test_populate(model):
|
||||
@@ -130,7 +179,7 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
def _test_unique_slug(model):
|
||||
"""Tests whether unique slugs are properly generated."""
|
||||
|
||||
title = 'mymagictitle'
|
||||
title = 'myuniquetitle'
|
||||
|
||||
obj = model()
|
||||
obj.title.en = title
|
||||
|
Reference in New Issue
Block a user