83 Commits
v2.6 ... 4.2

Author SHA1 Message Date
Swen Kooij
f7c14f897c Bump version number to 4.2 2017-06-20 18:04:44 +03:00
Swen Kooij
e5e3068835 Merge pull request #22 from MELScience/issue17
Added templates and static folders
2017-06-20 18:03:11 +03:00
seroy
de7d3b9a21 Tests excluded 2017-06-20 17:22:24 +03:00
seroy
fd47deaa2e Tests excluded 2017-06-20 16:37:49 +03:00
seroy
4b6d997ddd Upgrade django-postgres-extra to 1.11 2017-06-20 16:30:58 +03:00
seroy
68d6991608 Added templates and static folders 2017-06-20 14:52:54 +03:00
Swen Kooij
c75c1764e2 Improve test case for bulk_create 2017-05-31 11:38:53 +03:00
Swen Kooij
6736b3b32d Simplify test case for bulk_create 2017-05-31 11:33:20 +03:00
Swen Kooij
b97a7f3c23 Fix crash when using LocalizedUniqueSlugField in a bulk_create 2017-05-31 11:31:04 +03:00
Swen Kooij
1b036dc1de Add simple test to verify LocalizedField can be used in bulk_create 2017-05-31 11:19:53 +03:00
Swen Kooij
ea7733670d Bump version to 4.1 2017-05-30 16:38:08 +03:00
Swen Kooij
a8dc4fe837 Fix bug/typo in LocalizedField.from_db_value 2017-05-30 13:48:43 +03:00
Swen Kooij
3c8bea0fc3 Add test for LocalizedRef in combination with ArrayAgg 2017-05-30 13:47:48 +03:00
Swen Kooij
2741a6a2a2 Add extra tests for LocalizedRef 2017-05-30 13:45:43 +03:00
Swen Kooij
06f7ee15f0 Add LocalizedRef expression for extracting the value in the current language 2017-05-30 13:38:27 +03:00
Swen Kooij
e5214b07ae Fix aggregation not expanding into a actual list 2017-05-30 13:07:07 +03:00
Swen Kooij
2d5fe0be05 Fix documentation on INSTALLED_APPS 2017-05-26 17:38:49 +03:00
Swen Kooij
4305696f1b Fix pep8 issue, use two spaces before inline comment 2017-05-26 17:05:52 +03:00
Swen Kooij
8c73c9ab77 Fix some mistakes in the README 2017-05-26 17:05:05 +03:00
Swen Kooij
92a53bc3d7 Fix various pep8/flake8/pylint errors 2017-05-25 19:40:03 +03:00
Swen Kooij
5a4f449363 Fix support for ArrayAgg 2017-05-25 19:23:52 +03:00
Swen Kooij
0fa79ddbb0 Bump version to 4.0 2017-05-25 19:16:35 +03:00
Swen Kooij
2205f9c6a4 Move LocalizedValueTest into dedicated file 2017-05-25 19:16:04 +03:00
Swen Kooij
84c267330f Update docs on new import style 2017-05-25 19:11:39 +03:00
Swen Kooij
a1a02552b7 Shorten names for everything 2017-05-25 19:06:44 +03:00
Swen Kooij
bb84d7577c BREAKING CHANGE: Empty out __init__
It is bad practice to do this in Django. If somebody imports something
from the package before Django is loaded, you get a 'Apps aren't loaded yet.'
exception.
2017-05-25 18:51:11 +03:00
Swen Kooij
5e0343801f Restore LocalizedModel to its former glory as an convient way to get all the mixins 2017-05-25 18:50:20 +03:00
Swen Kooij
2df2ec8b36 Move LocalizedValueDescriptor into its own file 2017-05-25 18:45:21 +03:00
Swen Kooij
3fcaece894 Add missing docs to LocalizedField.contribute_to_class 2017-05-25 18:14:41 +03:00
Swen Kooij
093a9d58f2 Fix to_python not working with non-json values 2017-05-25 18:11:58 +03:00
Swen Kooij
cff22855c2 Revert "LocalizedUniqueSlugField refactored"
This reverts commit 03df76d6d7.
2017-05-25 17:23:39 +03:00
Swen Kooij
bf2995fd27 Merge pull request #16 from MELScience/deserialization
Ability to deserialize string value
2017-04-14 17:19:17 +03:00
seroy
f1798b0cc6 added ability to deserialize string value 2017-04-13 11:53:56 +03:00
seroy
fc80462ce7 added test for str parameter to_python method 2017-04-13 11:41:24 +03:00
Swen Kooij
366dceaddc Merge pull request #14 from MELScience/app_registry_not_ready
fixed "Apps aren't loaded yet." exception
2017-04-13 09:35:38 +03:00
seroy
8591af1f2a fixed "Apps aren't loaded yet." exception 2017-04-12 14:11:29 +03:00
seroy
c55001ac12 Added test for "Apps aren't loaded yet." exception 2017-04-12 13:55:42 +03:00
Swen Kooij
23c6f975d8 Merge remote-tracking branch 'beer/admin_integration' 2017-04-03 14:56:43 +03:00
Swen Kooij
a9037b603a Merge remote-tracking branch 'beer/uniqueslug'
# Conflicts:
#	README.rst
#	tests/fake_model.py
2017-04-03 14:54:25 +03:00
Swen Kooij
8b08e5a467 Merge remote-tracking branch 'beer/model_inheritance_free'
# Conflicts:
#	localized_fields/fields/localized_field.py
#	localized_fields/models.py
#	tests/fake_model.py
2017-04-03 14:49:27 +03:00
Swen Kooij
bf90131e4f Merge pull request #12 from SectorLabs/experimental
Add experimental features flag for LocalizedField
2017-03-24 15:13:55 +02:00
Bogdan Hopulele
8bdfee0666 Implement review suggestions 2017-03-24 14:48:33 +02:00
Bogdan Hopulele
add3c1e1d4 Bump up the library version 2017-03-24 14:39:06 +02:00
Bogdan Hopulele
8ba33695f9 Export LocalizedModel 2017-03-24 14:37:43 +02:00
seroy
78594541e1 fixed for new instance don't call refresh_from_db 2017-03-23 20:49:39 +03:00
Bogdan Hopulele
52145ca7d3 Add experimental features flag for LocalizedField
With the flag set, LocalizedField will return None if there is no
database value.
2017-03-23 17:32:37 +02:00
Swen Kooij
5df44b0d62 Merge pull request #7 from MELScience/import_error
fixed "Apps aren't loaded yet." exception
2017-03-22 17:48:18 +02:00
Swen Kooij
5aef1d791b Merge pull request #9 from MELScience/bleach_import
fixed ImportError if django-bleach not installed
2017-03-22 17:45:25 +02:00
seroy
03df76d6d7 LocalizedUniqueSlugField refactored 2017-03-18 22:58:11 +03:00
seroy
465e35ba8a fixed ImportError if django-bleach not installed 2017-03-18 19:04:51 +03:00
seroy
9278e99b18 import fixed 2017-03-18 18:44:26 +03:00
seroy
9754134298 fixed "Apps aren't loaded yet." exception 2017-03-18 18:26:33 +03:00
seroy
f0c7a72078 LocalizedModel keeped for backwards compatibility 2017-03-18 17:51:31 +03:00
seroy
7f4dfbae1f changed style of active and inactive tabs 2017-03-17 18:22:19 +03:00
seroy
d07da55215 Advanced django admin integration 2017-03-17 06:22:31 +03:00
seroy
340dde18cd no need inheritance from LocalizedModel anymore. Introduction of LocalizedValueDescriptor 2017-03-13 00:50:34 +03:00
Swen Kooij
3951266747 Bump version to 3.5 2017-03-09 14:32:48 +02:00
Swen Kooij
b5f4c43d6b LocalizedValue is now cast-able to dict 2017-03-09 14:32:33 +02:00
Swen Kooij
3d08475468 __eq__ should only compare same types
unless it's a string
2017-03-09 11:59:21 +02:00
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
41 changed files with 1291 additions and 1004 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

