Compare commits

..

No commits in common. "master" and "v6.1" have entirely different histories.
master ... v6.1

28 changed files with 114 additions and 800 deletions

View File

@ -3,59 +3,7 @@ jobs:
test-python36: test-python36:
docker: docker:
- image: python:3.6-alpine - image: python:3.6-alpine
- image: postgres:12.0 - 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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py36-dj{20,21,22,30,31,32}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python37:
docker:
- image: python:3.7-alpine
- image: postgres:12.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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py37-dj{20,21,22,30,31,32}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python38:
docker:
- image: python:3.8-alpine
- image: postgres:12.0
environment: environment:
POSTGRES_DB: 'localizedfields' POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields' POSTGRES_USER: 'localizedfields'
@ -72,7 +20,61 @@ jobs:
- run: - run:
name: Run tests name: Run tests
command: tox -e 'py38-dj{20,21,22,30,31,32,40,41,42}' command: tox -e 'py36-dj{20,21,22,30,31}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py37-dj{20,21,22,30,31}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python38:
docker:
- image: python:3.8-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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py38-dj{20,21,22,30,31}'
environment: environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields' DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
@ -82,7 +84,7 @@ jobs:
test-python39: test-python39:
docker: docker:
- image: python:3.9-alpine - image: python:3.9-alpine
- image: postgres:12.0 - image: postgres:11.0
environment: environment:
POSTGRES_DB: 'localizedfields' POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields' POSTGRES_USER: 'localizedfields'
@ -99,69 +101,16 @@ jobs:
- run: - run:
name: Run tests name: Run tests
command: tox -e 'py39-dj{30,31,32,40,41,42}' command: tox -e 'py39-dj{21,22,30,31}'
environment: environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields' DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results: - store_test_results:
path: reports path: reports
test-python310:
docker:
- image: python:3.10-alpine
- image: postgres:12.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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py310-dj{32,40,41,42,50}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python311:
docker:
- image: python:3.11-alpine
- image: postgres:12.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 --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py311-dj{42,50}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
analysis: analysis:
docker: docker:
- image: python:3.8-alpine - image: python:3.7-alpine
steps: steps:
- checkout - checkout
- run: - run:
@ -185,6 +134,4 @@ workflows:
- test-python37 - test-python37
- test-python38 - test-python38
- test-python39 - test-python39
- test-python310
- test-python311
- analysis - analysis

1
.gitignore vendored
View File

@ -15,7 +15,6 @@ reports/
# Ignore build results # Ignore build results
*.egg-info/ *.egg-info/
dist/ dist/
build/
pip-wheel-metadata pip-wheel-metadata
# Ignore stupid .DS_Store # Ignore stupid .DS_Store

View File

