29 Commits
2.5 ... v3.4

Author SHA1 Message Date
Swen Kooij
b3d7092b91 Bump version to 3.4 2017-02-28 15:00:13 +02:00
Swen Kooij
97a785e9b0 Upgrade django-postgres-extra to 1.4 2017-02-28 14:59:58 +02:00
Swen Kooij
97c14fd2ba Bump version to 3.3 2017-02-24 14:35:57 +02:00
Swen Kooij
6cb4cdf52e Now inheriting from PostgresModel for upserts 2017-02-24 14:35:29 +02:00
Swen Kooij
bcb2ff0092 Bump version to 3.2 2017-02-24 12:29:27 +02:00
Swen Kooij
43a48403e9 LocalizedField should not set required twice 2017-02-24 12:28:56 +02:00
Swen Kooij
f453c44a73 Bump version to 3.1 2017-02-23 16:54:55 +02:00
Swen Kooij
3cb8b04195 Update REAMDE on back-end changes 2017-02-23 16:54:34 +02:00
Swen Kooij
3f8fc77c4d Remove pylint plugins, Scrutinizer doesn't like it 2017-02-23 16:48:44 +02:00
Swen Kooij
9245c85e5d Scrutinizer should use PostgreSQL 9.6 2017-02-23 16:43:12 +02:00
Swen Kooij
818a0a2fe3 Test requirements should inherit from base.txt 2017-02-23 16:38:43 +02:00
Swen Kooij
c206005cae Add django-postgres-extra 1.2 as a dependency 2017-02-23 16:25:27 +02:00
Swen Kooij
3850c34374 Add scrutinizer-ci configuration 2017-02-23 16:24:52 +02:00
Swen Kooij
99c8830f10 Bump version number to 3.0 2017-02-21 17:37:20 +02:00
Swen Kooij
d7bd217a90 Ported to use django-postgres-extra 2017-02-21 17:36:56 +02:00
Swen Kooij
b9a4d3be2c Bump version number to 2.9.2 2017-02-16 10:20:09 +02:00
Swen Kooij
6d7a937eac Don't regenerate slug if not needed 2017-02-16 10:13:10 +02:00
Swen Kooij
2e9b83e49b Bump version to 2.9.1 2017-02-16 09:36:34 +02:00
Swen Kooij
679dcafef6 Pop kwargs after calling super constructor 2017-02-16 09:36:26 +02:00
Swen Kooij
ad2ef34546 Bump version number to 2.9 2017-02-15 19:29:29 +02:00
Swen Kooij
1317023160 Bump version number to 2.8 2017-02-15 19:13:40 +02:00
Swen Kooij
ca6b1c88fa Add option include_time to LocalizedUniqueSlugField 2017-02-15 19:13:19 +02:00
Swen Kooij
64c3c06612 Bumped version number to 2.7 2017-02-09 14:57:25 +02:00
Swen Kooij
b121dfc2d7 Added __eq__ operator to LocalizedValue 2017-02-09 14:57:08 +02:00
Swen Kooij
d529da8886 Fixed bug with with missing populate_from 2017-02-03 11:14:37 +02:00
Swen Kooij
ca879087ea Bumped version to 2.6 2017-02-03 10:41:05 +02:00
Swen Kooij
302a64a02c Updated base classes in documentation 2017-02-03 10:40:37 +02:00
Swen Kooij
bb11253207 Moved retry mechanism to mixin 2017-02-03 10:35:39 +02:00
Swen Kooij
5db87763fb Rename LocalizedMagicSlugField to LocalizedUniqueSlugField 2017-02-03 10:27:30 +02:00
22 changed files with 343 additions and 496 deletions

View File

@@ -1,6 +1,3 @@
[MASTER]
load-plugins=pylint_common, pylint_django
[FORMAT]
max-line-length=120

31
.scrutinizer.yml Normal file
View 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'

View File

@@ -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 :)

View File

@@ -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'
]

View File

@@ -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')

View File

@@ -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',
]

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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."""

View 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()

View File

@@ -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
View File

@@ -0,0 +1 @@
django-postgres-extra==1.4

View File

@@ -1,3 +1,5 @@
-r base.txt
coverage==4.2
Django==1.10.2
django-autoslug==1.9.3

View File

@@ -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 = (

View File

@@ -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.4',
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.4'
],
classifiers=[
'Environment :: Web Environment',
'Framework :: Django',

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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