@@ -1,2 +1,5 @@
include LICENSE
include README.rst
recursive-include localized_fields/static *
recursive-include localized_fields/templates *
recursive-exclude tests *

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
------------
@@ -42,17 +32,17 @@ Installation
....
'django.contrib.postgres',
'localized_fields'
'localized_fields.apps.LocalizedFieldsConfig'
]
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'
}
}
@@ -72,15 +62,14 @@ Usage
Preparation
^^^^^^^^^^^
Inherit your model from ``LocalizedModel`` and declare fields on your model as ``LocalizedField``:
Declare fields on your model as ``LocalizedField``:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField()
``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:
@@ -146,14 +135,14 @@ At the moment, it is not possible to select two languages to be marked as requir
.. code-block:: python
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField(required=True)
* Make all languages optional:
.. code-block:: python
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField(null=True)
**Uniqueness**
@@ -164,32 +153,32 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e
.. code-block:: python
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField(uniqueness=['en', 'ro'])
* Enforce uniqueness for **all** languages:
.. code-block:: python
from localized_fields import get_language_codes
from localized_fields.util import get_language_codes
class MyModel(LocalizedModel):
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(LocalizedModel):
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
from localized_fields.util import get_language_codes
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField(uniqueness=[(*get_language_codes())])
@@ -203,15 +192,22 @@ Besides ``LocalizedField``, there's also:
.. code-block:: python
from localized_fields import (LocalizedModel,
AtomicSlugRetryMixin,
LocalizedField,
LocalizedUniqueSlugField)
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField
class MyModel(AtomicSlugRetryMixin, LocalizedModel):
class MyModel(LocalizedModel):
title = LocalizedField()
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.
@@ -219,9 +215,7 @@ Besides ``LocalizedField``, there's also:
.. code-block:: python
from localized_fields import (LocalizedModel,
LocalizedField,
LocalizedUniqueSlugField)
from localized_fields.fields import LocalizedField, LocalizedAutoSlugField
class MyModel(LocalizedModel):
title = LocalizedField()
@@ -231,51 +225,69 @@ Besides ``LocalizedField``, there's also:
* ``LocalizedBleachField``
Automatically bleaches the content of the field.
* django-bleach
Example usage:
.. code-block:: python
from localized_fields import (LocalizedModel,
LocalizedField,
LocalizedBleachField)
from localized_fields.fields import LocalizedField, LocalizedBleachField
class MyModel(LocalizedModel):
class MyModel(models.Model):
title = LocalizedField()
description = LocalizedBleachField()
Experimental feature
^^^^^^^^^^^^^^^^^^^^
Enables the following experimental features:
* ``LocalizedField`` will return ``None`` instead of an empty ``LocalizedValue`` if there is no database value.
.. code-block:: python
LOCALIZED_FIELDS_EXPERIMENTAL = True
Django Admin Integration
^^^^^^^^^^^^^^^^^^^^^^^^
To enable widgets in the admin, you need to inherit from ``LocalizedFieldsAdminMixin``:
.. code-block:: python
from django.contrib import admin
from myapp.models import MyLocalizedModel
from localized_fields.admin import LocalizedFieldsAdminMixin
class MyLocalizedModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin):
"""Any admin options you need go here"""
admin.site.register(MyLocalizedModel, MyLocalizedModelAdmin)
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,20 +0,0 @@
from .util import get_language_codes
from .forms import LocalizedFieldForm, LocalizedFieldWidget
from .fields import (LocalizedField, LocalizedBleachField,
LocalizedAutoSlugField, LocalizedUniqueSlugField)
from .mixins import AtomicSlugRetryMixin
from .models import LocalizedModel
from .localized_value import LocalizedValue
__all__ = [
'get_language_codes',
'LocalizedField',
'LocalizedValue',
'LocalizedAutoSlugField',
'LocalizedUniqueSlugField',
'LocalizedBleachField',
'LocalizedFieldWidget',
'LocalizedFieldForm',
'LocalizedModel',
'AtomicSlugRetryMixin'
]

32
localized_fields/admin.py Normal file
View File

