mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-04-25 11:42:54 +03:00
Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
d9913f1eec | ||
|
3b973361d3 | ||
|
4c5d45f25a | ||
|
e6527fff4c | ||
|
25259b8469 | ||
|
84beade9e0 | ||
|
150f671115 | ||
|
c35844b471 | ||
|
5bb16af6a4 | ||
|
a55986d28c | ||
|
bcd1f1cc1a | ||
|
463c415be2 | ||
|
fbc1eec754 | ||
|
911251ebaa | ||
|
a9906dd159 | ||
|
a66b3492cd | ||
|
bc494694f5 | ||
|
b0cfaea2b4 | ||
|
8c7d0773f7 | ||
|
cc911d4909 | ||
|
0f30cc1493 | ||
|
0d1e9510cf | ||
|
25c1c24ccb | ||
|
bd3005a7e9 | ||
|
7902d8225a | ||
|
f024e4feb5 | ||
|
92cb5e8b1f | ||
|
5c298ef13e | ||
|
1b3e5989d3 | ||
|
d57f9a41bb | ||
|
bd8924224e | ||
|
62e1e805c7 | ||
|
afc39745bf | ||
|
1406954dec | ||
|
afb94ecf66 |
@ -3,7 +3,7 @@ jobs:
|
|||||||
test-python36:
|
test-python36:
|
||||||
docker:
|
docker:
|
||||||
- image: python:3.6-alpine
|
- image: python:3.6-alpine
|
||||||
- image: postgres:11.0
|
- image: postgres:12.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: 'localizedfields'
|
POSTGRES_DB: 'localizedfields'
|
||||||
POSTGRES_USER: 'localizedfields'
|
POSTGRES_USER: 'localizedfields'
|
||||||
@ -11,26 +11,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
name: Install packages
|
name: Install packages
|
||||||
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
|
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install Python packages
|
name: Install Python packages
|
||||||
command: pip install --progress-bar off .[test]
|
command: pip install --progress-bar off .[test]
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
command: tox -e 'py36-dj{20,21,22,30,31}'
|
command: tox -e 'py36-dj{20,21,22,30,31,32}'
|
||||||
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-python37:
|
test-python37:
|
||||||
docker:
|
docker:
|
||||||
- image: python:3.7-alpine
|
- image: python:3.7-alpine
|
||||||
- image: postgres:11.0
|
- image: postgres:12.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: 'localizedfields'
|
POSTGRES_DB: 'localizedfields'
|
||||||
POSTGRES_USER: 'localizedfields'
|
POSTGRES_USER: 'localizedfields'
|
||||||
@ -38,26 +37,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
name: Install packages
|
name: Install packages
|
||||||
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
|
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install Python packages
|
name: Install Python packages
|
||||||
command: pip install --progress-bar off .[test]
|
command: pip install --progress-bar off .[test]
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
command: tox -e 'py37-dj{20,21,22,30,31}'
|
command: tox -e 'py37-dj{20,21,22,30,31,32}'
|
||||||
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-python38:
|
test-python38:
|
||||||
docker:
|
docker:
|
||||||
- image: python:3.8-alpine
|
- image: python:3.8-alpine
|
||||||
- image: postgres:11.0
|
- image: postgres:12.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: 'localizedfields'
|
POSTGRES_DB: 'localizedfields'
|
||||||
POSTGRES_USER: 'localizedfields'
|
POSTGRES_USER: 'localizedfields'
|
||||||
@ -74,7 +72,7 @@ jobs:
|
|||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
command: tox -e 'py38-dj{20,21,22,30,31}'
|
command: tox -e 'py38-dj{20,21,22,30,31,32,40,41,42}'
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
|
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
|
||||||
|
|
||||||
@ -84,7 +82,7 @@ jobs:
|
|||||||
test-python39:
|
test-python39:
|
||||||
docker:
|
docker:
|
||||||
- image: python:3.9-alpine
|
- image: python:3.9-alpine
|
||||||
- image: postgres:11.0
|
- image: postgres:12.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: 'localizedfields'
|
POSTGRES_DB: 'localizedfields'
|
||||||
POSTGRES_USER: 'localizedfields'
|
POSTGRES_USER: 'localizedfields'
|
||||||
@ -101,16 +99,69 @@ jobs:
|
|||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
command: tox -e 'py39-dj{21,22,30,31}'
|
command: tox -e 'py39-dj{30,31,32,40,41,42}'
|
||||||
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.7-alpine
|
- image: python:3.8-alpine
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
- run:
|
||||||
@ -134,4 +185,6 @@ workflows:
|
|||||||
- test-python37
|
- test-python37
|
||||||
- test-python38
|
- test-python38
|
||||||
- test-python39
|
- test-python39
|
||||||
|
- test-python310
|
||||||
|
- test-python311
|
||||||
- analysis
|
- analysis
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,6 +15,7 @@ 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
|
||||||
|
20
README.md
20
README.md
@ -1,13 +1,13 @@
|
|||||||
| | | |
|
| | | |
|
||||||
|--------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| :white_check_mark: | **Tests** | [](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master) |
|
| :white_check_mark: | **Tests** | [](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master) |
|
||||||
| :memo: | **License** | [](http://doge.mit-license.org) |
|
| :memo: | **License** | [](http://doge.mit-license.org) |
|
||||||
| :package: | **PyPi** | [](https://pypi.python.org/pypi/django-localized-fields) |
|
| :package: | **PyPi** | [](https://pypi.python.org/pypi/django-localized-fields) |
|
||||||
| <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="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="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 |
|
| <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 |
|
||||||
| :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 10 or newer.
|
* PostgreSQL 12 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 psqlextra --pwprompt
|
λ createuser --superuser localized_fields --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
|
||||||
|
@ -1 +1,4 @@
|
|||||||
default_app_config = "localized_fields.apps.LocalizedFieldsConfig"
|
import django
|
||||||
|
|
||||||
|
if django.VERSION < (3, 2):
|
||||||
|
default_app_config = "localized_fields.apps.LocalizedFieldsConfig"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from . import widgets
|
from . import widgets
|
||||||
from .fields import (
|
from .fields import (
|
||||||
|
LocalizedBooleanField,
|
||||||
LocalizedCharField,
|
LocalizedCharField,
|
||||||
LocalizedField,
|
LocalizedField,
|
||||||
LocalizedFileField,
|
LocalizedFileField,
|
||||||
@ -11,6 +12,7 @@ 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},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@ -16,6 +17,7 @@ __all__ = [
|
|||||||
"LocalizedFileField",
|
"LocalizedFileField",
|
||||||
"LocalizedIntegerField",
|
"LocalizedIntegerField",
|
||||||
"LocalizedFloatField",
|
"LocalizedFloatField",
|
||||||
|
"LocalizedBooleanField",
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import html
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from .field import LocalizedField
|
from .field import LocalizedField
|
||||||
@ -7,6 +9,23 @@ 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.
|
||||||
|
|
||||||
@ -42,8 +61,14 @@ 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, bleach.clean(value, **get_bleach_default_options())
|
lang_code,
|
||||||
|
cleaned_value if self.escape else html.unescape(cleaned_value),
|
||||||
)
|
)
|
||||||
|
|
||||||
return localized_value
|
return localized_value
|
||||||
|
110
localized_fields/fields/boolean_field.py
Normal file
110
localized_fields/fields/boolean_field.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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)
|
@ -28,18 +28,26 @@ class LocalizedField(HStoreField):
|
|||||||
descriptor_class = LocalizedValueDescriptor
|
descriptor_class = LocalizedValueDescriptor
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, *args, required: Union[bool, List[str]] = None, **kwargs
|
self,
|
||||||
|
*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."""
|
||||||
|
|
||||||
super(LocalizedField, self).__init__(*args, required=required, **kwargs)
|
if (required is None and blank) or required is False:
|
||||||
|
|
||||||
if (self.required is None and self.blank) or self.required is False:
|
|
||||||
self.required = []
|
self.required = []
|
||||||
elif self.required is None and not self.blank:
|
elif required is None and not blank:
|
||||||
self.required = [settings.LANGUAGE_CODE]
|
self.required = [settings.LANGUAGE_CODE]
|
||||||
elif self.required is True:
|
elif required is True:
|
||||||
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
|
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
|
||||||
|
else:
|
||||||
|
self.required = required
|
||||||
|
|
||||||
|
super(LocalizedField, self).__init__(
|
||||||
|
*args, required=self.required, blank=blank, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
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.
|
||||||
|
@ -21,6 +21,10 @@ 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):
|
||||||
@ -28,6 +32,9 @@ 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")
|
||||||
@ -42,6 +49,13 @@ 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):
|
||||||
@ -59,6 +73,9 @@ 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(
|
||||||
(
|
(
|
||||||
@ -76,10 +93,14 @@ 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]
|
||||||
|
@ -6,12 +6,14 @@ 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,
|
||||||
@ -102,6 +104,15 @@ 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."""
|
||||||
|
@ -23,6 +23,7 @@ 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
|
||||||
|
|
||||||
@ -37,12 +38,35 @@ except ImportError:
|
|||||||
|
|
||||||
class LocalizedLookupMixin:
|
class LocalizedLookupMixin:
|
||||||
def process_lhs(self, qn, connection):
|
def process_lhs(self, qn, connection):
|
||||||
if isinstance(self.lhs, Col):
|
# If the LHS is already a reference to a specific hstore key, there
|
||||||
language = translation.get_language() or settings.LANGUAGE_CODE
|
# is nothing to be done since it already references as specific language.
|
||||||
self.lhs = KeyTransform(language, self.lhs)
|
if isinstance(self.lhs, HStoreColumn) or isinstance(
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,10 +34,11 @@
|
|||||||
|
|
||||||
.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-block;
|
display: inline;
|
||||||
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,
|
||||||
|
@ -136,6 +136,15 @@ 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."""
|
||||||
@ -228,6 +237,35 @@ 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."""
|
||||||
|
@ -120,6 +120,18 @@ 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
|
||||||
|
|
||||||
|
@ -3,3 +3,5 @@ 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
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import django
|
||||||
import dj_database_url
|
import dj_database_url
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
@ -8,7 +9,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:///psqlextra'),
|
'default': dj_database_url.config(default='postgres:///localized_fields'),
|
||||||
}
|
}
|
||||||
|
|
||||||
DATABASES['default']['ENGINE'] = 'psqlextra.backend'
|
DATABASES['default']['ENGINE'] = 'psqlextra.backend'
|
||||||
@ -51,6 +52,12 @@ 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
|
||||||
|
@ -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
|
known_third_party=django_bleach,bleach,pytest
|
||||||
|
26
setup.py
26
setup.py
@ -36,7 +36,7 @@ with open(
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="django-localized-fields",
|
name="django-localized-fields",
|
||||||
version="6.1",
|
version="6.8b5",
|
||||||
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,6 +64,11 @@ 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",
|
||||||
],
|
],
|
||||||
@ -71,28 +76,29 @@ 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.14.3",
|
"tox==3.28.0",
|
||||||
"pytest==5.3.2",
|
"pytest==7.0.1",
|
||||||
"pytest-django==3.7.0",
|
"pytest-django==4.5.2",
|
||||||
"pytest-cov==2.8.1",
|
"pytest-cov==2.12.1",
|
||||||
"dj-database-url==0.5.0",
|
"dj-database-url==0.5.0",
|
||||||
"django-autoslug==1.9.6",
|
"django-autoslug==1.9.9",
|
||||||
"django-bleach==0.6.1",
|
"django-bleach==0.9.0",
|
||||||
"psycopg2==2.8.4",
|
"psycopg2==2.9.8",
|
||||||
],
|
],
|
||||||
"analysis": [
|
"analysis": [
|
||||||
"black==19.3b0",
|
"black==22.3.0",
|
||||||
"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={
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""isort:skip_file."""
|
"""isort:skip_file."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import html
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -66,14 +67,24 @@ 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):
|
def _get_test_model(value, escape=True):
|
||||||
"""Gets a test model and a artifically constructed
|
"""Gets a test model and an artificially constructed
|
||||||
:see:LocalizedBleachField instance to test with."""
|
:see:LocalizedBleachField instance to test with."""
|
||||||
|
|
||||||
model = ModelTest(value)
|
model = ModelTest(value)
|
||||||
|
|
||||||
field = LocalizedBleachField()
|
field = LocalizedBleachField(escape=escape)
|
||||||
field.attname = "value"
|
field.attname = "value"
|
||||||
return model, field
|
return model, field
|
||||||
|
|
||||||
@ -89,7 +100,7 @@ class LocalizedBleachFieldTestCase(TestCase):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate(non_bleached_value, bleached_value):
|
def _validate(non_bleached_value, bleached_value, escaped_value=True):
|
||||||
"""Validates whether the specified non-bleached value ended up being
|
"""Validates whether the specified non-bleached value ended up being
|
||||||
correctly bleached.
|
correctly bleached.
|
||||||
|
|
||||||
@ -100,14 +111,20 @@ 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
|
||||||
|
|
||||||
expected_value = bleach.clean(
|
cleaned_value = bleach.clean(
|
||||||
non_bleached_value.get(lang_code), get_bleach_default_options()
|
non_bleached_value.get(lang_code)
|
||||||
|
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
|
||||||
|
211
tests/test_boolean_field.py
Normal file
211
tests/test_boolean_field.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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"]
|
@ -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
|
"""Tests whether the :see:get_prep_value function produces the expected
|
||||||
expected dictionary."""
|
dictionary."""
|
||||||
|
|
||||||
input_data = get_init_values()
|
input_data = get_init_values()
|
||||||
localized_value = LocalizedValue(input_data)
|
localized_value = LocalizedValue(input_data)
|
||||||
|
51
tests/test_isnull.py
Normal file
51
tests/test_isnull.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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()
|
@ -3,6 +3,7 @@ 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
|
||||||
|
|
||||||
@ -49,6 +50,18 @@ 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."""
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
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
|
||||||
@ -215,6 +217,53 @@ 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."""
|
||||||
@ -248,10 +297,13 @@ 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(populate_from="title")
|
field = LocalizedUniqueSlugField(
|
||||||
|
enabled=False, immutable=True, populate_from="title"
|
||||||
|
)
|
||||||
_, _, _, kwargs = field.deconstruct()
|
_, _, _, kwargs = field.deconstruct()
|
||||||
|
|
||||||
assert "populate_from" in kwargs
|
assert not kwargs["enabled"]
|
||||||
|
assert kwargs["immutable"]
|
||||||
assert kwargs["populate_from"] == field.populate_from
|
assert kwargs["populate_from"] == field.populate_from
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -38,6 +38,17 @@ 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
|
||||||
|
7
tox.ini
7
tox.ini
@ -1,5 +1,5 @@
|
|||||||
[tox]
|
[tox]
|
||||||
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}
|
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}
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
@ -8,6 +8,11 @@ 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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user