54 Commits

Author SHA1 Message Date
Swen Kooij
8cc50889ec Set version back to 5.0a11, it was never released 2019-06-27 13:30:19 +03:00
Swen Kooij
8494615d05 Upgrade django-postgres-extra to 1.22 2019-06-27 13:28:29 +03:00
Swen Kooij
f0541c047b Bump version number to 5.0a12 2019-06-27 13:27:59 +03:00
Swen Kooij
84658f6010 Bump version number to 5.0a10 2019-06-05 23:26:47 +03:00
Swen Kooij
0e29871458 Merge pull request #62 from GabLeRoux/patch-1
Remove extra dot in example integration code
2019-06-05 23:12:43 +03:00
Swen Kooij
ffe235d3ac Merge pull request #66 from martinsvoboda/admin-widget-image
Added the image of admin widget
2019-06-05 23:12:25 +03:00
Swen Kooij
d7db2c58c0 Merge pull request #68 from MELScience/fix-django-22
update to support Django 2.2
2019-06-05 23:11:38 +03:00
Dmitry Groshev
6a7545a910 update to support Django 2.2 2019-05-30 13:11:33 +01:00
Martin Svoboda
11bf4ee88a Added the image of admin widget 2019-05-26 11:12:45 +02:00
Gabriel Le Breton
d3223eca53 Remove extra dot in example integration code 2019-04-23 11:12:29 -04:00
Swen Kooij
f0ac0f7f25 Merge branch 'bump-version' 2019-02-21 12:54:04 +02:00
Swen Kooij
93ffce557c Bump version number to 5.0a9 2019-02-21 12:50:58 +02:00
Swen Kooij
7c432baec7 Upgrade django-postgres-extra to 1.21a16 2019-02-21 12:50:46 +02:00
Swen Kooij
476a20ba88 Merge pull request #61 from SectorLabs/bump-version
Bump version number to 5.0a8
2019-02-21 12:47:15 +02:00
Swen Kooij
ad99b77bcd Bump version number to 5.0a8 2019-02-21 12:34:22 +02:00
Swen Kooij
151250505d Merge pull request #60 from AdrianMuntean/error_on_empty_localized_integer_field
Return empty string in case the LocalizedIntegerField is null
2019-02-21 12:31:50 +02:00
Adrian Muntean
d8b872758c Return empty string in case of None 2019-02-20 12:26:04 +02:00
Adrian Muntean
a0ca977cab Set None in case the LocalizedIntegerField is null
In case the LocalizedIntegerField is null in the DB then it must explicitly be set to None,
otherwise it will yield TypeError: __str__ returned non-string
2019-02-14 14:48:38 +02:00
Swen Kooij
eb2cb6b244 Bump version number to 5.0a7 2019-01-14 11:00:20 +02:00
Swen Kooij
ed15fb0079 Upgrade django-postgres-extra>=1.21a15 2019-01-14 11:00:12 +02:00
Swen Kooij
f59904f8ea Bump version number to 5.0a6 2019-01-11 15:03:19 +02:00
Swen Kooij
f20966d6d2 Fix tests not passing for Django 2.X 2019-01-11 15:02:22 +02:00
Swen Kooij
25417b5815 Merge pull request #56 from sliverc/user_defined_pk_descriptor
Avoid DoesNotExist error when creating model with user defined pk
2019-01-11 14:47:33 +02:00
Swen Kooij
abd1587ca0 Merge pull request #54 from sliverc/query_by_active_lang
Add support for localized query lookups
2019-01-11 14:47:18 +02:00
Swen Kooij
60fc79e9ff Merge pull request #57 from velrest/master
Fix typo in documentation for clean
2019-01-11 14:46:21 +02:00
Swen Kooij
ca470fc577 Merge pull request #49 from MELScience/admin-fix
Add tests for LocalizedFieldsAdminMixin
2019-01-11 14:46:10 +02:00
Swen Kooij
fac1a595aa Merge pull request #55 from sliverc/hstore_extension
Enable HStore extension for localized fields to work
2019-01-11 14:44:05 +02:00
Swen Kooij
d66f5085a8 Merge branch 'master' into hstore_extension 2019-01-11 14:40:38 +02:00
Swen Kooij
c6e8321ae7 Add CircleCI badge to README 2019-01-11 14:40:27 +02:00
Swen Kooij
b2f50ec82b Convert to use CircleCI and run tests against Django 2.1/Python 3.7 2019-01-11 14:37:03 +02:00
Oliver Sauder
ff836836bf Add support for localized query look ups 2018-12-03 09:45:08 +01:00
Swen Kooij
acf8867974 Merge pull request #52 from SectorLabs/localized-integer-field-widget
Add LocalizedIntegerFieldWidget
2018-09-24 15:16:55 +03:00
Cristi Ingineru
4922a1b93f self.translate() 2018-09-24 12:57:46 +03:00
Jonas Cosandey
d308e773cf fix typo 2018-09-12 14:27:44 +02:00
Oliver Sauder
b3b88d6d28 Avoid does not exist error when creating model with user defined pk 2018-09-10 12:05:46 +02:00
Oliver Sauder
6c902229ce Enable HStore extension for localized fields to work 2018-08-27 15:07:41 +02:00
Cristi Ingineru
4f83cbf4ed Add LocalizedIntegerFieldWidget 2018-08-16 14:27:38 +03:00
Swen Kooij
88e2d29596 Bump version number to 5.0a3 2018-06-28 12:39:25 +03:00
Swen Kooij
588f32086b Merge pull request #51 from SectorLabs/multiwidget
Copy the widget for each language
2018-06-28 12:39:02 +03:00
Cristi Ingineru
13e2666a51 Copy the widget for each language 2018-06-28 12:33:44 +03:00
Swen Kooij
2393539b44 Bump version number to 5.0a2 2018-06-19 12:30:04 +03:00
Swen Kooij
e322b3d63b Upgrade django-postgres-extra to 1.21a11 2018-06-19 12:29:55 +03:00
Swen Kooij
1b1d24a460 Make defaults work for LocalizedIntegerField 2018-06-15 17:07:12 +03:00
Swen Kooij
fb233e8f25 Make sure values are strings before saving LocalizedIntegerValue 2018-06-15 16:19:32 +03:00
Swen Kooij
bca94a3508 Add quick docs on LocalizedIntegerField 2018-06-15 13:04:00 +03:00
Swen Kooij
8c83fa6b49 Bump version number to 5.0a1 2018-06-15 12:58:27 +03:00
Swen Kooij
90597da8fd Add a LocalizedIntegerField 2018-06-15 12:58:01 +03:00
Swen Kooij
752e17064d Deprecate LocalizedFileValue.localized() 2018-06-14 08:01:10 +03:00
Swen Kooij
def7dae640 Add LocalizedValue.translate()
LocalizedValue.translate() behaves the exact same as the str(..) cast
works, with the exception that it returns None if there is no value
instead of an empty string. This makes it easier to implement custom
value classes on top of the LocalizedValue class.

