Skip to content

Commit 56837a1

Browse files
authored
Make the Requested amount field accepts locale formated values (#4767)
Fixes #4628 Add custom `to_python()` function to allow localised number inputs to Requested amount field. Add custom `prepare_value()` to make the widget use the locale correct decimal separator. ## Test Steps - [ ] Try saving all number format you know about. 10000 can in different countries be written as "10 000", "10000.00", "10 000,00", "10.000,00", "10,000.00". They should all be saved as "10000" in the database. - [ ] Try to set the site to different locales and combine with different number format. All combinations should work.
1 parent da8b489 commit 56837a1

2 files changed

Lines changed: 174 additions & 2 deletions

File tree

hypha/apply/funds/blocks.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22

33
from django import forms
4+
from django.utils.formats import get_format
45
from django.utils.translation import gettext_lazy as _
56
from wagtail import blocks
67

@@ -17,6 +18,67 @@
1718
from hypha.apply.utils.templatetags.apply_tags import format_number_as_currency
1819

1920

21+
class LocalizedFloatField(forms.FloatField):
22+
"""
23+
FloatField that accepts locale-formatted numbers without relying on the
24+
active locale. Assumes at most two decimal places (no cents sub-divisions),
25+
which makes the separator role unambiguous:
26+
27+
- Both . and , present: whichever comes last is the decimal separator.
28+
- Single separator with exactly 3 digits after it: thousands separator.
29+
- Single separator with 1 or 2 digits after it: decimal separator.
30+
- Multiple identical separators: all are thousands separators.
31+
"""
32+
33+
def to_python(self, value):
34+
if value not in self.empty_values:
35+
# Remove all spaces.
36+
value = str(value).strip().replace(" ", "")
37+
has_dot = "." in value
38+
has_comma = "," in value
39+
40+
if has_dot and has_comma:
41+
# Both present — whichever appears last is the decimal.
42+
if value.rfind(".") > value.rfind(","):
43+
value = value.replace(",", "") # e.g. "1,000.50" (comma-thousands)
44+
else:
45+
value = value.replace(".", "").replace(
46+
",", "."
47+
) # e.g. "1.000,50" (dot-thousands)
48+
elif has_comma:
49+
parts = value.split(",")
50+
if len(parts) > 2 or len(parts[1]) == 3:
51+
value = value.replace(
52+
",", ""
53+
) # e.g. "10,000" or "1,000,000" (comma-thousands)
54+
else:
55+
value = value.replace(
56+
",", "."
57+
) # e.g. "10000,00" or "1,5" (comma-decimal)
58+
elif has_dot:
59+
parts = value.split(".")
60+
if len(parts) > 2 or len(parts[1]) == 3:
61+
value = value.replace(
62+
".", ""
63+
) # e.g. "10.000" or "1.000.000" (dot-thousands)
64+
# else: already a valid decimal, e.g. "10.5" or "10.00" (dot-decimal)
65+
66+
result = super().to_python(value)
67+
if result is not None and result == int(result):
68+
return int(result)
69+
return result
70+
71+
def prepare_value(self, value):
72+
# Format a stored numeric value using the active locale's decimal
73+
# separator so the widget displays e.g. "10000,5" in comma-decimal
74+
# locales rather than the Python default "10000.5". String values
75+
# (mid-form re-display after a validation error) are returned unchanged.
76+
if isinstance(value, float):
77+
decimal_sep = get_format("DECIMAL_SEPARATOR")
78+
return str(value).replace(".", decimal_sep)
79+
return value
80+
81+
2082
class ApplicationSingleIncludeFieldBlock(SingleIncludeBlock):
2183
pass
2284

@@ -51,13 +113,22 @@ def get_field_kwargs(self, struct_value):
51113
class ValueBlock(ApplicationSingleIncludeFieldBlock):
52114
name = "value"
53115
description = "The value of the project"
54-
widget = forms.NumberInput(attrs={"min": 0})
55-
field_class = forms.FloatField
116+
# TextInput + inputmode="decimal" lets us handle locale-specific separators
117+
# server-side. <input type="number"> formats/validates using the *browser*
118+
# locale which differs from Django's active locale and behaves inconsistently
119+
# across browsers, causing e.g. German "10.000" to be stored as 10.
120+
widget = forms.TextInput(attrs={"inputmode": "decimal"})
121+
field_class = LocalizedFloatField
56122

57123
class Meta:
58124
label = _("Requested amount")
59125
icon = "decimal"
60126

127+
def get_field_kwargs(self, struct_value):
128+
kwargs = super().get_field_kwargs(struct_value)
129+
kwargs["min_value"] = 0
130+
return kwargs
131+
61132
def prepare_data(self, value, data, serialize):
62133
if not data:
63134
return data
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from django.core.exceptions import ValidationError
2+
from django.test import TestCase
3+
from django.utils import translation
4+
5+
from hypha.apply.funds.blocks import LocalizedFloatField
6+
7+
8+
class TestLocalizedFloatField(TestCase):
9+
def setUp(self):
10+
self.field = LocalizedFloatField()
11+
12+
# --- Both separators present: last one is the decimal ---
13+
14+
def test_dot_thousands_comma_decimal(self):
15+
"""1.000,50 → 1000.5 (dot-thousands, comma-decimal)"""
16+
self.assertAlmostEqual(self.field.clean("1.000,50"), 1000.5)
17+
18+
def test_comma_thousands_dot_decimal(self):
19+
"""1,000.50 → 1000.5 (comma-thousands, dot-decimal)"""
20+
self.assertAlmostEqual(self.field.clean("1,000.50"), 1000.5)
21+
22+
# --- Multiple identical separators: all thousands ---
23+
24+
def test_dot_thousands_millions(self):
25+
"""1.000.000 → 1000000 (dot-thousands)"""
26+
self.assertAlmostEqual(self.field.clean("1.000.000"), 1_000_000)
27+
28+
def test_comma_thousands_millions(self):
29+
"""1,000,000 → 1000000 (comma-thousands)"""
30+
self.assertAlmostEqual(self.field.clean("1,000,000"), 1_000_000)
31+
32+
# --- Single separator: 3 digits after = thousands, 1-2 digits = decimal ---
33+
34+
def test_dot_thousands(self):
35+
"""10.000 → 10000 (dot-thousands)"""
36+
self.assertAlmostEqual(self.field.clean("10.000"), 10_000)
37+
38+
def test_comma_thousands(self):
39+
"""10,000 → 10000 (comma-thousands)"""
40+
self.assertAlmostEqual(self.field.clean("10,000"), 10_000)
41+
42+
def test_dot_decimal(self):
43+
"""10.50 → 10.5 (dot-decimal)"""
44+
self.assertAlmostEqual(self.field.clean("10.50"), 10.5)
45+
46+
def test_comma_decimal(self):
47+
"""10,50 → 10.5 (comma-decimal)"""
48+
self.assertAlmostEqual(self.field.clean("10,50"), 10.5)
49+
50+
def test_comma_decimal_one_digit(self):
51+
"""1,5 → 1.5 (comma-decimal)"""
52+
self.assertAlmostEqual(self.field.clean("1,5"), 1.5)
53+
54+
def test_large_amount_comma_decimal(self):
55+
"""10000,00 → 10000.0 (comma-decimal, no thousands separator)"""
56+
self.assertAlmostEqual(self.field.clean("10000,00"), 10_000.0)
57+
58+
# --- Plain numbers and integer/float return type ---
59+
60+
def test_plain_integer(self):
61+
self.assertEqual(self.field.clean("10000"), 10_000)
62+
self.assertIsInstance(self.field.clean("10000"), int)
63+
64+
def test_whole_number_returns_int(self):
65+
"""10000.00 and 10000,00 should be stored as 10000, not 10000.0"""
66+
self.assertIsInstance(self.field.clean("10000.00"), int)
67+
self.assertIsInstance(self.field.clean("10000,00"), int)
68+
69+
def test_fractional_number_returns_float(self):
70+
"""10000.50 should be stored as 10000.5, not 10000"""
71+
result = self.field.clean("10000.50")
72+
self.assertIsInstance(result, float)
73+
self.assertAlmostEqual(result, 10000.5)
74+
75+
def test_zero(self):
76+
self.assertEqual(self.field.clean("0"), 0)
77+
self.assertIsInstance(self.field.clean("0"), int)
78+
79+
# --- prepare_value: display stored value in locale format ---
80+
81+
def test_prepare_value_uses_locale_decimal_separator(self):
82+
"""Stored float 10000.5 should display as "10000,5" in a comma-decimal locale."""
83+
with translation.override("sv"):
84+
self.assertEqual(self.field.prepare_value(10000.5), "10000,5")
85+
86+
def test_prepare_value_integer_unchanged(self):
87+
"""Stored int 10000 should display as 10000 (no decimal separator added)."""
88+
with translation.override("sv"):
89+
self.assertEqual(self.field.prepare_value(10000), 10000)
90+
91+
def test_prepare_value_string_unchanged(self):
92+
"""A string value (re-display after validation error) is not reformatted."""
93+
with translation.override("sv"):
94+
self.assertEqual(self.field.prepare_value("10000,5"), "10000,5")
95+
96+
# --- Validation ---
97+
98+
def test_negative_rejected_when_min_value_set(self):
99+
field = LocalizedFloatField(min_value=0)
100+
with self.assertRaises(ValidationError):
101+
field.clean("-1")

0 commit comments

Comments
 (0)