@@ -0,0 +1,32 @@
from django.contrib.admin import ModelAdmin
from . import widgets
from .fields import LocalizedField
FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = {
LocalizedField: {'widget': widgets.AdminLocalizedFieldWidget},
}
class LocalizedFieldsAdminMixin(ModelAdmin):
"""Mixin for making the fancy widgets work in Django Admin."""
class Media:
css = {
'all': (
'localized_fields/localized-fields-admin.css',
)
}
js = (
'localized_fields/localized-fields-admin.js',
)
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldsAdminMixin."""
super(LocalizedFieldsAdminMixin, self).__init__(*args, **kwargs)
overrides = FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS.copy()
overrides.update(self.formfield_overrides)
self.formfield_overrides = overrides

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.'
) % base_class_name)
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.'
) % base_class_name)
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

@@ -0,0 +1,65 @@
from django.conf import settings
from django.utils import six, translation
class LocalizedValueDescriptor:
"""
The descriptor for the localized value attribute on the model instance.
Returns a :see:LocalizedValue when accessed so you can do stuff like::
>>> from myapp.models import MyModel
>>> instance = MyModel()
>>> instance.value.en = 'English value'
Assigns a strings to active language key in :see:LocalizedValue on
assignment so you can do::
>>> from django.utils import translation
>>> from myapp.models import MyModel
>>> translation.activate('nl')
>>> instance = MyModel()
>>> instance.title = 'dutch title'
>>> print(instance.title.nl) # prints 'dutch title'
"""
def __init__(self, field):
"""Initializes a new instance of :see:LocalizedValueDescriptor."""
self.field = field
def __get__(self, instance, cls=None):
if instance is None:
return self
# This is slightly complicated, so worth an explanation.
# `instance.localizedvalue` needs to ultimately return some instance of
# `LocalizedValue`, probably a subclass.
# The instance dict contains whatever was originally assigned
# in __set__.
if self.field.name in instance.__dict__:
value = instance.__dict__[self.field.name]
elif instance.pk is not None:
instance.refresh_from_db(fields=[self.field.name])
value = getattr(instance, self.field.name)
else:
value = None
if value is None:
attr = self.field.attr_class()
instance.__dict__[self.field.name] = attr
if isinstance(value, dict):
attr = self.field.attr_class(value)
instance.__dict__[self.field.name] = attr
return instance.__dict__[self.field.name]
def __set__(self, instance, value):
if isinstance(value, six.string_types):
language = translation.get_language() or settings.LANGUAGE_CODE
self.__get__(instance).set(language, value) # pylint: disable=no-member
else:
instance.__dict__[self.field.name] = value

View File

@@ -0,0 +1,25 @@
from django.conf import settings
from django.utils import translation
from psqlextra import expressions
class LocalizedRef(expressions.HStoreRef):
"""Expression that selects the value in a field only in
the currently active language."""
def __init__(self, name: str, lang: str=None):
"""Initializes a new instance of :see:LocalizedRef.
Arguments:
name:
The field/column to select from.
lang:
The language to get the field/column in.
If not specified, the currently active language
is used.
"""
language = lang or translation.get_language() or settings.LANGUAGE_CODE
super().__init__(name, language)

View File

@@ -1,12 +1,18 @@
from .localized_field import LocalizedField
from .localized_autoslug_field import LocalizedAutoSlugField
from .localized_uniqueslug_field import LocalizedUniqueSlugField
from .localized_bleach_field import LocalizedBleachField
from .field import LocalizedField
from .autoslug_field import LocalizedAutoSlugField
from .uniqueslug_field import LocalizedUniqueSlugField
__all__ = [
'LocalizedField',
'LocalizedAutoSlugField',
'LocalizedUniqueSlugField',
'LocalizedBleachField',
]
try:
from .bleach_field import LocalizedBleachField
__all__ += [
'LocalizedBleachField'
]
except ImportError:
pass

View File

@@ -1,11 +1,12 @@
from typing import Callable
from typing import Callable, Tuple
from datetime import datetime
from django import forms
from django.conf import settings
from django.utils.text import slugify
from .localized_field import LocalizedField
from ..localized_value import LocalizedValue
from .field import LocalizedField
from ..value import LocalizedValue
class LocalizedAutoSlugField(LocalizedField):
@@ -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):
@@ -66,16 +69,13 @@ class LocalizedAutoSlugField(LocalizedField):
slugs = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
value = self._get_populate_from_value(
instance,
self.populate_from,
lang_code
)
for lang_code, value in self._get_populate_values(instance):
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."""
@@ -122,6 +122,30 @@ class LocalizedAutoSlugField(LocalizedField):
return unique_slug
def _get_populate_values(self, instance) -> Tuple[str, str]:
"""Gets all values (for each language) from the
specified's instance's `populate_from` field.
Arguments:
instance:
The instance to get the values from.
Returns:
A list of (lang_code, value) tuples.
"""
return [
(
lang_code,
self._get_populate_from_value(
instance,
self.populate_from,
lang_code
),
)
for lang_code, _ in settings.LANGUAGES
]
@staticmethod
def _get_populate_from_value(instance, field_name: str, language: str):
"""Gets the value to create a slug from in the specified language.

View File

@@ -1,8 +1,9 @@
import bleach
from django.conf import settings
from django_bleach.utils import get_bleach_default_options
from .localized_field import LocalizedField
from .field import LocalizedField
class LocalizedBleachField(LocalizedField):

View File

@@ -1,9 +1,15 @@
import json
from typing import Union
from django.conf import settings
from django.contrib.postgres.fields import HStoreField
from django.db.utils import IntegrityError
from localized_fields import LocalizedFieldForm
from ..localized_value import LocalizedValue
from psqlextra.fields import HStoreField
from ..forms import LocalizedFieldForm
from ..value import LocalizedValue
from ..descriptor import LocalizedValueDescriptor
class LocalizedField(HStoreField):
@@ -14,15 +20,33 @@ class LocalizedField(HStoreField):
Meta = None
def __init__(self, *args, uniqueness=None, **kwargs):
"""Initializes a new instance of :see:LocalizedValue."""
# The class to wrap instance attributes in. Accessing to field attribute in
# model instance will always return an instance of attr_class.
attr_class = LocalizedValue
# The descriptor to use for accessing the attribute off of the class.
descriptor_class = LocalizedValueDescriptor
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedField."""
super(LocalizedField, self).__init__(*args, **kwargs)
self.uniqueness = uniqueness
def contribute_to_class(self, model, name, **kwargs):
"""Adds this field to the specifed model.
@staticmethod
def from_db_value(value, *_):
Arguments:
cls:
The model to add the field to.
name:
The name of the field to add.
"""
super(LocalizedField, self).contribute_to_class(model, name, **kwargs)
setattr(model, self.name, self.descriptor_class(self))
@classmethod
def from_db_value(cls, value, *_):
"""Turns the specified database value into its Python
equivalent.
@@ -37,11 +61,36 @@ class LocalizedField(HStoreField):
"""
if not value:
return LocalizedValue()
if getattr(settings, 'LOCALIZED_FIELDS_EXPERIMENTAL', False):
return None
else:
return cls.attr_class()
return LocalizedValue(value)
# we can get a list if an aggregation expression was used..
# if we the expression was flattened when only one key was selected
# then we don't wrap each value in a localized value, otherwise we do
if isinstance(value, list):
result = []
for inner_val in value:
if isinstance(inner_val, dict):
if inner_val is None:
result.append(None)
else:
result.append(cls.attr_class(inner_val))
else:
result.append(inner_val)
def to_python(self, value: dict) -> LocalizedValue:
return result
# this is for when you select an individual key, it will be string,
# not a dictionary, we'll give it to you as a flat value, not as a
# localized value instance
if not isinstance(value, dict):
return value
return cls.attr_class(value)
def to_python(self, value: Union[dict, str, None]) -> LocalizedValue:
"""Turns the specified database value into its Python
equivalent.
@@ -55,10 +104,17 @@ class LocalizedField(HStoreField):
data extracted from the database.
"""
if not value or not isinstance(value, dict):
return LocalizedValue()
# first let the base class handle the deserialization, this is in case we
# get specified a json string representing a dict
try:
deserialized_value = super(LocalizedField, self).to_python(value)
except json.JSONDecodeError:
deserialized_value = value
return LocalizedValue(value)
if not deserialized_value:
return self.attr_class()
return self.attr_class(deserialized_value)
def get_prep_value(self, value: LocalizedValue) -> dict:
"""Turns the specified value into something the database

View File

@@ -1,11 +1,12 @@
from django.conf import settings
from datetime import datetime
from django.utils.text import slugify
from django.core.exceptions import ImproperlyConfigured
from .autoslug_field import LocalizedAutoSlugField
from ..util import get_language_codes
from ..mixins import AtomicSlugRetryMixin
from ..localized_value import LocalizedValue
from .localized_autoslug_field import LocalizedAutoSlugField
from ..value import LocalizedValue
class LocalizedUniqueSlugField(LocalizedAutoSlugField):
@@ -34,6 +35,18 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
)
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
@@ -59,19 +72,31 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
slugs = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
value = self._get_populate_from_value(
instance,
self.populate_from,
lang_code
)
for lang_code, value in self._get_populate_values(instance):
if not value:
continue
slug = slugify(value, allow_unicode=True)
if instance.retries > 0:
slug += '-%d' % instance.retries
# 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
retries = getattr(instance, 'retries', 0)
if retries > 0:
# do not add another - if we already added time
if not self.include_time:
slug += '-'
slug += '%d' % retries
slugs.set(lang_code, slug)

View File

@@ -2,53 +2,17 @@ from typing import List
from django import forms
from django.conf import settings
from django.forms import MultiWidget
from .localized_value import LocalizedValue
class LocalizedFieldWidget(MultiWidget):
"""Widget that has an input box for every language."""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
widgets = []
for _ in settings.LANGUAGES:
widgets.append(forms.Textarea())
super(LocalizedFieldWidget, self).__init__(widgets, *args, **kwargs)
def decompress(self, value: LocalizedValue) -> List[str]:
"""Decompresses the specified value so
it can be spread over the internal widgets.
Arguments:
value:
The :see:LocalizedValue to display in this
widget.
Returns:
All values to display in the inner widgets.
"""
result = []
for lang_code, _ in settings.LANGUAGES:
if value:
result.append(value.get(lang_code))
else:
result.append(None)
return result
from .value import LocalizedValue
from .widgets import LocalizedFieldWidget
class LocalizedFieldForm(forms.MultiValueField):
"""Form for a localized field, allows editing
the field in multiple languages."""
widget = LocalizedFieldWidget()
widget = LocalizedFieldWidget
value_class = LocalizedValue
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldForm."""
@@ -59,15 +23,19 @@ class LocalizedFieldForm(forms.MultiValueField):
field_options = {'required': False}
if lang_code == settings.LANGUAGE_CODE:
field_options['required'] = True
field_options['required'] = kwargs.get('required', True)
field_options['label'] = lang_code
fields.append(forms.fields.CharField(**field_options))
super(LocalizedFieldForm, self).__init__(
fields,
require_all_fields=False
require_all_fields=False,
*args, **kwargs
)
# set 'required' attribute for each widget separately
for f, w in zip(self.fields, self.widget.widgets):
w.is_required = f.required
def compress(self, value: List[str]) -> LocalizedValue:
"""Compresses the values from individual fields
@@ -82,7 +50,7 @@ class LocalizedFieldForm(forms.MultiValueField):
the value in several languages.
"""
localized_value = LocalizedValue()
localized_value = self.value_class()
for (lang_code, _), value in zip(settings.LANGUAGES, value):
localized_value.set(lang_code, value)