Behavior for str(..) stays the same as it was.
2018-06-14 07:57:02 +03:00
seroy
8ba0540237 Fix using LocalizedFieldsAdminMixin with inlines 2018-04-20 02:33:15 +03:00
seroy
b3624916b2 Add tests for LocalizedFieldsAdminMixin 2018-04-20 02:27:57 +03:00
Swen Kooij
db4324fbf3 Bump version number to 4.6a3 2018-04-02 15:59:42 +03:00
Swen Kooij
9ff0b781ab Upgrade django-postgres-extra to 1.21a9 2018-04-02 15:59:27 +03:00
Swen Kooij
a76101c9ad Fix LocalizedFieldsAdminMixin not having a base class
This was a breaking change and broke a lot of projects.
2018-04-02 15:59:16 +03:00
34 changed files with 837 additions and 104 deletions

92
.circleci/config.yml Normal file
View File

@@ -0,0 +1,92 @@
version: 2
jobs:
test-python35:
docker:
- image: python:3.5-alpine
- image: postgres:11.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install -r requirements/test.txt
- run:
name: Run tests
command: tox -e 'py35-dj{111,20,21}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
test-python36:
docker:
- image: python:3.6-alpine
- image: postgres:11.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install -r requirements/test.txt
- run:
name: Run tests
command: tox -e 'py36-dj{111,20,21}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
test-python37:
docker:
- image: python:3.7-alpine
- image: postgres:11.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install -r requirements/test.txt
- run:
name: Run tests
command: tox -e 'py37-dj{111,20,21}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
lint:
docker:
- image: python:3.5-alpine
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install -r requirements/test.txt
- run:
name: Lint code
command: python setup.py lint
workflows:
version: 2
build:
jobs:
- test-python35
- test-python36
- test-python37
- lint

View File