@ -1,13 +1,13 @@
| | | | | | | |
|--------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |--------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| :white_check_mark: | **Tests** | [![CircleCI](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master.svg?style=svg)](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master) | | :white_check_mark: | **Tests** | [![CircleCI](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master.svg?style=svg)](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master) |
| :memo: | **License** | [![License](https://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) | | :memo: | **License** | [![License](https://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) |
| :package: | **PyPi** | [![PyPi](https://badge.fury.io/py/django-localized-fields.svg)](https://pypi.python.org/pypi/django-localized-fields) | | :package: | **PyPi** | [![PyPi](https://badge.fury.io/py/django-localized-fields.svg)](https://pypi.python.org/pypi/django-localized-fields) |
| <img src="https://cdn.iconscout.com/icon/free/png-256/django-1-282754.png" width="22px" height="22px" align="center" /> | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0 | | <img src="https://icon-library.net/images/django-icon/django-icon-0.jpg" width="22px" height="22px" align="center" /> | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1 |
| <img src="http://www.iconarchive.com/download/i73027/cornmanthe3rd/plex/Other-python.ico" width="22px" height="22px" align="center" /> | **Python Versions** | 3.6, 3.7, 3.8, 3.9, 3.10, 3.11 | | <img src="http://www.iconarchive.com/download/i73027/cornmanthe3rd/plex/Other-python.ico" width="22px" height="22px" align="center" /> | **Python Versions** | 3.6, 3.7, 3.8, 3.9 |
| :book: | **Documentation** | [Read The Docs](https://django-localized-fields.readthedocs.io) | | :book: | **Documentation** | [Read The Docs](https://django-localized-fields.readthedocs.io) |
| :warning: | **Upgrade** | [Upgrade fom v5.x](https://django-localized-fields.readthedocs.io/en/latest/releases.html#v6-0) | :warning: | **Upgrade** | [Upgrade fom v5.x](https://django-localized-fields.readthedocs.io/en/latest/releases.html#v6-0)
| :checkered_flag: | **Installation** | [Installation Guide](https://django-localized-fields.readthedocs.io/en/latest/installation.html) | | :checkered_flag: | **Installation** | [Installation Guide](https://django-localized-fields.readthedocs.io/en/latest/installation.html) |
`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` since Django 1.10. `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` since Django 1.10.
@ -20,7 +20,7 @@
## Working with the code ## Working with the code
### Prerequisites ### Prerequisites
* PostgreSQL 12 or newer. * PostgreSQL 10 or newer.
* Django 2.0 or newer. * Django 2.0 or newer.
* Python 3.6 or newer. * Python 3.6 or newer.
@ -38,7 +38,7 @@
3. Create a postgres user for use in tests (skip if your default user is a postgres superuser): 3. Create a postgres user for use in tests (skip if your default user is a postgres superuser):
λ createuser --superuser localized_fields --pwprompt λ createuser --superuser psqlextra --pwprompt
λ export DATABASE_URL=postgres://localized_fields:<password>@localhost/localized_fields λ export DATABASE_URL=postgres://localized_fields:<password>@localhost/localized_fields
Hint: if you're using virtualenvwrapper, you might find it beneficial to put Hint: if you're using virtualenvwrapper, you might find it beneficial to put

View File

@ -1,4 +1 @@
import django default_app_config = "localized_fields.apps.LocalizedFieldsConfig"
if django.VERSION < (3, 2):
default_app_config = "localized_fields.apps.LocalizedFieldsConfig"

View File

@ -1,6 +1,5 @@
from . import widgets from . import widgets
from .fields import ( from .fields import (
LocalizedBooleanField,
LocalizedCharField, LocalizedCharField,
LocalizedField, LocalizedField,
LocalizedFileField, LocalizedFileField,
@ -12,7 +11,6 @@ FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = {
LocalizedCharField: {"widget": widgets.AdminLocalizedCharFieldWidget}, LocalizedCharField: {"widget": widgets.AdminLocalizedCharFieldWidget},
LocalizedTextField: {"widget": widgets.AdminLocalizedFieldWidget}, LocalizedTextField: {"widget": widgets.AdminLocalizedFieldWidget},
LocalizedFileField: {"widget": widgets.AdminLocalizedFileFieldWidget}, LocalizedFileField: {"widget": widgets.AdminLocalizedFileFieldWidget},
LocalizedBooleanField: {"widget": widgets.AdminLocalizedBooleanFieldWidget},
} }

View File

@ -1,5 +1,4 @@
from .autoslug_field import LocalizedAutoSlugField from .autoslug_field import LocalizedAutoSlugField
from .boolean_field import LocalizedBooleanField
from .char_field import LocalizedCharField from .char_field import LocalizedCharField
from .field import LocalizedField from .field import LocalizedField
from .file_field import LocalizedFileField from .file_field import LocalizedFileField
@ -17,7 +16,6 @@ __all__ = [
"LocalizedFileField", "LocalizedFileField",
"LocalizedIntegerField", "LocalizedIntegerField",
"LocalizedFloatField", "LocalizedFloatField",
"LocalizedBooleanField",
] ]
try: try:

View File

@ -16,14 +16,14 @@ from .field import LocalizedField
class LocalizedAutoSlugField(LocalizedField): class LocalizedAutoSlugField(LocalizedField):
"""Automatically provides slugs for a localized field upon saving.""" """Automatically provides slugs for a localized field upon saving."""
warnings.warn(
"LocalizedAutoSlug is deprecated and will be removed in the next major version.",
DeprecationWarning,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedAutoSlugField.""" """Initializes a new instance of :see:LocalizedAutoSlugField."""
warnings.warn(
"LocalizedAutoSlug is deprecated and will be removed in the next major version.",
DeprecationWarning,
)
self.populate_from = kwargs.pop("populate_from", None) self.populate_from = kwargs.pop("populate_from", None)
self.include_time = kwargs.pop("include_time", False) self.include_time = kwargs.pop("include_time", False)

View File

@ -1,5 +1,3 @@
import html
from django.conf import settings from django.conf import settings
from .field import LocalizedField from .field import LocalizedField
@ -9,23 +7,6 @@ class LocalizedBleachField(LocalizedField):
"""Custom version of :see:BleachField that is actually a """Custom version of :see:BleachField that is actually a
:see:LocalizedField.""" :see:LocalizedField."""
DEFAULT_SHOULD_ESCAPE = True
def __init__(self, *args, escape=True, **kwargs):
"""Initializes a new instance of :see:LocalizedBleachField."""
self.escape = escape
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.escape != self.DEFAULT_SHOULD_ESCAPE:
kwargs["escape"] = self.escape
return name, path, args, kwargs
def pre_save(self, instance, add: bool): def pre_save(self, instance, add: bool):
"""Ran just before the model is saved, allows us to built the slug. """Ran just before the model is saved, allows us to built the slug.
@ -61,14 +42,8 @@ class LocalizedBleachField(LocalizedField):
if not value: if not value:
continue continue
cleaned_value = bleach.clean(
value if self.escape else html.unescape(value),
**get_bleach_default_options()
)
localized_value.set( localized_value.set(
lang_code, lang_code, bleach.clean(value, **get_bleach_default_options())
cleaned_value if self.escape else html.unescape(cleaned_value),
) )
return localized_value return localized_value

View File

@ -1,110 +0,0 @@
from typing import Dict, Optional, Union
from django.conf import settings
from django.db.utils import IntegrityError
from ..forms import LocalizedBooleanFieldForm
from ..value import LocalizedBooleanValue, LocalizedValue
from .field import LocalizedField
class LocalizedBooleanField(LocalizedField):
"""Stores booleans as a localized value."""
attr_class = LocalizedBooleanValue
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedBooleanValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
if isinstance(db_value, str):
if db_value.lower() == "true":
return True
return False
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(
self, value: Union[Dict[str, str], str, None]
) -> LocalizedBooleanValue:
"""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: LocalizedBooleanValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedBooleanValue(self.default)
if isinstance(value, LocalizedBooleanValue):
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 values to be converted to bool
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
if local_value is not None and local_value.lower() not in (
"false",
"true",
):
raise IntegrityError(
'non-boolean value in column "%s.%s" violates '
"boolean 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": LocalizedBooleanFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)
@staticmethod
def _convert_localized_value(
value: LocalizedValue,
) -> LocalizedBooleanValue:
"""Converts from :see:LocalizedValue to :see:LocalizedBooleanValue."""
integer_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if isinstance(local_value, str):
if local_value.lower() == "false":
local_value = False
elif local_value.lower() == "true":
local_value = True
else:
raise ValueError(
f"Could not convert value {local_value} to boolean."
)
integer_values[lang_code] = local_value
elif local_value is not None:
raise TypeError(
f"Expected value of type str instead of {type(local_value)}."
)
return LocalizedBooleanValue(integer_values)

View File

@ -28,26 +28,18 @@ class LocalizedField(HStoreField):
descriptor_class = LocalizedValueDescriptor descriptor_class = LocalizedValueDescriptor
def __init__( def __init__(
self, self, *args, required: Union[bool, List[str]] = None, **kwargs
*args,
required: Optional[Union[bool, List[str]]] = None,
blank: bool = False,
**kwargs
): ):
"""Initializes a new instance of :see:LocalizedField.""" """Initializes a new instance of :see:LocalizedField."""
if (required is None and blank) or required is False: super(LocalizedField, self).__init__(*args, required=required, **kwargs)
self.required = []
elif required is None and not blank:
self.required = [settings.LANGUAGE_CODE]
elif required is True:
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
else:
self.required = required
super(LocalizedField, self).__init__( if (self.required is None and self.blank) or self.required is False:
*args, required=self.required, blank=blank, **kwargs self.required = []
) elif self.required is None and not self.blank:
self.required = [settings.LANGUAGE_CODE]
elif self.required is True:
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
def contribute_to_class(self, model, name, **kwargs): def contribute_to_class(self, model, name, **kwargs):
"""Adds this field to the specifed model. """Adds this field to the specifed model.

View File

@ -21,10 +21,6 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
When in doubt, use this over :see:LocalizedAutoSlugField. When in doubt, use this over :see:LocalizedAutoSlugField.
Inherit from :see:AtomicSlugRetryMixin in your model to Inherit from :see:AtomicSlugRetryMixin in your model to
make this field work properly. make this field work properly.
By default, this creates a new slug if the field(s) specified
in `populate_from` are changed. Set `immutable=True` to get
immutable slugs.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -32,9 +28,6 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
kwargs["uniqueness"] = kwargs.pop("uniqueness", get_language_codes()) kwargs["uniqueness"] = kwargs.pop("uniqueness", get_language_codes())
self.enabled = kwargs.pop("enabled", True)
self.immutable = kwargs.pop("immutable", False)
super(LocalizedUniqueSlugField, self).__init__(*args, **kwargs) super(LocalizedUniqueSlugField, self).__init__(*args, **kwargs)
self.populate_from = kwargs.pop("populate_from") self.populate_from = kwargs.pop("populate_from")
@ -49,13 +42,6 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
kwargs["populate_from"] = self.populate_from kwargs["populate_from"] = self.populate_from
kwargs["include_time"] = self.include_time kwargs["include_time"] = self.include_time
if self.enabled is False:
kwargs["enabled"] = self.enabled
if self.immutable is True:
kwargs["immutable"] = self.immutable
return name, path, args, kwargs return name, path, args, kwargs
def pre_save(self, instance, add: bool): def pre_save(self, instance, add: bool):
@ -73,9 +59,6 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
The localized slug that was generated. The localized slug that was generated.
""" """
if not self.enabled:
return getattr(instance, self.name)
if not isinstance(instance, AtomicSlugRetryMixin): if not isinstance(instance, AtomicSlugRetryMixin):
raise ImproperlyConfigured( raise ImproperlyConfigured(
( (
@ -93,14 +76,10 @@ class LocalizedUniqueSlugField(LocalizedAutoSlugField):
slug = slugify(value, allow_unicode=True) slug = slugify(value, allow_unicode=True)
current_slug = getattr(instance, self.name).get(lang_code)
if current_slug and self.immutable:
slugs.set(lang_code, current_slug)
continue
# verify whether it's needed to re-generate a slug, # verify whether it's needed to re-generate a slug,
# if not, re-use the same slug # if not, re-use the same slug
if instance.pk is not None: if instance.pk is not None:
current_slug = getattr(instance, self.name).get(lang_code)
if current_slug is not None: if current_slug is not None:
current_slug_end_index = current_slug.rfind("-") current_slug_end_index = current_slug.rfind("-")
stripped_slug = current_slug[0:current_slug_end_index] stripped_slug = current_slug[0:current_slug_end_index]

View File

@ -6,14 +6,12 @@ from django.core.exceptions import ValidationError
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
from .value import ( from .value import (
LocalizedBooleanValue,
LocalizedFileValue, LocalizedFileValue,
LocalizedIntegerValue, LocalizedIntegerValue,
LocalizedStringValue, LocalizedStringValue,
LocalizedValue, LocalizedValue,
) )
from .widgets import ( from .widgets import (
AdminLocalizedBooleanFieldWidget,
AdminLocalizedIntegerFieldWidget, AdminLocalizedIntegerFieldWidget,
LocalizedCharFieldWidget, LocalizedCharFieldWidget,
LocalizedFieldWidget, LocalizedFieldWidget,
@ -104,15 +102,6 @@ class LocalizedIntegerFieldForm(LocalizedFieldForm):
value_class = LocalizedIntegerValue value_class = LocalizedIntegerValue
class LocalizedBooleanFieldForm(LocalizedFieldForm, forms.BooleanField):
"""Form for a localized boolean field, allows editing the field in multiple
languages."""
widget = AdminLocalizedBooleanFieldWidget
field_class = forms.fields.BooleanField
value_class = LocalizedBooleanValue
class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField): class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField):
"""Form for a localized file field, allows editing the field in multiple """Form for a localized file field, allows editing the field in multiple
languages.""" languages."""

View File

@ -23,7 +23,6 @@ from django.db.models.lookups import (
StartsWith, StartsWith,
) )
from django.utils import translation from django.utils import translation
from psqlextra.expressions import HStoreColumn
from .fields import LocalizedField from .fields import LocalizedField
@ -38,35 +37,12 @@ except ImportError:
class LocalizedLookupMixin: class LocalizedLookupMixin:
def process_lhs(self, qn, connection): def process_lhs(self, qn, connection):
# If the LHS is already a reference to a specific hstore key, there if isinstance(self.lhs, Col):
# is nothing to be done since it already references as specific language. language = translation.get_language() or settings.LANGUAGE_CODE
if isinstance(self.lhs, HStoreColumn) or isinstance( self.lhs = KeyTransform(language, self.lhs)
self.lhs, KeyTransform
):
return super().process_lhs(qn, connection)
# If this is something custom expression, we don't really know how to
# handle that, so we better do nothing.
if not isinstance(self.lhs, Col):
return super().process_lhs(qn, connection)
# Select the key for the current language. We do this so that
#
# myfield__<lookup>=
#
# Is converted into:
#
# myfield__<lookup>__<current language>=
language = translation.get_language() or settings.LANGUAGE_CODE
self.lhs = KeyTransform(language, self.lhs)
return super().process_lhs(qn, connection) return super().process_lhs(qn, connection)
def get_prep_lookup(self): def get_prep_lookup(self):
# Django 4.0 removed the ability for isnull fields to be something
# other than a bool We should NOT convert them to strings
if isinstance(self.rhs, bool):
return self.rhs
return str(self.rhs) return str(self.rhs)

View File

@ -34,11 +34,10 @@
.localized-fields-widget.tabs .localized-fields-widget.tab label { .localized-fields-widget.tabs .localized-fields-widget.tab label {
padding: 5px 10px; padding: 5px 10px;
display: inline; display: inline-block;
text-decoration: none; text-decoration: none;
color: #fff; color: #fff;
width: initial; width: initial;
cursor: pointer;
} }
.localized-fields-widget.tabs .localized-fields-widget.tab.active, .localized-fields-widget.tabs .localized-fields-widget.tab.active,

View File

@ -136,15 +136,6 @@ class LocalizedValue(dict):
return None return None
def is_empty(self) -> bool:
"""Gets whether all the languages contain the default value."""
for lang_code, _ in settings.LANGUAGES:
if self.get(lang_code) != self.default_value:
return False
return True
def __str__(self) -> str: def __str__(self) -> str:
"""Gets the value in the current language or falls back to the next """Gets the value in the current language or falls back to the next
language if there's no value in the current language.""" language if there's no value in the current language."""
@ -237,35 +228,6 @@ class LocalizedFileValue(LocalizedValue):
return self.get(translation.get_language()) return self.get(translation.get_language())
class LocalizedBooleanValue(LocalizedValue):
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
if isinstance(value, bool):
return value
if value.lower() == "true":
return True
return False
def __bool__(self):
"""Gets the value in the current language as a boolean."""
value = self.translate()
return value
def __str__(self):
"""Returns string representation of value."""
value = self.translate()
return str(value) if value is not None else ""
class LocalizedNumericValue(LocalizedValue): class LocalizedNumericValue(LocalizedValue):
def __int__(self): def __int__(self):
"""Gets the value in the current language as an integer.""" """Gets the value in the current language as an integer."""

View File

@ -120,18 +120,6 @@ class AdminLocalizedFieldWidget(LocalizedFieldWidget):
widget = widgets.AdminTextareaWidget widget = widgets.AdminTextareaWidget
class AdminLocalizedBooleanFieldWidget(LocalizedFieldWidget):
widget = forms.Select
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedBooleanFieldWidget."""
super().__init__(*args, **kwargs)
for widget in self.widgets:
widget.choices = [("False", False), ("True", True)]
class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget): class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminTextInputWidget widget = widgets.AdminTextInputWidget

View File

@ -3,5 +3,3 @@ DJANGO_SETTINGS_MODULE=settings
testpaths=tests testpaths=tests
addopts=-m "not benchmark" addopts=-m "not benchmark"
junit_family=legacy junit_family=legacy
filterwarnings=
ignore::DeprecationWarning:localized_fields.fields.autoslug_field

View File

@ -1,4 +1,3 @@
import django
import dj_database_url import dj_database_url
DEBUG = True DEBUG = True
@ -9,7 +8,7 @@ SECRET_KEY = 'this is my secret key' # NOQA
TEST_RUNNER = 'django.test.runner.DiscoverRunner' TEST_RUNNER = 'django.test.runner.DiscoverRunner'
DATABASES = { DATABASES = {
'default': dj_database_url.config(default='postgres:///localized_fields'), 'default': dj_database_url.config(default='postgres:///psqlextra'),
} }
DATABASES['default']['ENGINE'] = 'psqlextra.backend' DATABASES['default']['ENGINE'] = 'psqlextra.backend'
@ -52,12 +51,6 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
] ]
# See: https://github.com/psycopg/psycopg2/issues/1293
if django.VERSION >= (3, 1):
USE_TZ = True
USE_I18N = True
TIME_ZONE = 'UTC'
# set to a lower number than the default, since # set to a lower number than the default, since
# we want the tests to be fast, default is 100 # we want the tests to be fast, default is 100
LOCALIZED_FIELDS_MAX_RETRIES = 3 LOCALIZED_FIELDS_MAX_RETRIES = 3

View File

@ -9,4 +9,4 @@ lines_between_types=1
include_trailing_comma=True include_trailing_comma=True
not_skip=__init__.py not_skip=__init__.py
known_standard_library=dataclasses known_standard_library=dataclasses
known_third_party=django_bleach,bleach,pytest known_third_party=django_bleach,bleach

View File

@ -36,7 +36,7 @@ with open(
setup( setup(
name="django-localized-fields", name="django-localized-fields",
version="6.8b5", version="6.1",
packages=find_packages(exclude=["tests"]), packages=find_packages(exclude=["tests"]),
include_package_data=True, include_package_data=True,
license="MIT License", license="MIT License",
@ -64,11 +64,6 @@ setup(
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
], ],
@ -76,29 +71,28 @@ setup(
install_requires=[ install_requires=[
"Django>=2.0", "Django>=2.0",
"django-postgres-extra>=2.0,<3.0", "django-postgres-extra>=2.0,<3.0",
"deprecation>=2.0.7", "deprecation==2.0.7",
], ],
extras_require={ extras_require={
':python_version <= "3.6"': ["dataclasses"], ':python_version <= "3.6"': ["dataclasses"],
"docs": ["Sphinx==2.2.0", "sphinx-rtd-theme==0.4.3"], "docs": ["Sphinx==2.2.0", "sphinx-rtd-theme==0.4.3"],
"test": [ "test": [
"tox==3.28.0", "tox==3.14.3",
"pytest==7.0.1", "pytest==5.3.2",
"pytest-django==4.5.2", "pytest-django==3.7.0",
"pytest-cov==2.12.1", "pytest-cov==2.8.1",
"dj-database-url==0.5.0", "dj-database-url==0.5.0",
"django-autoslug==1.9.9", "django-autoslug==1.9.6",
"django-bleach==0.9.0", "django-bleach==0.6.1",
"psycopg2==2.9.8", "psycopg2==2.8.4",
], ],
"analysis": [ "analysis": [
"black==22.3.0", "black==19.3b0",
"flake8==3.7.7", "flake8==3.7.7",
"autoflake==1.3", "autoflake==1.3",
"autopep8==1.4.4", "autopep8==1.4.4",
"isort==4.3.20", "isort==4.3.20",
"sl-docformatter==1.4", "sl-docformatter==1.4",
"click==8.0.2",
], ],
}, },
cmdclass={ cmdclass={

View File

@ -1,7 +1,6 @@
"""isort:skip_file.""" """isort:skip_file."""
import sys import sys
import html
import pytest import pytest
@ -67,24 +66,14 @@ class LocalizedBleachFieldTestCase(TestCase):
bleached_value = field.pre_save(model, False) bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value) self._validate(value, bleached_value)
def test_pre_save_do_not_escape(self):
"""Tests whether the :see:pre_save function works properly when field
escape argument is set to False."""
value = self._get_test_value()
model, field = self._get_test_model(value, escape=False)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value, False)
@staticmethod @staticmethod
def _get_test_model(value, escape=True): def _get_test_model(value):
"""Gets a test model and an artificially constructed """Gets a test model and a artifically constructed
:see:LocalizedBleachField instance to test with.""" :see:LocalizedBleachField instance to test with."""
model = ModelTest(value) model = ModelTest(value)
field = LocalizedBleachField(escape=escape) field = LocalizedBleachField()
field.attname = "value" field.attname = "value"
return model, field return model, field
@ -100,7 +89,7 @@ class LocalizedBleachFieldTestCase(TestCase):
return value return value
@staticmethod @staticmethod
def _validate(non_bleached_value, bleached_value, escaped_value=True): def _validate(non_bleached_value, bleached_value):
"""Validates whether the specified non-bleached value ended up being """Validates whether the specified non-bleached value ended up being
correctly bleached. correctly bleached.
@ -111,20 +100,14 @@ class LocalizedBleachFieldTestCase(TestCase):
bleached_value: bleached_value:
The value after bleaching. The value after bleaching.
""" """
for lang_code, _ in settings.LANGUAGES: for lang_code, _ in settings.LANGUAGES:
if not non_bleached_value.get(lang_code): if not non_bleached_value.get(lang_code):
assert not bleached_value.get(lang_code) assert not bleached_value.get(lang_code)
continue continue
cleaned_value = bleach.clean( expected_value = bleach.clean(
non_bleached_value.get(lang_code) non_bleached_value.get(lang_code), get_bleach_default_options()
if escaped_value
else html.unescape(non_bleached_value.get(lang_code)),
get_bleach_default_options(),
)
expected_value = (
cleaned_value if escaped_value else html.unescape(cleaned_value)
) )
assert bleached_value.get(lang_code) == expected_value assert bleached_value.get(lang_code) == expected_value

View File

@ -1,211 +0,0 @@
from django.conf import settings
from django.db import connection
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import translation
from localized_fields.fields import LocalizedBooleanField
from localized_fields.value import LocalizedBooleanValue
from .fake_model import get_fake_model
class LocalizedBooleanFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedBooleanField and
:see:LocalizedIntegerValue works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({"translated": LocalizedBooleanField()})
def test_basic(self):
"""Tests the basics of storing boolean values."""
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, False)
obj.save()
obj = self.TestModel.objects.all().first()
for lang_code, _ in settings.LANGUAGES:
assert obj.translated.get(lang_code) is False
def test_primary_language_required(self):
"""Tests whether the primary language is required by default and all
other languages are optional."""
# 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.translated.set(lang_code, True)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages is
NoneType."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, True)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.translated.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to a boolean results in the value
being returned in the currently active language as a boolean."""
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, True)
obj.save()
obj.refresh_from_db()
for lang_code, _ in settings.LANGUAGES:
with translation.override(lang_code):
assert bool(obj.translated) is True
assert obj.translated.translate() is True
def test_translate_primary_fallback(self):
"""Tests whether casting the value to a boolean results in the value
being returned in the active language and falls back to the primary
language if there is no value in that language."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, True)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.translated.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.translated.translate() is True
assert bool(obj.translated) is True
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.translated.set(settings.LANGUAGE_CODE, True)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.translated.get(secondary_language) is None
assert obj.translated.get(secondary_language, False) is False
def test_completely_optional(self):
"""Tests whether having all languages optional works properly."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
null=True, required=[], blank=True
)
}
)
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.translated, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises an error when trying to
store a non-boolean."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.translated.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 a boolean."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, False)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set translated = 'en=>haha'" % table_name)
with self.assertRaises(ValueError):
obj.refresh_from_db()
def test_default_value(self):
"""Tests whether a default is properly set when specified."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
default={settings.LANGUAGE_CODE: True}
)
}
)
obj = model.objects.create()
assert obj.translated.get(settings.LANGUAGE_CODE) is True
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.translated.get(lang_code) is True
else:
assert obj.translated.get(lang_code) is None
def test_default_value_update(self):
"""Tests whether a default is properly set when specified during
updates."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
default={settings.LANGUAGE_CODE: True}, null=True
)
}
)
obj = model.objects.create(
translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: False})
)
assert obj.translated.get(settings.LANGUAGE_CODE) is False
model.objects.update(
translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: None})
)
obj.refresh_from_db()
assert obj.translated.get(settings.LANGUAGE_CODE) is True
def test_callable_default_value(self):
output = {"en": True}
def func():
return output
model = get_fake_model({"test": LocalizedBooleanField(default=func)})
obj = model.objects.create()
assert obj.test["en"] == output["en"]

View File

@ -121,8 +121,8 @@ class LocalizedFieldTestCase(TestCase):
@staticmethod @staticmethod
def test_get_prep_value(): def test_get_prep_value():
"""Tests whether the :see:get_prep_value function produces the expected """"Tests whether the :see:get_prep_value function produces the
dictionary.""" expected dictionary."""
input_data = get_init_values() input_data = get_init_values()
localized_value = LocalizedValue(input_data) localized_value = LocalizedValue(input_data)

View File

@ -1,51 +0,0 @@
import django
import pytest
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
class LocalizedIsNullLookupsTestCase(TestCase):
"""Tests whether ref lookups properly work with."""
TestModel1 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedIsNullLookupsTestCase, cls).setUpClass()
cls.TestModel = get_fake_model(
{"text": LocalizedField(null=True, required=[])}
)
cls.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl="text_nl"))
)
cls.TestModel.objects.create(
text=None,
)
def test_isnull_lookup_valid_values(self):
"""Test whether isnull properly works with valid values."""
assert self.TestModel.objects.filter(text__isnull=True).exists()
assert self.TestModel.objects.filter(text__isnull=False).exists()
def test_isnull_lookup_null(self):
"""Test whether isnull crashes with None as value."""
with pytest.raises(ValueError):
assert self.TestModel.objects.filter(text__isnull=None).exists()
def test_isnull_lookup_string(self):
"""Test whether isnull properly works with string values on the
corresponding Django version."""
if django.VERSION < (4, 0):
assert self.TestModel.objects.filter(text__isnull="True").exists()
else:
with pytest.raises(ValueError):
assert self.TestModel.objects.filter(
text__isnull="True"
).exists()

View File

@ -3,7 +3,6 @@ from django.conf import settings
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import translation from django.utils import translation
from localized_fields.expressions import LocalizedRef
from localized_fields.fields import LocalizedField from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue from localized_fields.value import LocalizedValue
@ -50,18 +49,6 @@ class LocalizedLookupsTestCase(TestCase):
# ensure that hstore lookups still work # ensure that hstore lookups still work
assert self.TestModel.objects.filter(text__ro="text_ro").exists() assert self.TestModel.objects.filter(text__ro="text_ro").exists()
def test_localized_lookup_specific_isnull(self):
self.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl=None))
)
translation.activate("nl")
assert (
self.TestModel.objects.annotate(text_localized=LocalizedRef("text"))
.filter(text_localized__isnull=True)
.exists()
)
class LocalizedRefLookupsTestCase(TestCase): class LocalizedRefLookupsTestCase(TestCase):
"""Tests whether ref lookups properly work with.""" """Tests whether ref lookups properly work with."""

View File

@ -1,7 +1,5 @@
import copy import copy
import pytest
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@ -217,53 +215,6 @@ class LocalizedSlugFieldTestCase(TestCase):
for lang_code, lang_name in settings.LANGUAGES: for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == "title-%s" % lang_name.lower() assert obj.slug.get(lang_code) == "title-%s" % lang_name.lower()
@classmethod
def test_disable(cls):
"""Tests whether disabling auto-slugging works."""
Model = get_fake_model(
{
"title": LocalizedField(),
"slug": LocalizedUniqueSlugField(
populate_from="title", enabled=False
),
}
)
obj = Model()
obj.title = "test"
# should raise IntegrityError because auto-slugging
# is disabled and the slug field is NULL
with pytest.raises(IntegrityError):
obj.save()
@classmethod
def test_allows_override_when_immutable(cls):
"""Tests whether setting a value manually works and does not get
overriden."""
Model = get_fake_model(
{
"title": LocalizedField(),
"name": models.CharField(max_length=255),
"slug": LocalizedUniqueSlugField(
populate_from="title", immutable=True
),
}
)
obj = Model()
for lang_code, lang_name in settings.LANGUAGES:
obj.slug.set(lang_code, "my value %s" % lang_code)
obj.title.set(lang_code, "my title %s" % lang_code)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == "my value %s" % lang_code
@classmethod @classmethod
def test_unique_slug(cls): def test_unique_slug(cls):
"""Tests whether unique slugs are properly generated.""" """Tests whether unique slugs are properly generated."""
@ -297,13 +248,10 @@ class LocalizedSlugFieldTestCase(TestCase):
"""Tests whether the :see:deconstruct function properly retains options """Tests whether the :see:deconstruct function properly retains options
specified in the constructor.""" specified in the constructor."""
field = LocalizedUniqueSlugField( field = LocalizedUniqueSlugField(populate_from="title")
enabled=False, immutable=True, populate_from="title"
)
_, _, _, kwargs = field.deconstruct() _, _, _, kwargs = field.deconstruct()
assert not kwargs["enabled"] assert "populate_from" in kwargs
assert kwargs["immutable"]
assert kwargs["populate_from"] == field.populate_from assert kwargs["populate_from"] == field.populate_from
@staticmethod @staticmethod

View File

@ -38,17 +38,6 @@ class LocalizedValueTestCase(TestCase):
for lang_code, _ in settings.LANGUAGES: for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None assert getattr(value, lang_code) is None
@staticmethod
def test_is_empty():
"""Tests whether a newly constructed :see:LocalizedValue without any
content is considered "empty"."""
value = LocalizedValue()
assert value.is_empty()
value.set(settings.LANGUAGE_CODE, "my value")
assert not value.is_empty()
@staticmethod @staticmethod
def test_init_array(): def test_init_array():
"""Tests whether the __init__ function of :see:LocalizedValue properly """Tests whether the __init__ function of :see:LocalizedValue properly

View File

@ -1,5 +1,5 @@
[tox] [tox]
envlist = py36-dj{20,21,22,30,31,32}, py37-dj{20,21,22,30,31,32}, py38-dj{20,21,22,30,31,32,40,41,42}, py39-dj{30,31,32,40,41,42}, py310-dj{32,40,41,42,50}, py311-dj{42,50} envlist = py36-dj{20,21,22,30,31}, py37-dj{20,21,22,30,31}, py38-dj{20,21,22,30,31}, py39-dj{21,22,30,31}
[testenv] [testenv]
deps = deps =
@ -8,11 +8,6 @@ deps =
dj22: Django>=2.2,<2.3 dj22: Django>=2.2,<2.3
dj30: Django>=3.0,<3.0.2 dj30: Django>=3.0,<3.0.2
dj31: Django>=3.1,<3.2 dj31: Django>=3.1,<3.2
dj32: Django>=3.2,<4.0
dj40: Django>=4.0,<4.1
dj41: Django>=4.1,<4.2
dj42: Django>=4.2,<5.0
dj50: Django>=5.0,<5.1
.[test] .[test]
setenv = setenv =
DJANGO_SETTINGS_MODULE=settings DJANGO_SETTINGS_MODULE=settings