View File

@@ -1,132 +0,0 @@
"""This module is unused, but should be contributed to Django."""
from typing import List
from django.db import models
class HStoreIndex(models.Index):
"""Allows creating a index on a specific HStore index.
Note: pieces of code in this class have been copied
from the base class. There was no way around this."""
def __init__(self, field: str, keys: List[str], unique: bool=False,
name: str=''):
"""Initializes a new instance of :see:HStoreIndex.
Arguments:
field:
Name of the hstore field for
which's keys to create a index for.
keys:
The name of the hstore keys to
create the index on.
unique:
Whether this index should
be marked as UNIQUE.
name:
The name of the index. If left
empty, one will be generated.
"""
self.field = field
self.keys = keys
self.unique = unique
# this will eventually set self.name
super(HStoreIndex, self).__init__(
fields=[field],
name=name
)
def get_sql_create_template_values(self, model, schema_editor, using):
"""Gets the values for the SQL template.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
using:
Optional: "USING" statement.
Returns:
Dictionary of keys to pass into the SQL template.
"""
fields = [model._meta.get_field(field_name) for field_name, order in self.fields_orders]
tablespace_sql = schema_editor._get_index_tablespace_sql(model, fields)
quote_name = schema_editor.quote_name
columns = [
'(%s->\'%s\')' % (self.field, key)
for key in self.keys
]
return {
'table': quote_name(model._meta.db_table),
'name': quote_name(self.name),
'columns': ', '.join(columns),
'using': using,
'extra': tablespace_sql,
}
def create_sql(self, model, schema_editor, using=''):
"""Gets the SQL to execute when creating the index.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
using:
Optional: "USING" statement.
Returns:
SQL string to execute to create this index.
"""
sql_create_index = schema_editor.sql_create_index
if self.unique:
sql_create_index = sql_create_index.replace('CREATE', 'CREATE UNIQUE')
sql_parameters = self.get_sql_create_template_values(model, schema_editor, using)
return sql_create_index % sql_parameters
def remove_sql(self, model, schema_editor):
"""Gets the SQL to execute to remove this index.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
Returns:
SQL string to execute to remove this index.
"""
quote_name = schema_editor.quote_name
return schema_editor.sql_delete_index % {
'table': quote_name(model._meta.db_table),
'name': quote_name(self.name),
}
def deconstruct(self):
"""Gets the values to pass to :see:__init__ when
re-creating this object."""
path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
return (path, (), {
'field': self.field,
'keys': self.keys,
'unique': self.unique,
'name': self.name
})

View File