@@ -1,5 +1,3 @@
[run]
include = localized_fields/*
omit = *migrations*, *tests*
plugins =
django_coverage_plugin

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Ignore virtual environments
env/
.env/
# Ignore Python byte code cache
*.pyc

View File

@@ -1,34 +0,0 @@
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'
variables:
DJANGO_SETTINGS_MODULES: settings
DATABASE_URL: postgres://scrutinizer:scrutinizer@localhost:5434/localized_fields
postgresql: true
dependencies:
override:
- 'pip install -r requirements/test.txt'
tests:
override:
-
command: pep8 ./localized_fields/
-
command: flake8 ./localized_fields/
-
command: tox
coverage:
file: '.coverage'
format: 'py-cc'

View File

@@ -1,11 +1,8 @@
django-localized-fields
=======================
.. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/quality-score.png
:target: https://scrutinizer-ci.com/g/SectorLabs/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://circleci.com/gh/SectorLabs/django-localized-fields.svg?style=svg
:target: https://circleci.com/gh/SectorLabs/django-localized-fields
.. image:: https://img.shields.io/github/license/SectorLabs/django-localized-fields.svg
:target: https://github.com/SectorLabs/django-localized-fields/blob/master/LICENSE.md
@@ -36,7 +33,7 @@ Installation
.. code-block:: bash
INSTALLED_APPS = [
....
...
'django.contrib.postgres',
'localized_fields.apps.LocalizedFieldsConfig'
@@ -121,6 +118,7 @@ Or get it in a specific language:
print(new.title.get('en')) # prints 'english title'
print(new.title.get('ro')) # prints 'romanian title'
print(new.title.get()) # whatever language is the primary one
print(new.title.get('ar', 'haha')) # prints 'haha' if there is no value in arabic
You can also explicitly set a value in a certain language:
@@ -136,7 +134,7 @@ Constraints
**Required/Optional**
Constraints is enforced on a database level.
Constraints are enforced on a database level.
* Optional filling
@@ -274,8 +272,24 @@ Besides ``LocalizedField``, there's also:
title = LocalizedField()
description = LocalizedBleachField()
* ``LocalizedIntegerField``
This is an experimental field type introduced in version 5.0 and is subject to change. It also has
some pretty major downsides due to the fact that values are stored as strings and are converted
back and forth.
Allows storing integers in multiple languages. This works exactly like ``LocalizedField`` except that
all values must be integers. Do note that values are stored as strings in your database because
the backing field type is ``hstore``, which only allows storing strings. The ``LocalizedIntegerField``
takes care of ensuring that all values are integers and converts the stored strings back to integers
when retrieving them from the database. Do not expect to be able to do queries such as:
.. code-block:: python
MyModel.objects.filter(score__en__gt=1)
* ``LocalizedCharField`` and ``LocalizedTextField``
This fields following the Django convention for string-based fields use the empty string as value for “no data”, not NULL.
These fields following the Django convention for string-based fields use the empty string as value for “no data”, not NULL.
``LocalizedCharField`` uses ``TextInput`` (``<input type="text">``) widget for render.
Example usage:
@@ -331,6 +345,7 @@ Experimental feature
^^^^^^^^^^^^^^^^^^^^
Enables the following experimental features:
* ``LocalizedField`` will return ``None`` instead of an empty ``LocalizedValue`` if there is no database value.
* ``LocalizedField`` lookups will lookup by currently active language instead of HStoreField
.. code-block:: python
@@ -354,6 +369,10 @@ To enable widgets in the admin, you need to inherit from ``LocalizedFieldsAdminM
admin.site.register(MyLocalizedModel, MyLocalizedModelAdmin)
.. image:: ./images/admin-widget.png
:alt: The appearance of admin widget
Frequently asked questions (FAQ)
--------------------------------

BIN
images/admin-widget.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -0,0 +1 @@
default_app_config = 'localized_fields.apps.LocalizedFieldsConfig'

View File

@@ -22,6 +22,7 @@ class LocalizedFieldsAdminMixin:
}
js = (
'admin/js/jquery.init.js',
'localized_fields/localized-fields-admin.js',
)

View File

@@ -1,5 +1,21 @@
import inspect
from django.apps import AppConfig
from django.conf import settings
from . import lookups
from .fields import LocalizedField
from .lookups import LocalizedLookupMixin
class LocalizedFieldsConfig(AppConfig):
name = 'localized_fields'
def ready(self):
if getattr(settings, 'LOCALIZED_FIELDS_EXPERIMENTAL', False):
for _, clazz in inspect.getmembers(lookups):
if not inspect.isclass(clazz) or clazz is LocalizedLookupMixin:
continue
if issubclass(clazz, LocalizedLookupMixin):
LocalizedField.register_lookup(clazz)

View File

@@ -41,7 +41,7 @@ class LocalizedValueDescriptor:
if self.field.name in instance.__dict__:
value = instance.__dict__[self.field.name]
elif instance.pk is not None:
elif not instance._state.adding:
instance.refresh_from_db(fields=[self.field.name])
value = getattr(instance, self.field.name)
else:

View File

@@ -4,6 +4,7 @@ from .uniqueslug_field import LocalizedUniqueSlugField
from .char_field import LocalizedCharField
from .text_field import LocalizedTextField
from .file_field import LocalizedFileField
from .integer_field import LocalizedIntegerField
__all__ = [
@@ -12,7 +13,8 @@ __all__ = [
'LocalizedUniqueSlugField',
'LocalizedCharField',
'LocalizedTextField',
'LocalizedFileField'
'LocalizedFileField',
'LocalizedIntegerField'
]
try:

View File

@@ -1,6 +1,6 @@
import json
from typing import Union, List
from typing import Union, List, Optional
from django.conf import settings
from django.db.utils import IntegrityError
@@ -53,7 +53,7 @@ class LocalizedField(HStoreField):
setattr(model, self.name, self.descriptor_class(self))
@classmethod
def from_db_value(cls, value, *_):
def from_db_value(cls, value, *_) -> Optional[LocalizedValue]:
"""Turns the specified database value into its Python
equivalent.
@@ -165,7 +165,7 @@ class LocalizedField(HStoreField):
can store in the database.
For example, when all the language fields are
left empty, and the field is allows to be null,
left empty, and the field is allowed to be null,
we will store None instead of empty keys.
Arguments:

View File

@@ -0,0 +1,91 @@
from typing import Optional, Union, Dict
from django.conf import settings
from django.db.utils import IntegrityError
from .field import LocalizedField
from ..value import LocalizedValue, LocalizedIntegerValue
from ..forms import LocalizedIntegerFieldForm
class LocalizedIntegerField(LocalizedField):
"""Stores integers as a localized value."""
attr_class = LocalizedIntegerValue
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedIntegerValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
# if we were used in an expression somehow then it might be
# that we're returning an individual value or an array, so
# we should not convert that into an :see:LocalizedIntegerValue
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(self, value: Union[Dict[str, int], int, None]) -> LocalizedIntegerValue:
"""Converts the value from a database value into a Python value."""
db_value = super().to_python(value)
return self._convert_localized_value(db_value)
def get_prep_value(self, value: LocalizedIntegerValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedIntegerValue(self.default)
if isinstance(value, LocalizedIntegerValue):
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code)
if local_value is None:
value.set(lang_code, default_values.get(lang_code, None))
prepped_value = super().get_prep_value(value)
if prepped_value is None:
return None
# make sure all values are proper integers
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
try:
if local_value is not None:
int(local_value)
except (TypeError, ValueError):
raise IntegrityError('non-integer value in column "%s.%s" violates '
'integer constraint' % (self.name, lang_code))
# convert to a string before saving because the underlying
# type is hstore, which only accept strings
prepped_value[lang_code] = str(local_value) if local_value is not None else None
return prepped_value
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {
'form_class': LocalizedIntegerFieldForm
}
defaults.update(kwargs)
return super().formfield(**defaults)
@staticmethod
def _convert_localized_value(value: LocalizedValue) -> LocalizedIntegerValue:
"""Converts from :see:LocalizedValue to :see:LocalizedIntegerValue."""
integer_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if local_value is None or local_value.strip() == '':
local_value = None
try:
integer_values[lang_code] = int(local_value)
except (ValueError, TypeError):
integer_values[lang_code] = None
return LocalizedIntegerValue(integer_values)

View File

@@ -6,9 +6,9 @@ from django.core.exceptions import ValidationError
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from .value import LocalizedValue, LocalizedStringValue, \
LocalizedFileValue
LocalizedFileValue, LocalizedIntegerValue
from .widgets import LocalizedFieldWidget, LocalizedCharFieldWidget, \
LocalizedFileWidget
LocalizedFileWidget, AdminLocalizedIntegerFieldWidget
class LocalizedFieldForm(forms.MultiValueField):
@@ -79,6 +79,14 @@ class LocalizedTextFieldForm(LocalizedFieldForm):
value_class = LocalizedStringValue
class LocalizedIntegerFieldForm(LocalizedFieldForm):
"""Form for a localized integer field, allows editing
the field in multiple languages."""
widget = AdminLocalizedIntegerFieldWidget
value_class = LocalizedIntegerValue
class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField):
"""Form for a localized file field, allows editing
the field in multiple languages."""

View File

@@ -0,0 +1,80 @@
from django.conf import settings
from django.contrib.postgres.fields.hstore import KeyTransform
from django.contrib.postgres.lookups import (SearchLookup, TrigramSimilar,
Unaccent)
from django.db.models.expressions import Col
from django.db.models.lookups import (Contains, EndsWith, Exact, IContains,
IEndsWith, IExact, In, IRegex, IsNull,
IStartsWith, Regex, StartsWith)
from django.utils import translation
class LocalizedLookupMixin():
def process_lhs(self, qn, connection):
if isinstance(self.lhs, Col):
language = translation.get_language() or settings.LANGUAGE_CODE
self.lhs = KeyTransform(language, self.lhs)
return super().process_lhs(qn, connection)
def get_prep_lookup(self):
return str(self.rhs)
class LocalizedSearchLookup(LocalizedLookupMixin, SearchLookup):
pass
class LocalizedUnaccent(LocalizedLookupMixin, Unaccent):
pass
class LocalizedTrigramSimilair(LocalizedLookupMixin, TrigramSimilar):
pass
class LocalizedExact(LocalizedLookupMixin, Exact):
pass
class LocalizedIExact(LocalizedLookupMixin, IExact):
pass
class LocalizedIn(LocalizedLookupMixin, In):
pass
class LocalizedContains(LocalizedLookupMixin, Contains):
pass
class LocalizedIContains(LocalizedLookupMixin, IContains):
pass
class LocalizedStartsWith(LocalizedLookupMixin, StartsWith):
pass
class LocalizedIStartsWith(LocalizedLookupMixin, IStartsWith):
pass
class LocalizedEndsWith(LocalizedLookupMixin, EndsWith):
pass
class LocalizedIEndsWith(LocalizedLookupMixin, IEndsWith):
pass
class LocalizedIsNullWith(LocalizedLookupMixin, IsNull):
pass
class LocalizedRegexWith(LocalizedLookupMixin, Regex):
pass
class LocalizedIRegexWith(LocalizedLookupMixin, IRegex):
pass

View File

@@ -0,0 +1,14 @@
# Generated by Django 2.1 on 2018-08-27 08:05
from django.contrib.postgres.operations import HStoreExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
]
operations = [
HStoreExtension(),
]

View File

View File

@@ -1,4 +1,16 @@
{% for widget in widget.subwidgets %}
<label for="{{ widget.attrs.id }}">{{ widget.lang_name }}</label>
{% include widget.template_name %}
{% endfor %}
{% with widget_id=widget.attrs.id %}
<div class="localized-fields-widget" role="tabs" data-synctabs="translation">
<ul class="localized-fields-widget tabs">
{% for widget in widget.subwidgets %}
<li class="localized-fields-widget tab">
<label for="{{ widget_id }}_{{ widget.lang_code }}">{{ widget.lang_name|capfirst }}</label>
</li>
{% endfor %}
</ul>
{% for widget in widget.subwidgets %}
<div role="tabpanel" id="{{ widget_id }}_{{ widget.lang_code }}">
{% include widget.template_name %}
</div>
{% endfor %}
</div>
{% endwith %}

View File

@@ -1,5 +1,7 @@
import deprecation
import collections
from typing import Optional
from django.conf import settings
from django.utils import translation
@@ -36,7 +38,8 @@ class LocalizedValue(dict):
"""
language = language or settings.LANGUAGE_CODE
return super().get(language, default)
value = super().get(language, default)
return value if value is not None else default
def set(self, language: str, value: str):
"""Sets the value in the specified language.
@@ -98,10 +101,10 @@ class LocalizedValue(dict):
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."""
def translate(self) -> Optional[str]:
"""Gets the value in the current language or falls
back to the next language if there's no value in the
current language."""
fallbacks = getattr(settings, 'LOCALIZED_FIELDS_FALLBACKS', {})
@@ -112,9 +115,16 @@ class LocalizedValue(dict):
for lang_code in languages:
value = self.get(lang_code)
if value:
return value or ''
return value or None
return ''
return None
def __str__(self) -> str:
"""Gets the value in the current language or falls
back to the next language if there's no value in the
current language."""
return self.translate() or ''
def __eq__(self, other):
"""Compares :paramref:self to :paramref:other for
@@ -183,8 +193,44 @@ class LocalizedFileValue(LocalizedValue):
def __str__(self) -> str:
"""Returns string representation of value"""
return str(super().__str__())
@deprecation.deprecated(deprecated_in='4.6', removed_in='5.0',
current_version='4.6',
details='Use the translate() function instead.')
def localized(self):
"""Returns value for current language"""
return self.get(translation.get_language())
class LocalizedIntegerValue(LocalizedValue):
"""All values are integers."""
default_value = None
def translate(self):
"""Gets the value in the current language, or
in the configured fallbck language."""
value = super().translate()
if value is None or (isinstance(value, str) and value.strip() == ''):
return None
return int(value)
def __int__(self):
"""Gets the value in the current language as an integer."""
value = self.translate()
if value is None:
return self.default_value
return int(value)
def __str__(self) -> str:
"""Returns string representation of value"""
value = self.translate()
return str(value) if value is not None else ''

View File

@@ -1,3 +1,5 @@
import copy
from typing import List
from django.conf import settings
@@ -16,7 +18,7 @@ class LocalizedFieldWidget(forms.MultiWidget):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
initial_widgets = [
self.widget
copy.copy(self.widget)
for _ in settings.LANGUAGES
]
@@ -118,3 +120,7 @@ class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget):
class AdminLocalizedFileFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminFileWidget
class AdminLocalizedIntegerFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminIntegerFieldWidget

View File

@@ -1 +1,2 @@
django-postgres-extra==1.21a8
django-postgres-extra==1.21a9
deprecation==2.0.3

View File

@@ -2,15 +2,9 @@
django-autoslug==1.9.3
django-bleach==0.3.0
django-coverage-plugin==1.3.1
psycopg2==2.7.3.2
pylint==1.8.1
pylint-common==0.2.5
pylint-django==0.8.0
pylint-plugin-utils==0.2.6
coverage==4.4.2
django-coverage-plugin==1.3.1
flake8==3.5.0
flake8==3.6.0
pep8==1.7.1
dj-database-url==0.4.2
tox==2.9.1

View File

@@ -21,6 +21,9 @@ LANGUAGES = (
)
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.admin',
'localized_fields',
'tests',
)

View File

@@ -1,5 +1,6 @@
[flake8]
max-line-length = 120
ignore = E252, W605
exclude = env,.tox,.git,config/settings,*/migrations/*,*/static/CACHE/*,docs,node_modules
[pep8]

View File

@@ -1,13 +1,39 @@
import os
import distutils.cmd
import subprocess
from setuptools import find_packages, setup
class BaseCommand(distutils.cmd.Command):
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def create_command(text, commands):
"""Creates a custom setup.py command."""
class CustomCommand(BaseCommand):
description = text
def run(self):
for cmd in commands:
subprocess.check_call(cmd)
return CustomCommand
with open(os.path.join(os.path.dirname(__file__), 'README.rst'), encoding='utf-8') as readme:
README = readme.read()
setup(
name='django-localized-fields',
version='4.6a2',
version='5.0a11',
packages=find_packages(exclude=['tests']),
include_package_data=True,
license='MIT License',
@@ -18,8 +44,9 @@ setup(
author_email='open-source@sectorlabs.ro',
keywords=['django', 'localized', 'language', 'models', 'fields'],
install_requires=[
'django-postgres-extra>=1.11',
'Django>=1.11'
'django-postgres-extra>=1.22',
'Django>=1.11',
'deprecation==2.0.3'
],
classifiers=[
'Environment :: Web Environment',
@@ -31,5 +58,11 @@ setup(
'Programming Language :: Python :: 3.5',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
]
],
cmdclass={
'lint': create_command(
'Lints the code',
[['flake8', 'setup.py', 'localized_fields', 'tests']],
),
},
)

87
tests/test_admin.py Normal file
View File

@@ -0,0 +1,87 @@
from django.apps import apps
from django.contrib import admin
from django.contrib.admin.checks import check_admin_app
from django.db import models
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.admin import LocalizedFieldsAdminMixin
from tests.fake_model import get_fake_model
class LocalizedFieldsAdminMixinTestCase(TestCase):
"""Tests the :see:LocalizedFieldsAdminMixin class."""
TestModel = None
TestRelModel = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedFieldsAdminMixinTestCase, cls).setUpClass()
cls.TestRelModel = get_fake_model(
{
'description': LocalizedField()
}
)
cls.TestModel = get_fake_model(
{
'title': LocalizedField(),
'rel': models.ForeignKey(cls.TestRelModel,
on_delete=models.CASCADE)
}
)
def tearDown(self):
if admin.site.is_registered(self.TestModel):
admin.site.unregister(self.TestModel)
if admin.site.is_registered(self.TestRelModel):
admin.site.unregister(self.TestRelModel)
@classmethod
def test_model_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin
mixin are works with admin.ModelAdmin"""
@admin.register(cls.TestModel)
class TestModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin):
pass
assert len(check_admin_app(apps.get_app_configs())) == 0
@classmethod
def test_stackedmodel_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin mixin are works
with admin.StackedInline"""
class TestModelStackedInline(LocalizedFieldsAdminMixin,
admin.StackedInline):
model = cls.TestModel
@admin.register(cls.TestRelModel)
class TestRelModelAdmin(admin.ModelAdmin):
inlines = [
TestModelStackedInline,
]
assert len(check_admin_app(apps.get_app_configs())) == 0
@classmethod
def test_tabularmodel_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin mixin are works
with admin.TabularInline"""
class TestModelTabularInline(LocalizedFieldsAdminMixin,
admin.TabularInline):
model = cls.TestModel
@admin.register(cls.TestRelModel)
class TestRelModelAdmin(admin.ModelAdmin):
inlines = [
TestModelTabularInline,
]
assert len(check_admin_app(apps.get_app_configs())) == 0

View File

@@ -1,6 +1,7 @@
import json
from django.conf import settings
from django.db import models
from django.db.utils import IntegrityError
from django.test import TestCase
@@ -33,7 +34,6 @@ class LocalizedFieldTestCase(TestCase):
field = LocalizedField(required=False)
assert field.required == []
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function
@@ -205,6 +205,16 @@ class LocalizedFieldTestCase(TestCase):
for field in field.formfield().fields:
assert field.required
def test_descriptor_user_defined_primary_key(self):
"""Tests that descriptor works even when primary key is user defined."""
model = get_fake_model(dict(
slug=models.SlugField(primary_key=True),
title=LocalizedField()
))
obj = model.objects.create(slug='test', title='test')
assert obj.title == 'test'
def test_required_all(self):
"""Tests whether passing required=True properly validates
that all languages are filled in."""

View File

@@ -40,7 +40,6 @@ class LocalizedFieldFormTestCase(TestCase):
for field in form.fields:
assert not field.required
@staticmethod
def test_compress():
"""Tests whether the :see:compress function

176
tests/test_integer_field.py Normal file
View File

@@ -0,0 +1,176 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from django.conf import settings
from django.db import connection
from django.utils import translation
from localized_fields.fields import LocalizedIntegerField
from .fake_model import get_fake_model
class LocalizedIntegerFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedIntegerField
and :see:LocalizedIntegerValue works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({
'score': LocalizedIntegerField()
})
def test_basic(self):
"""Tests the basics of storing integer values."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj = self.TestModel.objects.all().first()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
assert obj.score.get(lang_code) == index + 1
def test_primary_language_required(self):
"""Tests whether the primary language is required by
default and all other languages are optiona."""
# not filling in anything should raise IntegrityError,
# the primary language is required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
obj.save()
# when filling all other languages besides the primary language
# should still raise an error because the primary is always required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
obj.score.set(lang_code, 23)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages
is NoneType."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 1234)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.score.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to an integer
results in the value being returned in the currently
active language as an integer."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj.refresh_from_db()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
with translation.override(lang_code):
assert int(obj.score) == index + 1
assert obj.score.translate() == index + 1
def test_translate_primary_fallback(self):
"""Tests whether casting the value to an integer
results in the value begin returned in the active
language and falls back to the primary language
if there is no value in that language."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.score.translate() == 25
assert int(obj.score) == 25
def test_get_default_value(self):
"""Tests whether getting the value in a specific
language properly returns the specified default
in case it is not available."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
assert obj.score.get(secondary_language, 1337) == 1337
def test_completely_optional(self):
"""Tests whether having all languages optional
works properly."""
model = get_fake_model({
'score': LocalizedIntegerField(null=True, required=[], blank=True)
})
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.score, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises
an error when trying to store a non-integer."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.score.set(lang_code, 'haha')
obj.save()
def test_none_if_illegal_value_stored(self):
"""Tests whether None is returned for a language
if the value stored in the database is not an
integer."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set score = 'en=>haha'" % table_name)
obj.refresh_from_db()
assert obj.score.get(settings.LANGUAGE_CODE) is None
def test_default_value(self):
"""Tests whether a default is properly set
when specified."""
model = get_fake_model({
'score': LocalizedIntegerField(default={settings.LANGUAGE_CODE: 75})
})
obj = model.objects.create()
assert obj.score.get(settings.LANGUAGE_CODE) == 75
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.score.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.score.get(lang_code) == 75
else:
assert obj.score.get(lang_code) is None

51
tests/test_lookups.py Normal file
View File

@@ -0,0 +1,51 @@
from django.apps import apps
from django.conf import settings
from django.test import TestCase, override_settings
from django.utils import translation
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
@override_settings(LOCALIZED_FIELDS_EXPERIMENTAL=True)
class LocalizedLookupsTestCase(TestCase):
"""Tests whether localized lookups properly work with."""
TestModel1 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedLookupsTestCase, cls).setUpClass()
# reload app as setting has changed
config = apps.get_app_config('localized_fields')
config.ready()
cls.TestModel = get_fake_model(
{
'text': LocalizedField(),
}
)
def test_localized_lookup(self):
"""Tests whether localized lookup properly works."""
self.TestModel.objects.create(
text=LocalizedValue(dict(en='text_en', ro='text_ro', nl='text_nl')),
)
# assert that it properly lookups the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
assert self.TestModel.objects.filter(text='text_' + lang_code).exists()
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
assert self.TestModel.objects.filter(text='text_en').exists()
# ensure that hstore lookups still work
assert self.TestModel.objects.filter(text__ro='text_ro').exists()

View File

@@ -1,6 +1,4 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from localized_fields.fields import LocalizedField

View File

@@ -1,7 +1,7 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from django.db.models import F
from django.conf import settings
from django.utils import translation
from django.test import TestCase, override_settings
from localized_fields.value import LocalizedValue
@@ -90,18 +90,6 @@ class LocalizedValueTestCase(TestCase):
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
@@ -116,9 +104,21 @@ class LocalizedValueTestCase(TestCase):
assert a != b
@staticmethod
def test_str_fallback():
def test_translate():
"""Tests whether the :see:LocalizedValue
class's __str__'s fallback functionality
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert localized_value.translate() == value
@staticmethod
def test_translate_fallback():
"""Tests whether the :see:LocalizedValue
class's translate()'s fallback functionality
works properly."""
test_value = 'myvalue'
@@ -131,13 +131,13 @@ class LocalizedValueTestCase(TestCase):
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
assert localized_value.translate() == 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
assert localized_value.translate() == test_value
# make sure that it's just __str__ falling
# back and that for the other language
@@ -145,12 +145,35 @@ class LocalizedValueTestCase(TestCase):
assert localized_value.get(other_language) != test_value
@staticmethod
def test_str_fallback_custom_fallback():
def test_translate_none():
"""Tests whether the :see:LocalizedValue
class's translate() method properly returns
None when there is no value."""
# with no value, we always expect it to return None
localized_value = LocalizedValue()
assert localized_value.translate() is None
assert str(localized_value) == ''
# with no value for the default language, the default
# behavior is to return None, unless a custom fallback
# chain is configured, which there is not for this test
other_language = settings.LANGUAGES[-1][0]
localized_value = LocalizedValue({
other_language: 'hey'
})
translation.activate(settings.LANGUAGE_CODE)
assert localized_value.translate() is None
assert str(localized_value) == ''
@staticmethod
def test_translate_fallback_custom_fallback():
"""Tests whether the :see:LocalizedValue class's
__str__'s fallback functionality properly respects
translate()'s fallback functionality properly respects
the LOCALIZED_FIELDS_FALLBACKS setting."""
settings.LOCALIZED_FIELDS_FALLBACKS = {
fallbacks = {
'nl': ['ro']
}
@@ -159,8 +182,9 @@ class LocalizedValueTestCase(TestCase):
'ro': 'ro'
})
with translation.override('nl'):
assert str(localized_value) == 'ro'
with override_settings(LOCALIZED_FIELDS_FALLBACKS=fallbacks):
with translation.override('nl'):
assert localized_value.translate() == 'ro'
@staticmethod
def test_deconstruct():

View File

@@ -16,6 +16,7 @@ class LocalizedFieldWidgetTestCase(TestCase):
widget = LocalizedFieldWidget()
assert len(widget.widgets) == len(settings.LANGUAGES)
assert len(set(widget.widgets)) == len(widget.widgets)
@staticmethod
def test_decompress():

View File

@@ -1,10 +1,12 @@
[tox]
envlist = py35-dj{111,20}
envlist = py35-dj{111,20,21,22}, py36-dj{111,20,21,22}, py37-dj{111,20,21,22}
[testenv]
deps =
dj111: Django>=1.11,<1.12
dj20: Django>=2.0,<2.1
dj21: Django>=2.1,<2.2
dj22: Django>=2.2,<2.3
-rrequirements/test.txt
setenv =
DJANGO_SETTINGS_MODULE=settings