Skip to content

Commit e72f0ae

Browse files
committed
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 seperator.
1 parent d5734b7 commit e72f0ae

2 files changed

Lines changed: 173 additions & 2 deletions

File tree

hypha/apply/funds/blocks.py

Lines changed: 72 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,66 @@
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+
value = str(value).strip()
36+
has_dot = "." in value
37+
has_comma = "," in value
38+
39+
if has_dot and has_comma:
40+
# Both present — whichever appears last is the decimal.
41+
if value.rfind(".") > value.rfind(","):
42+
value = value.replace(",", "") # e.g. "1,000.50" (comma-thousands)
43+
else:
44+
value = value.replace(".", "").replace(
45+
",", "."
46+
) # e.g. "1.000,50" (dot-thousands)
47+
elif has_comma:
48+
parts = value.split(",")
49+
if len(parts) > 2 or len(parts[1]) == 3:
50+
value = value.replace(
51+
",", ""
52+
) # e.g. "10,000" or "1,000,000" (comma-thousands)
53+
else:
54+
value = value.replace(
55+
",", "."
56+
) # e.g. "10000,00" or "1,5" (comma-decimal)
57+
elif has_dot:
58+
parts = value.split(".")
59+
if len(parts) > 2 or len(parts[1]) == 3:
60+
value = value.replace(
61+
".", ""
62+
) # e.g. "10.000" or "1.000.000" (dot-thousands)
63+
# else: already a valid decimal, e.g. "10.5" or "10.00" (dot-decimal)
64+
65+
result = super().to_python(value)
66+
if result is not None and result == int(result):
67+
return int(result)
68+
return result
69+
70+
def prepare_value(self, value):
71+
# Format a stored numeric value using the active locale's decimal
72+
# separator so the widget displays e.g. "10000,5" in comma-decimal
73+
# locales rather than the Python default "10000.5". String values
74+
# (mid-form re-display after a validation error) are returned unchanged.
75+
if isinstance(value, float):
76+
decimal_sep = get_format("DECIMAL_SEPARATOR")
77+
return str(value).replace(".", decimal_sep)
78+
return value
79+
80+
2081
class ApplicationSingleIncludeFieldBlock(SingleIncludeBlock):
2182
pass
2283

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

57122
class Meta:
58123
label = _("Requested amount")
59124
icon = "decimal"
60125

126+
def get_field_kwargs(self, struct_value):
127+
kwargs = super().get_field_kwargs(struct_value)
128+
kwargs["min_value"] = 0
129+
return kwargs
130+
61131
def prepare_data(self, value, data, serialize):
62132
if not data:
63133
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)