Skip to content

Commit e4bd26e

Browse files
authored
Add support for storing Float values (#80)
* Add LocalizedFloatValue * Add LocalizedFloatField * Add tests for float field * Create LocalizedNumericValue with __int__ and __float__ methods
1 parent a198440 commit e4bd26e

File tree

4 files changed

+307
-12
lines changed

4 files changed

+307
-12
lines changed

localized_fields/fields/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .text_field import LocalizedTextField
66
from .file_field import LocalizedFileField
77
from .integer_field import LocalizedIntegerField
8+
from .float_field import LocalizedFloatField
89

910

1011
__all__ = [
@@ -14,7 +15,8 @@
1415
'LocalizedCharField',
1516
'LocalizedTextField',
1617
'LocalizedFileField',
17-
'LocalizedIntegerField'
18+
'LocalizedIntegerField',
19+
'LocalizedFloatField'
1820
]
1921

2022
try:
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from typing import Optional, Union, Dict
2+
3+
from django.conf import settings
4+
from django.db.utils import IntegrityError
5+
6+
from .field import LocalizedField
7+
from ..value import LocalizedValue, LocalizedFloatValue
8+
from ..forms import LocalizedIntegerFieldForm
9+
10+
11+
class LocalizedFloatField(LocalizedField):
12+
"""Stores float as a localized value."""
13+
14+
attr_class = LocalizedFloatValue
15+
16+
@classmethod
17+
def from_db_value(cls, value, *_) -> Optional[LocalizedFloatValue]:
18+
db_value = super().from_db_value(value)
19+
if db_value is None:
20+
return db_value
21+
22+
# if we were used in an expression somehow then it might be
23+
# that we're returning an individual value or an array, so
24+
# we should not convert that into an :see:LocalizedFloatValue
25+
if not isinstance(db_value, LocalizedValue):
26+
return db_value
27+
28+
return cls._convert_localized_value(db_value)
29+
30+
def to_python(self, value: Union[Dict[str, int], int, None]) -> LocalizedFloatValue:
31+
"""Converts the value from a database value into a Python value."""
32+
33+
db_value = super().to_python(value)
34+
return self._convert_localized_value(db_value)
35+
36+
def get_prep_value(self, value: LocalizedFloatValue) -> dict:
37+
"""Gets the value in a format to store into the database."""
38+
39+
# apply default values
40+
default_values = LocalizedFloatValue(self.default)
41+
if isinstance(value, LocalizedFloatValue):
42+
for lang_code, _ in settings.LANGUAGES:
43+
local_value = value.get(lang_code)
44+
if local_value is None:
45+
value.set(lang_code, default_values.get(lang_code, None))
46+
47+
prepped_value = super().get_prep_value(value)
48+
if prepped_value is None:
49+
return None
50+
51+
# make sure all values are proper floats
52+
for lang_code, _ in settings.LANGUAGES:
53+
local_value = prepped_value[lang_code]
54+
try:
55+
if local_value is not None:
56+
float(local_value)
57+
except (TypeError, ValueError):
58+
raise IntegrityError('non-float value in column "%s.%s" violates '
59+
'float constraint' % (self.name, lang_code))
60+
61+
# convert to a string before saving because the underlying
62+
# type is hstore, which only accept strings
63+
prepped_value[lang_code] = str(local_value) if local_value is not None else None
64+
65+
return prepped_value
66+
67+
def formfield(self, **kwargs):
68+
"""Gets the form field associated with this field."""
69+
defaults = {
70+
'form_class': LocalizedIntegerFieldForm
71+
}
72+
73+
defaults.update(kwargs)
74+
return super().formfield(**defaults)
75+
76+
@staticmethod
77+
def _convert_localized_value(value: LocalizedValue) -> LocalizedFloatValue:
78+
"""Converts from :see:LocalizedValue to :see:LocalizedFloatValue."""
79+
80+
float_values = {}
81+
for lang_code, _ in settings.LANGUAGES:
82+
local_value = value.get(lang_code, None)
83+
if local_value is None or local_value.strip() == '':
84+
local_value = None
85+
86+
try:
87+
float_values[lang_code] = float(local_value)
88+
except (ValueError, TypeError):
89+
float_values[lang_code] = None
90+
91+
return LocalizedFloatValue(float_values)

localized_fields/value.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,31 @@ def localized(self):
206206
return self.get(translation.get_language())
207207

208208

209-
class LocalizedIntegerValue(LocalizedValue):
209+
class LocalizedNumericValue(LocalizedValue):
210+
def __int__(self):
211+
"""Gets the value in the current language as an integer."""
212+
value = self.translate()
213+
if value is None:
214+
return self.default_value
215+
216+
return int(value)
217+
218+
def __str__(self) -> str:
219+
"""Returns string representation of value"""
220+
221+
value = self.translate()
222+
return str(value) if value is not None else ''
223+
224+
def __float__(self):
225+
"""Gets the value in the current language as a float"""
226+
value = self.translate()
227+
if value is None:
228+
return self.default_value
229+
230+
return float(value)
231+
232+
233+
class LocalizedIntegerValue(LocalizedNumericValue):
210234
"""All values are integers."""
211235

212236
default_value = None
@@ -221,17 +245,19 @@ def translate(self):
221245

222246
return int(value)
223247

224-
def __int__(self):
225-
"""Gets the value in the current language as an integer."""
226248

227-
value = self.translate()
228-
if value is None:
229-
return self.default_value
249+
class LocalizedFloatValue(LocalizedNumericValue):
250+
"""All values are floats"""
230251

231-
return int(value)
252+
default_value = None
232253

233-
def __str__(self) -> str:
234-
"""Returns string representation of value"""
254+
def translate(self):
255+
"""
256+
Gets the value in the current language, or in the configured
257+
fallback language.
258+
"""
259+
value = super().translate()
260+
if value is None or (isinstance(value, str) and value.strip() == ''):
261+
return None
235262

236-
value = self.translate()
237-
return str(value) if value is not None else ''
263+
return float(value)

tests/test_float_field.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from django.test import TestCase
2+
from django.db.utils import IntegrityError
3+
from django.conf import settings
4+
from django.db import connection
5+
from django.utils import translation
6+
7+
from localized_fields.fields import LocalizedFloatField
8+
9+
from .fake_model import get_fake_model
10+
11+
12+
class LocalizedFloatFieldTestCase(TestCase):
13+
"""Tests whether the :see:LocalizedFloatField
14+
and :see:LocalizedFloatValue works properly."""
15+
16+
TestModel = None
17+
18+
@classmethod
19+
def setUpClass(cls):
20+
super().setUpClass()
21+
22+
cls.TestModel = get_fake_model({
23+
'score': LocalizedFloatField()
24+
})
25+
26+
def test_basic(self):
27+
"""Tests the basics of storing float values."""
28+
29+
obj = self.TestModel()
30+
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
31+
obj.score.set(lang_code, index + 1.0)
32+
obj.save()
33+
34+
obj = self.TestModel.objects.all().first()
35+
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
36+
assert obj.score.get(lang_code) == index + 1.0
37+
38+
def test_primary_language_required(self):
39+
"""Tests whether the primary language is required by
40+
default and all other languages are optiona."""
41+
42+
# not filling in anything should raise IntegrityError,
43+
# the primary language is required
44+
with self.assertRaises(IntegrityError):
45+
obj = self.TestModel()
46+
obj.save()
47+
48+
# when filling all other languages besides the primary language
49+
# should still raise an error because the primary is always required
50+
with self.assertRaises(IntegrityError):
51+
obj = self.TestModel()
52+
for lang_code, _ in settings.LANGUAGES:
53+
if lang_code == settings.LANGUAGE_CODE:
54+
continue
55+
obj.score.set(lang_code, 23.0)
56+
obj.save()
57+
58+
def test_default_value_none(self):
59+
"""Tests whether the default value for optional languages
60+
is NoneType."""
61+
62+
obj = self.TestModel()
63+
obj.score.set(settings.LANGUAGE_CODE, 1234.0)
64+
obj.save()
65+
66+
for lang_code, _ in settings.LANGUAGES:
67+
if lang_code == settings.LANGUAGE_CODE:
68+
continue
69+
70+
assert obj.score.get(lang_code) is None
71+
72+
def test_translate(self):
73+
"""Tests whether casting the value to an float
74+
results in the value being returned in the currently
75+
active language as an float."""
76+
77+
obj = self.TestModel()
78+
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
79+
obj.score.set(lang_code, index + 1.0)
80+
obj.save()
81+
82+
obj.refresh_from_db()
83+
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
84+
with translation.override(lang_code):
85+
assert float(obj.score) == index + 1.0
86+
assert obj.score.translate() == index + 1.0
87+
88+
def test_translate_primary_fallback(self):
89+
"""Tests whether casting the value to an float
90+
results in the value begin returned in the active
91+
language and falls back to the primary language
92+
if there is no value in that language."""
93+
94+
obj = self.TestModel()
95+
obj.score.set(settings.LANGUAGE_CODE, 25.0)
96+
97+
secondary_language = settings.LANGUAGES[-1][0]
98+
assert obj.score.get(secondary_language) is None
99+
100+
with translation.override(secondary_language):
101+
assert obj.score.translate() == 25.0
102+
assert float(obj.score) == 25.0
103+
104+
def test_get_default_value(self):
105+
"""Tests whether getting the value in a specific
106+
language properly returns the specified default
107+
in case it is not available."""
108+
109+
obj = self.TestModel()
110+
obj.score.set(settings.LANGUAGE_CODE, 25.0)
111+
112+
secondary_language = settings.LANGUAGES[-1][0]
113+
assert obj.score.get(secondary_language) is None
114+
assert obj.score.get(secondary_language, 1337.0) == 1337.0
115+
116+
def test_completely_optional(self):
117+
"""Tests whether having all languages optional
118+
works properly."""
119+
120+
model = get_fake_model({
121+
'score': LocalizedFloatField(null=True, required=[], blank=True)
122+
})
123+
124+
obj = model()
125+
obj.save()
126+
127+
for lang_code, _ in settings.LANGUAGES:
128+
assert getattr(obj.score, lang_code) is None
129+
130+
def test_store_string(self):
131+
"""Tests whether the field properly raises
132+
an error when trying to store a non-float."""
133+
134+
for lang_code, _ in settings.LANGUAGES:
135+
obj = self.TestModel()
136+
with self.assertRaises(IntegrityError):
137+
obj.score.set(lang_code, 'haha')
138+
obj.save()
139+
140+
def test_none_if_illegal_value_stored(self):
141+
"""Tests whether None is returned for a language
142+
if the value stored in the database is not an
143+
float."""
144+
145+
obj = self.TestModel()
146+
obj.score.set(settings.LANGUAGE_CODE, 25.0)
147+
obj.save()
148+
149+
with connection.cursor() as cursor:
150+
table_name = self.TestModel._meta.db_table
151+
cursor.execute("update %s set score = 'en=>haha'" % table_name)
152+
153+
obj.refresh_from_db()
154+
assert obj.score.get(settings.LANGUAGE_CODE) is None
155+
156+
def test_default_value(self):
157+
"""Tests whether a default is properly set
158+
when specified."""
159+
160+
model = get_fake_model({
161+
'score': LocalizedFloatField(default={settings.LANGUAGE_CODE: 75.0})
162+
})
163+
164+
obj = model.objects.create()
165+
assert obj.score.get(settings.LANGUAGE_CODE) == 75.0
166+
167+
obj = model()
168+
for lang_code, _ in settings.LANGUAGES:
169+
obj.score.set(lang_code, None)
170+
obj.save()
171+
172+
for lang_code, _ in settings.LANGUAGES:
173+
if lang_code == settings.LANGUAGE_CODE:
174+
assert obj.score.get(lang_code) == 75.0
175+
else:
176+
assert obj.score.get(lang_code) is None

0 commit comments

Comments
 (0)