Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions hypha/apply/funds/blocks.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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. <input type="number"> 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
Expand Down
101 changes: 101 additions & 0 deletions hypha/apply/funds/tests/test_blocks.py
Original file line number Diff line number Diff line change
@@ -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")
Loading