@@ -1,82 +0,0 @@
from django.conf import settings
from django.utils import translation
class LocalizedValue:
"""Represents the value of a :see:LocalizedField."""
def __init__(self, keys: dict=None):
"""Initializes a new instance of :see:LocalizedValue.
Arguments:
keys:
The keys to initialize this value with. Every
key contains the value of this field in a
different language.
"""
if isinstance(keys, str):
setattr(self, settings.LANGUAGE_CODE, keys)
else:
for lang_code, _ in settings.LANGUAGES:
value = keys.get(lang_code) if keys else None
setattr(self, lang_code, value)
def get(self, language: str=None) -> str:
"""Gets the underlying value in the specified or
primary language.
Arguments:
language:
The language to get the value in.
Returns:
The value in the current language, or
the primary language in case no language
was specified.
"""
language = language or settings.LANGUAGE_CODE
return getattr(self, language, None)
def set(self, language: str, value: str):
"""Sets the value in the specified language.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
setattr(self, language, value)
return self
def deconstruct(self) -> dict:
"""Deconstructs this value into a primitive type.
Returns:
A dictionary with all the localized values
contained in this instance.
"""
path = 'localized_fields.fields.LocalizedValue'
return path, [self.__dict__], {}
def __str__(self) -> str:
"""Gets the value in the current language, or falls
back to the primary language if there's no value
in the current language."""
value = self.get(translation.get_language())
if not value:
value = self.get(settings.LANGUAGE_CODE)
return value or ''
def __repr__(self): # pragma: no cover
"""Gets a textual representation of this object."""
return 'LocalizedValue<%s> 0x%s' % (self.__dict__, id(self))

View File

@@ -1,36 +1,16 @@
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
from .mixins import AtomicSlugRetryMixin
class LocalizedModel(models.Model):
"""A model that contains localized fields."""
class LocalizedModel(AtomicSlugRetryMixin, PostgresModel):
"""Turns a model into a model that contains LocalizedField's.
For basic localisation functionality, it isn't needed to inherit
from LocalizedModel. However, for certain features, this is required.
It is definitely needed for :see:LocalizedUniqueSlugField, unless you
manually inherit from AtomicSlugRetryMixin."""
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedModel.
Here we set all the fields that are of :see:LocalizedField
to an instance of :see:LocalizedValue in case they are none
so that the user doesn't explicitely have to do so."""
super(LocalizedModel, self).__init__(*args, **kwargs)
for field in self._meta.get_fields():
if not isinstance(field, LocalizedField):
continue
value = getattr(self, field.name, None)
if not isinstance(value, LocalizedValue):
if isinstance(value, dict):
value = LocalizedValue(value)
else:
value = LocalizedValue()
setattr(self, field.name, value)

View File

@@ -0,0 +1,47 @@
.localized-fields-widget {
margin-left: 160px;
}
.localized-fields-widget.tabs {
display: block;
margin: 0;
border-bottom: 1px solid #eee;
}
.localized-fields-widget.tabs .localized-fields-widget.tab {
display: inline-block;
margin-left: 5px;
border: 1px solid #79aec8;
border-bottom: none;
border-radius: 4px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: #79aec8;
color: #fff;
font-weight: 400;
opacity: 0.5;
}
.localized-fields-widget.tabs .localized-fields-widget.tab:first-child {
margin-left: 0;
}
.localized-fields-widget.tabs .localized-fields-widget.tab:hover {
background: #417690;
border-color: #417690;
opacity: 1;
}
.localized-fields-widget.tabs .localized-fields-widget.tab a {
padding: 5px 10px;
display: inline-block;
text-decoration: none;
color: #fff;
}
.localized-fields-widget.tabs .localized-fields-widget.tab.active,
.localized-fields-widget.tabs .localized-fields-widget.tab.active:hover {
background: #79aec8;
border-color: #79aec8;
opacity: 1;
}

View File

@@ -0,0 +1,35 @@
(function($) {
var syncTabs = function(lang) {
$('.localized-fields-widget.tab a:contains("'+lang+'")').each(function(){
$(this).parents('.localized-fields-widget[role="tabs"]').find('.localized-fields-widget.tab').removeClass('active');
$(this).parents('.localized-fields-widget.tab').addClass('active');
$(this).parents('.localized-fields-widget[role="tabs"]').children('.localized-fields-widget [role="tabpanel"]').hide();
$($(this).attr('href')).show();
});
}
$(function (){
$('.localized-fields-widget [role="tabpanel"]').hide();
// set first tab as active
$('.localized-fields-widget[role="tabs"]').each(function () {
$(this).find('.localized-fields-widget.tab:first').addClass('active');
$($(this).find('.localized-fields-widget.tab:first a').attr('href')).show();
});
// try set active last selected tab
if (window.sessionStorage) {
var lang = window.sessionStorage.getItem('localized-field-lang');
if (lang) {
syncTabs(lang);
}
}
$('.localized-fields-widget.tab a').click(function(event) {
event.preventDefault();
syncTabs(this.innerText);
if (window.sessionStorage) {
window.sessionStorage.setItem('localized-field-lang', this.innerText);
}
return false;
});
});
})(django.jQuery)

View File

@@ -0,0 +1,14 @@
<div class="localized-fields-widget" role="tabs" data-synctabs="translation">
<ul class="localized-fields-widget tabs">
{% for key, lang in available_languages %}
<li class="localized-fields-widget tab">
<a href="#{{ id }}_{{ key }}">{{ lang|capfirst }}</a>
</li>
{% endfor %}
</ul>
{% for key, widget in widgets %}
<div role="tabpanel" id="{{ id }}_{{ key }}">
{{ widget }}
</div>
{% endfor %}
</div>

159
localized_fields/value.py Normal file
View File

