From e72f0ae2f33b041ab50f83a4d06abed68bceb9e2 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sun, 15 Mar 2026 15:31:02 +0100 Subject: [PATCH] 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. --- hypha/apply/funds/blocks.py | 74 +++++++++++++++++- hypha/apply/funds/tests/test_blocks.py | 101 +++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 hypha/apply/funds/tests/test_blocks.py diff --git a/hypha/apply/funds/blocks.py b/hypha/apply/funds/blocks.py index 4416796e91..01926cf492 100644 --- a/hypha/apply/funds/blocks.py +++ b/hypha/apply/funds/blocks.py @@ -1,6 +1,7 @@ import json from django import forms +from django.utils.formats import get_format from django.utils.translation import gettext_lazy as _ from wagtail import blocks @@ -17,6 +18,66 @@ from hypha.apply.utils.templatetags.apply_tags import format_number_as_currency +class LocalizedFloatField(forms.FloatField): + """ + FloatField that accepts locale-formatted numbers without relying on the + active locale. Assumes at most two decimal places (no cents sub-divisions), + which makes the separator role unambiguous: + + - Both . and , present: whichever comes last is the decimal separator. + - Single separator with exactly 3 digits after it: thousands separator. + - Single separator with 1 or 2 digits after it: decimal separator. + - Multiple identical separators: all are thousands separators. + """ + + def to_python(self, value): + if value not in self.empty_values: + value = str(value).strip() + has_dot = "." in value + has_comma = "," in value + + if has_dot and has_comma: + # Both present — whichever appears last is the decimal. + if value.rfind(".") > value.rfind(","): + value = value.replace(",", "") # e.g. "1,000.50" (comma-thousands) + else: + value = value.replace(".", "").replace( + ",", "." + ) # e.g. "1.000,50" (dot-thousands) + elif has_comma: + parts = value.split(",") + if len(parts) > 2 or len(parts[1]) == 3: + value = value.replace( + ",", "" + ) # e.g. "10,000" or "1,000,000" (comma-thousands) + else: + value = value.replace( + ",", "." + ) # e.g. "10000,00" or "1,5" (comma-decimal) + elif has_dot: + parts = value.split(".") + if len(parts) > 2 or len(parts[1]) == 3: + value = value.replace( + ".", "" + ) # e.g. "10.000" or "1.000.000" (dot-thousands) + # else: already a valid decimal, e.g. "10.5" or "10.00" (dot-decimal) + + result = super().to_python(value) + if result is not None and result == int(result): + return int(result) + return result + + def prepare_value(self, value): + # Format a stored numeric value using the active locale's decimal + # separator so the widget displays e.g. "10000,5" in comma-decimal + # locales rather than the Python default "10000.5". String values + # (mid-form re-display after a validation error) are returned unchanged. + if isinstance(value, float): + decimal_sep = get_format("DECIMAL_SEPARATOR") + return str(value).replace(".", decimal_sep) + return value + + class ApplicationSingleIncludeFieldBlock(SingleIncludeBlock): pass @@ -51,13 +112,22 @@ def get_field_kwargs(self, struct_value): class ValueBlock(ApplicationSingleIncludeFieldBlock): name = "value" description = "The value of the project" - widget = forms.NumberInput(attrs={"min": 0}) - field_class = forms.FloatField + # TextInput + inputmode="decimal" lets us handle locale-specific separators + # server-side. formats/validates using the *browser* + # locale which differs from Django's active locale and behaves inconsistently + # across browsers, causing e.g. German "10.000" to be stored as 10. + widget = forms.TextInput(attrs={"inputmode": "decimal"}) + field_class = LocalizedFloatField class Meta: label = _("Requested amount") icon = "decimal" + def get_field_kwargs(self, struct_value): + kwargs = super().get_field_kwargs(struct_value) + kwargs["min_value"] = 0 + return kwargs + def prepare_data(self, value, data, serialize): if not data: return data diff --git a/hypha/apply/funds/tests/test_blocks.py b/hypha/apply/funds/tests/test_blocks.py new file mode 100644 index 0000000000..39182cfef2 --- /dev/null +++ b/hypha/apply/funds/tests/test_blocks.py @@ -0,0 +1,101 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import translation + +from hypha.apply.funds.blocks import LocalizedFloatField + + +class TestLocalizedFloatField(TestCase): + def setUp(self): + self.field = LocalizedFloatField() + + # --- Both separators present: last one is the decimal --- + + def test_dot_thousands_comma_decimal(self): + """1.000,50 → 1000.5 (dot-thousands, comma-decimal)""" + self.assertAlmostEqual(self.field.clean("1.000,50"), 1000.5) + + def test_comma_thousands_dot_decimal(self): + """1,000.50 → 1000.5 (comma-thousands, dot-decimal)""" + self.assertAlmostEqual(self.field.clean("1,000.50"), 1000.5) + + # --- Multiple identical separators: all thousands --- + + def test_dot_thousands_millions(self): + """1.000.000 → 1000000 (dot-thousands)""" + self.assertAlmostEqual(self.field.clean("1.000.000"), 1_000_000) + + def test_comma_thousands_millions(self): + """1,000,000 → 1000000 (comma-thousands)""" + self.assertAlmostEqual(self.field.clean("1,000,000"), 1_000_000) + + # --- Single separator: 3 digits after = thousands, 1-2 digits = decimal --- + + def test_dot_thousands(self): + """10.000 → 10000 (dot-thousands)""" + self.assertAlmostEqual(self.field.clean("10.000"), 10_000) + + def test_comma_thousands(self): + """10,000 → 10000 (comma-thousands)""" + self.assertAlmostEqual(self.field.clean("10,000"), 10_000) + + def test_dot_decimal(self): + """10.50 → 10.5 (dot-decimal)""" + self.assertAlmostEqual(self.field.clean("10.50"), 10.5) + + def test_comma_decimal(self): + """10,50 → 10.5 (comma-decimal)""" + self.assertAlmostEqual(self.field.clean("10,50"), 10.5) + + def test_comma_decimal_one_digit(self): + """1,5 → 1.5 (comma-decimal)""" + self.assertAlmostEqual(self.field.clean("1,5"), 1.5) + + def test_large_amount_comma_decimal(self): + """10000,00 → 10000.0 (comma-decimal, no thousands separator)""" + self.assertAlmostEqual(self.field.clean("10000,00"), 10_000.0) + + # --- Plain numbers and integer/float return type --- + + def test_plain_integer(self): + self.assertEqual(self.field.clean("10000"), 10_000) + self.assertIsInstance(self.field.clean("10000"), int) + + def test_whole_number_returns_int(self): + """10000.00 and 10000,00 should be stored as 10000, not 10000.0""" + self.assertIsInstance(self.field.clean("10000.00"), int) + self.assertIsInstance(self.field.clean("10000,00"), int) + + def test_fractional_number_returns_float(self): + """10000.50 should be stored as 10000.5, not 10000""" + result = self.field.clean("10000.50") + self.assertIsInstance(result, float) + self.assertAlmostEqual(result, 10000.5) + + def test_zero(self): + self.assertEqual(self.field.clean("0"), 0) + self.assertIsInstance(self.field.clean("0"), int) + + # --- prepare_value: display stored value in locale format --- + + def test_prepare_value_uses_locale_decimal_separator(self): + """Stored float 10000.5 should display as "10000,5" in a comma-decimal locale.""" + with translation.override("sv"): + self.assertEqual(self.field.prepare_value(10000.5), "10000,5") + + def test_prepare_value_integer_unchanged(self): + """Stored int 10000 should display as 10000 (no decimal separator added).""" + with translation.override("sv"): + self.assertEqual(self.field.prepare_value(10000), 10000) + + def test_prepare_value_string_unchanged(self): + """A string value (re-display after validation error) is not reformatted.""" + with translation.override("sv"): + self.assertEqual(self.field.prepare_value("10000,5"), "10000,5") + + # --- Validation --- + + def test_negative_rejected_when_min_value_set(self): + field = LocalizedFloatField(min_value=0) + with self.assertRaises(ValidationError): + field.clean("-1")