@@ -0,0 +1,159 @@
import collections
from django.conf import settings
from django.utils import translation
class LocalizedValue(dict):
"""Represents the value of a :see:LocalizedField."""
def __init__(self, keys: dict=None):
"""Initializes a new instance of :see:LocalizedValue.
Arguments:
keys:
The keys to initialize this value with. Every
key contains the value of this field in a
different language.
"""
super().__init__({})
self._interpret_value(keys)
def get(self, language: str=None) -> str:
"""Gets the underlying value in the specified or
primary language.
Arguments:
language:
The language to get the value in.
Returns:
The value in the current language, or
the primary language in case no language
was specified.
"""
language = language or settings.LANGUAGE_CODE
return super().get(language, None)
def set(self, language: str, value: str):
"""Sets the value in the specified language.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
self[language] = value
self.__dict__.update(self)
return self
def deconstruct(self) -> dict:
"""Deconstructs this value into a primitive type.
Returns:
A dictionary with all the localized values
contained in this instance.
"""
path = 'localized_fields.fields.LocalizedValue'
return path, [self.__dict__], {}
def _interpret_value(self, value):
"""Interprets a value passed in the constructor as
a :see:LocalizedValue.
If string:
Assumes it's the default language.
If dict:
Each key is a language and the value a string
in that language.
If list:
Recurse into to apply rules above.
Arguments:
value:
The value to interpret.
"""
for lang_code, _ in settings.LANGUAGES:
self.set(lang_code, None)
if isinstance(value, str):
self.set(settings.LANGUAGE_CODE, value)
elif isinstance(value, dict):
for lang_code, _ in settings.LANGUAGES:
lang_value = value.get(lang_code) or None
self.set(lang_code, lang_value)
elif isinstance(value, collections.Iterable):
for val in value:
self._interpret_value(val)
def __str__(self) -> str:
"""Gets the value in the current language, or falls
back to the primary language if there's no value
in the current language."""
value = self.get(translation.get_language())
if not value:
value = self.get(settings.LANGUAGE_CODE)
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.
"""
if not isinstance(other, type(self)):
if isinstance(other, str):
return self.__str__() == other
return False
for lang_code, _ in settings.LANGUAGES:
if self.get(lang_code) != other.get(lang_code):
return False
return True
def __ne__(self, other):
"""Compares :paramref:self to :paramerf:other for
in-equality.
Returns:
True when :paramref:self is not equal to :paramref:other.
And False when they are.
"""
return not self.__eq__(other)
def __setattr__(self, language: str, value: str):
"""Sets the value for a language with the specified name.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
self.set(language, value)
def __repr__(self): # pragma: no cover
"""Gets a textual representation of this object."""
return 'LocalizedValue<%s> 0x%s' % (dict(self), id(self))

View File

@@ -0,0 +1,86 @@
from typing import List
from django.conf import settings
from django import forms
from django.contrib.admin import widgets
from django.template.loader import render_to_string
from .value import LocalizedValue
class LocalizedFieldWidget(forms.MultiWidget):
"""Widget that has an input box for every language."""
widget = forms.Textarea
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
initial_widgets = [
self.widget
for _ in settings.LANGUAGES
]
super().__init__(initial_widgets, *args, **kwargs)
def decompress(self, value: LocalizedValue) -> List[str]:
"""Decompresses the specified value so
it can be spread over the internal widgets.
Arguments:
value:
The :see:LocalizedValue to display in this
widget.
Returns:
All values to display in the inner widgets.
"""
result = []
for lang_code, _ in settings.LANGUAGES:
if value:
result.append(value.get(lang_code))
else:
result.append(None)
return result
class AdminLocalizedFieldWidget(LocalizedFieldWidget):
widget = widgets.AdminTextareaWidget
template = 'localized_fields/admin/widget.html'
def render(self, name, value, attrs=None):
if self.is_localized:
for widget in self.widgets:
widget.is_localized = self.is_localized
# value is a list of values, each corresponding to a widget
# in self.widgets.
if not isinstance(value, list):
value = self.decompress(value)
output = []
final_attrs = self.build_attrs(attrs)
id_ = final_attrs.get('id')
for i, widget in enumerate(self.widgets):
try:
widget_value = value[i]
except IndexError:
widget_value = None
if id_:
final_attrs = dict(final_attrs, id='%s_%s' % (id_, i))
widget_attrs = self.build_widget_attrs(widget, widget_value, final_attrs)
output.append(widget.render(name + '_%s' % i, widget_value, widget_attrs))
context = {
'id': final_attrs.get('id'),
'name': name,
'widgets': zip([code for code, lang in settings.LANGUAGES], output),
'available_languages': settings.LANGUAGES
}
return render_to_string(self.template, context)
@staticmethod
def build_widget_attrs(widget, value, attrs):
attrs = dict(attrs) # Copy attrs to avoid modifying the argument.
if (not widget.use_required_attribute(value) or not widget.is_required) \
and 'required' in attrs:
del attrs['required']
return attrs

1
requirements/base.txt Normal file
View File

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

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 = (
@@ -21,9 +21,12 @@ LANGUAGES = (
)
INSTALLED_APPS = (
'localized_fields',
'tests',
)
# set to a lower number than the default, since
# we want the tests to be fast, default is 100
LOCALIZED_FIELDS_MAX_RETRIES = 3
LOCALIZED_FIELDS_EXPERIMENTAL = False

View File

@@ -7,8 +7,8 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme:
setup(
name='django-localized-fields',
version='2.6',
packages=find_packages(),
version='4.2',
packages=find_packages(exclude=['tests']),
include_package_data=True,
license='MIT License',
description='Implementation of localized model fields using PostgreSQL HStore fields.',
@@ -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.11'
],
classifiers=[
'Environment :: Web Environment',
'Framework :: Django',

13
tests/data.py Normal file
View File

@@ -0,0 +1,13 @@
from django.conf import settings
def get_init_values() -> dict:
"""Gets a test dictionary containing a key
for every language."""
keys = {}
for lang_code, lang_name in settings.LANGUAGES:
keys[lang_code] = 'value in %s' % lang_name
return keys

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, AtomicSlugRetryMixin
from localized_fields.models import LocalizedModel
def define_fake_model(name='TestModel', fields=None):
@@ -14,8 +14,8 @@ def define_fake_model(name='TestModel', fields=None):
if fields:
attributes.update(fields)
model = type(name, (AtomicSlugRetryMixin,LocalizedModel,), attributes)
model = type(name, (LocalizedModel,), attributes)
return model

View File

@@ -1,9 +1,11 @@
import bleach
from django.conf import settings
from django.test import TestCase
from django_bleach.utils import get_bleach_default_options
import bleach
from localized_fields import LocalizedBleachField, LocalizedValue
from localized_fields.fields import LocalizedBleachField
from localized_fields.value import LocalizedValue
class TestModel:

45
tests/test_bulk.py Normal file
View File

@@ -0,0 +1,45 @@
from django.db import models
from django.test import TestCase
from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField
from .fake_model import get_fake_model
class LocalizedBulkTestCase(TestCase):
"""Tests bulk operations with data structures provided
by the django-localized-fields library."""
@staticmethod
def test_localized_bulk_insert():
"""Tests whether bulk inserts work properly when using
a :see:LocalizedUniqueSlugField in the model."""
model = get_fake_model(
'BulkSlugInsertModel',
{
'name': LocalizedField(),
'slug': LocalizedUniqueSlugField(populate_from='name', include_time=True),
'score': models.IntegerField()
}
)
to_create = [
model(name={'en': 'english name 1', 'ro': 'romanian name 1'}, score=1),
model(name={'en': 'english name 2', 'ro': 'romanian name 2'}, score=2),
model(name={'en': 'english name 3', 'ro': 'romanian name 3'}, score=3)
]
model.objects.bulk_create(to_create)
assert model.objects.all().count() == 3
for obj in to_create:
obj_db = model.objects.filter(
name__en=obj.name.en,
name__ro=obj.name.ro,
score=obj.score
).first()
assert obj_db
assert len(obj_db.slug.en) >= len(obj_db.name.en)
assert len(obj_db.slug.ro) >= len(obj_db.name.ro)

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

86
tests/test_expressions.py Normal file
View File

@@ -0,0 +1,86 @@
from django.test import TestCase
from django.db import models
from django.utils import translation
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from localized_fields.expressions import LocalizedRef
from .fake_model import get_fake_model
class LocalizedExpressionsTestCase(TestCase):
"""Tests whether expressions properly work with :see:LocalizedField."""
TestModel1 = None
TestModel2 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedExpressionsTestCase, cls).setUpClass()
cls.TestModel1 = get_fake_model(
'LocalizedExpressionsTestCase2',
{
'name': models.CharField(null=False, blank=False, max_length=255),
}
)
cls.TestModel2 = get_fake_model(
'LocalizedExpressionsTestCase1',
{
'text': LocalizedField(),
'other': models.ForeignKey(cls.TestModel1, related_name='features')
}
)
@classmethod
def test_localized_ref(cls):
"""Tests whether the :see:LocalizedRef expression properly works."""
obj = cls.TestModel1.objects.create(name='bla bla')
for i in range(0, 10):
cls.TestModel2.objects.create(
text=LocalizedValue(dict(en='text_%d_en' % i, ro='text_%d_ro' % i, nl='text_%d_nl' % i)),
other=obj
)
def create_queryset(ref):
return (
cls.TestModel1.objects
.annotate(mytexts=ref)
.values_list('mytexts', flat=True)
)
# assert that it properly selects the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
queryset = create_queryset(LocalizedRef('features__text'))
for index, value in enumerate(queryset):
assert translation.get_language() in value
assert str(index) in value
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
queryset = create_queryset(LocalizedRef('features__text'))
for index, value in enumerate(queryset):
assert settings.LANGUAGE_CODE in value
assert str(index) in value
# ensures that overriding the language works properly
queryset = create_queryset(LocalizedRef('features__text', 'ro'))
for index, value in enumerate(queryset):
assert 'ro' in value
assert str(index) in value
# ensures that using this in combination with ArrayAgg works properly
queryset = create_queryset(ArrayAgg(LocalizedRef('features__text', 'ro'))).first()
assert isinstance(queryset, list)
for value in queryset:
assert 'ro' in value

158
tests/test_field.py Normal file
View File

@@ -0,0 +1,158 @@
import json
from django.conf import settings
from django.db.utils import IntegrityError
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.forms import LocalizedFieldForm
from localized_fields.value import LocalizedValue
from .data import get_init_values
class LocalizedFieldTestCase(TestCase):
"""Tests the :see:LocalizedField class."""
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().from_db_value(input_data)
for lang_code, _ in settings.LANGUAGES:
assert getattr(localized_value, lang_code) == input_data[lang_code]
@staticmethod
def test_from_db_value_none():
"""Tests whether the :see:from_db_value function
correctly handles None values."""
localized_value = LocalizedField().from_db_value(None)
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
def test_from_db_value_none_return_none(self):
"""Tests whether the :see:from_db_value function
correctly handles None values when LOCALIZED_FIELDS_EXPERIMENTAL
is set to True."""
with self.settings(LOCALIZED_FIELDS_EXPERIMENTAL=True):
localized_value = LocalizedField.from_db_value(None)
assert localized_value is None
@staticmethod
def test_to_python():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().to_python(input_data)
for language, value in input_data.items():
assert localized_value.get(language) == value
@staticmethod
def test_to_python_non_json():
"""Tests whether the :see:to_python function
properly handles a string that is not JSON."""
localized_value = LocalizedField().to_python('my value')
assert localized_value.get() == 'my value'
@staticmethod
def test_to_python_none():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue
instance when it is passes None."""
localized_value = LocalizedField().to_python(None)
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_non_dict():
"""Tests whether the :see:to_python function produces
the expected :see:LocalizedValue when it is
passed a non-dictionary value."""
localized_value = LocalizedField().to_python(list())
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_str():
"""Tests whether the :see:to_python function produces
the expected :see:LocalizedValue when it is
passed serialized string value."""
serialized_str = json.dumps(get_init_values())
localized_value = LocalizedField().to_python(serialized_str)
assert isinstance(localized_value, LocalizedValue)
for language, value in get_init_values().items():
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_get_prep_value():
""""Tests whether the :see:get_prep_value function
produces the expected dictionary."""
input_data = get_init_values()
localized_value = LocalizedValue(input_data)
output_data = LocalizedField().get_prep_value(localized_value)
for language, value in input_data.items():
assert language in output_data
assert output_data.get(language) == value
@staticmethod
def test_get_prep_value_none():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed None."""
output_data = LocalizedField().get_prep_value(None)
assert not output_data
@staticmethod
def test_get_prep_value_no_localized_value():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed a
non-LocalizedValue value."""
output_data = LocalizedField().get_prep_value(['huh'])
assert not output_data
def test_get_prep_value_clean(self):
"""Tests whether the :see:get_prep_value produces
None as the output when it is passed an empty, but
valid LocalizedValue value but, only when null=True."""
localized_value = LocalizedValue()
with self.assertRaises(IntegrityError):
LocalizedField(null=False).get_prep_value(localized_value)
assert not LocalizedField(null=True).get_prep_value(localized_value)
assert not LocalizedField().clean(None)
assert not LocalizedField().clean(['huh'])
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function
correctly returns a valid form."""
assert isinstance(
LocalizedField().formfield(),
LocalizedFieldForm
)

View File

@@ -1,7 +1,7 @@
from django.conf import settings
from django.test import TestCase
from localized_fields import LocalizedFieldForm
from localized_fields.forms import LocalizedFieldForm
class LocalizedFieldFormTestCase(TestCase):

View File

@@ -1,265 +0,0 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from django.db.utils import IntegrityError
from localized_fields import LocalizedField, LocalizedValue, LocalizedFieldForm
def get_init_values() -> dict:
"""Gets a test dictionary containing a key
for every language."""
keys = {}
for lang_code, lang_name in settings.LANGUAGES:
keys[lang_code] = 'value in %s' % lang_name
return keys
class LocalizedValueTestCase(TestCase):
"""Tests the :see:LocalizedValue class."""
@staticmethod
def tearDown():
"""Assures that the current language
is set back to the default."""
translation.activate(settings.LANGUAGE_CODE)
@staticmethod
def test_init():
"""Tests whether the __init__ function
of the :see:LocalizedValue class works
as expected."""
keys = get_init_values()
value = LocalizedValue(keys)
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code, None) == keys[lang_code]
@staticmethod
def test_init_default_values():
"""Tests wehther the __init__ function
of the :see:LocalizedValue accepts the
default value or an empty dict properly."""
value = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None
@staticmethod
def test_get_explicit():
"""Tests whether the the :see:LocalizedValue
class's :see:get function works properly
when specifying an explicit value."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
assert localized_value.get(language) == value
@staticmethod
def test_get_default_language():
"""Tests whether the :see:LocalizedValue
class's see:get function properly
gets the value in the default language."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, _ in keys.items():
translation.activate(language)
assert localized_value.get() == keys[settings.LANGUAGE_CODE]
@staticmethod
def test_set():
"""Tests whether the :see:LocalizedValue
class's see:set function works properly."""
localized_value = LocalizedValue()
for language, value in get_init_values():
localized_value.set(language, value)
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_str():
"""Tests whether the :see:LocalizedValue
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert str(localized_value) == value
@staticmethod
def test_str_fallback():
"""Tests whether the :see:LocalizedValue
class's __str__'s fallback functionality
works properly."""
test_value = 'myvalue'
localized_value = LocalizedValue({
settings.LANGUAGE_CODE: test_value
})
other_language = settings.LANGUAGES[-1][0]
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
# make sure that it falls back to the
# primary language when there's no value
# available in the current language
translation.activate(other_language)
assert str(localized_value) == test_value
# make sure that it's just __str__ falling
# back and that for the other language
# there's no actual value
assert localized_value.get(other_language) != test_value
@staticmethod
def test_deconstruct():
"""Tests whether the :see:LocalizedValue
class's :see:deconstruct function works properly."""
keys = get_init_values()
value = LocalizedValue(keys)
path, args, kwargs = value.deconstruct()
assert args[0] == keys
@staticmethod
def test_construct_string():
"""Tests whether the :see:LocalizedValue's constructor
assumes the primary language when passing a single string."""
value = LocalizedValue('beer')
assert value.get(settings.LANGUAGE_CODE) == 'beer'
class LocalizedFieldTestCase(TestCase):
"""Tests the :see:LocalizedField class."""
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField.from_db_value(input_data)
for lang_code, _ in settings.LANGUAGES:
assert getattr(localized_value, lang_code) == input_data[lang_code]
@staticmethod
def test_from_db_value_none():
"""Tests whether the :see:from_db_valuei function
correctly handles None values."""
localized_value = LocalizedField.from_db_value(None)
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().to_python(input_data)
for language, value in input_data.items():
assert localized_value.get(language) == value
@staticmethod
def test_to_python_none():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue
instance when it is passes None."""
localized_value = LocalizedField().to_python(None)
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_non_dict():
"""Tests whether the :see:to_python function produces
the expected :see:LocalizedValue when it is
passed a non-dictionary value."""
localized_value = LocalizedField().to_python(list())
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_get_prep_value():
""""Tests whether the :see:get_prep_value function
produces the expected dictionary."""
input_data = get_init_values()
localized_value = LocalizedValue(input_data)
output_data = LocalizedField().get_prep_value(localized_value)
for language, value in input_data.items():
assert language in output_data
assert output_data.get(language) == value
@staticmethod
def test_get_prep_value_none():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed None."""
output_data = LocalizedField().get_prep_value(None)
assert not output_data
@staticmethod
def test_get_prep_value_no_localized_value():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed a
non-LocalizedValue value."""
output_data = LocalizedField().get_prep_value(['huh'])
assert not output_data
def test_get_prep_value_clean(self):
"""Tests whether the :see:get_prep_value produces
None as the output when it is passed an empty, but
valid LocalizedValue value but, only when null=True."""
localized_value = LocalizedValue()
with self.assertRaises(IntegrityError):
LocalizedField(null=False).get_prep_value(localized_value)
assert not LocalizedField(null=True).get_prep_value(localized_value)
assert not LocalizedField().clean(None)
assert not LocalizedField().clean(['huh'])
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function
correctly returns a valid form."""
assert isinstance(
LocalizedField().formfield(),
LocalizedFieldForm
)

View File

@@ -1,6 +1,7 @@
from django.test import TestCase
from localized_fields import LocalizedField, LocalizedValue
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
@@ -33,7 +34,6 @@ class LocalizedModelTestCase(TestCase):
assert isinstance(obj.title, LocalizedValue)
@classmethod
def test_model_init_kwargs(cls):
"""Tests whether all :see:LocalizedField

View File

@@ -1,11 +1,16 @@
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,
LocalizedUniqueSlugField)
from localized_fields.fields import (
LocalizedField,
LocalizedAutoSlugField,
LocalizedUniqueSlugField
)
from .fake_model import get_fake_model
@@ -43,7 +48,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 +56,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 +64,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 +136,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,7 +144,7 @@ class LocalizedSlugFieldTestCase(TestCase):
cls._test_deconstruct(LocalizedAutoSlugField)
@classmethod
def test_deconstruct_magic(cls):
def test_deconstruct_unique(cls):
cls._test_deconstruct(LocalizedUniqueSlugField)
@classmethod
@@ -99,7 +152,7 @@ class LocalizedSlugFieldTestCase(TestCase):
cls._test_formfield(LocalizedAutoSlugField)
@classmethod
def test_formfield_magic(cls):
def test_formfield_unique(cls):
cls._test_formfield(LocalizedUniqueSlugField)
@staticmethod
@@ -130,7 +183,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

164
tests/test_value.py Normal file
View File

@@ -0,0 +1,164 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from localized_fields.value import LocalizedValue
from .data import get_init_values
class LocalizedValueTestCase(TestCase):
"""Tests the :see:LocalizedValue class."""
@staticmethod
def tearDown():
"""Assures that the current language
is set back to the default."""
translation.activate(settings.LANGUAGE_CODE)
@staticmethod
def test_init():
"""Tests whether the __init__ function
of the :see:LocalizedValue class works
as expected."""
keys = get_init_values()
value = LocalizedValue(keys)
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code, None) == keys[lang_code]
@staticmethod
def test_init_default_values():
"""Tests whether the __init__ function
of the :see:LocalizedValue accepts the
default value or an empty dict properly."""
value = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None
@staticmethod
def test_init_array():
"""Tests whether the __init__ function
of :see:LocalizedValue properly handles an
array.
Arrays can be passed to LocalizedValue as
a result of a ArrayAgg operation."""
value = LocalizedValue(['my value'])
assert value.get(settings.LANGUAGE_CODE) == 'my value'
@staticmethod
def test_get_explicit():
"""Tests whether the the :see:LocalizedValue
class's :see:get function works properly
when specifying an explicit value."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
assert localized_value.get(language) == value
@staticmethod
def test_get_default_language():
"""Tests whether the :see:LocalizedValue
class's see:get function properly
gets the value in the default language."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, _ in keys.items():
translation.activate(language)
assert localized_value.get() == keys[settings.LANGUAGE_CODE]
@staticmethod
def test_set():
"""Tests whether the :see:LocalizedValue
class's see:set function works properly."""
localized_value = LocalizedValue()
for language, value in get_init_values():
localized_value.set(language, value)
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_str():
"""Tests whether the :see:LocalizedValue
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
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
class's __str__'s fallback functionality
works properly."""
test_value = 'myvalue'
localized_value = LocalizedValue({
settings.LANGUAGE_CODE: test_value
})
other_language = settings.LANGUAGES[-1][0]
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
# make sure that it falls back to the
# primary language when there's no value
# available in the current language
translation.activate(other_language)
assert str(localized_value) == test_value
# make sure that it's just __str__ falling
# back and that for the other language
# there's no actual value
assert localized_value.get(other_language) != test_value
@staticmethod
def test_deconstruct():
"""Tests whether the :see:LocalizedValue
class's :see:deconstruct function works properly."""
keys = get_init_values()
value = LocalizedValue(keys)
path, args, kwargs = value.deconstruct()
assert args[0] == keys
@staticmethod
def test_construct_string():
"""Tests whether the :see:LocalizedValue's constructor
assumes the primary language when passing a single string."""
value = LocalizedValue('beer')
assert value.get(settings.LANGUAGE_CODE) == 'beer'

View File

@@ -1,7 +1,8 @@
from django.conf import settings
from django.test import TestCase
from localized_fields import LocalizedFieldWidget, LocalizedValue
from localized_fields.value import LocalizedValue
from localized_fields.widgets import LocalizedFieldWidget
class LocalizedFieldWidgetTestCase(TestCase):