Refactor format_number

This commit is contained in:
Oliver Walters 2026-06-19 09:58:01 +00:00
parent 4ceb1b1423
commit 3a81e6182e
2 changed files with 81 additions and 46 deletions

View File

@ -783,6 +783,7 @@ def _get_report_locale(override: Optional[str] = None) -> Optional[str]:
"""
if override:
return override
return get_global_setting('REPORT_LOCALE', cache=True) or settings.LANGUAGE_CODE
@ -889,74 +890,90 @@ def render_html_text(text: str, **kwargs):
@register.simple_tag
def format_number(
number: int | float | Decimal,
decimal_places: Optional[int] = None,
multiplier: Optional[int | float | Decimal] = None,
integer: bool = False,
leading: int = 0,
separator: Optional[str] = None,
separator: bool = False,
min_digits: Optional[int] = None,
decimal_places: Optional[int] = None,
max_decimal_places: Optional[int] = None,
fmt: Optional[str] = None,
locale: Optional[str] = None,
) -> str:
"""Render a number with optional formatting options.
Arguments:
number: The number to be formatted
decimal_places: Number of decimal places to render
multiplier: Optional multiplier to apply to the number before formatting
integer: Boolean, whether to render the number as an integer
leading: Number of leading zeros (default = 0)
separator: Character to use as a thousands separator (default = None, ignored when locale is active)
separator: Boolean, whether to include a thousands separator
min_digits: Minimum number of digits to render (default = 1)
decimal_places: Number of decimal places to render (default = 0)
max_decimal_places: Maximum number of decimal places to render (default = 0)
separator:
fmt: Optional format string for the number - if provided, takes priority over 'decimal_places' and 'leading'
locale: Optional locale override (e.g. 'en-us', 'de-de'). When set, babel controls decimal and thousands separators.
"""
check_nulls('format_number', number)
# Check that the provided number is valid
try:
number = Decimal(str(number).strip())
except Exception:
# If the number cannot be converted to a Decimal, just return the original value
return str(number)
number = float(number)
if multiplier is not None:
number *= Decimal(str(multiplier).strip())
number *= multiplier
if integer:
number = Decimal(int(number))
number = int(number)
if decimal_places is not None:
try:
decimal_places = int(decimal_places)
number = round(number, decimal_places)
except ValueError:
decimal_places = None
# Construct a formatting string for the number, based on the provided options
if not fmt:
fmt = '###,###,###,###,##0' # Default format string - this will be modified based on the provided options
# The 'min_digits' option specifies the minimum number of digits to render (not including decimal places)
if min_digits is not None:
try:
min_digits = int(min_digits) or 0
except (ValueError, TypeError):
min_digits = 0
if min_digits > 1:
fmt = fmt[::-1].replace('#', '0', (min_digits - 1))[::-1]
if not bool(separator):
fmt = fmt.replace(',', '')
if decimal_places is not None or max_decimal_places is not None:
# Account for decimal places, if provided
try:
decimal_places = int(decimal_places) or 0
except (ValueError, TypeError):
decimal_places = 0
try:
max_decimal_places = int(max_decimal_places) or 0
except (ValueError, TypeError):
max_decimal_places = 0
fmt += '.' + '0' * decimal_places
if max_decimal_places > decimal_places:
fmt += '#' * (max_decimal_places - decimal_places)
elif not integer:
# No decimal places specified, allow any number of decimal places (up to the precision of the Decimal)
fmt += '.####################'
resolved_locale = _get_report_locale(locale)
babel_locale = get_locale(resolved_locale)
if resolved_locale:
babel_locale = get_locale(resolved_locale)
fmt = '#,##0.' + '0' * decimal_places if decimal_places is not None else None
value = babel_format_decimal(number, format=fmt, locale=babel_locale)
else:
# Normalize the number (remove trailing zeroes), avoid scientific notation
value = Decimal(number)
value = (
value.quantize(Decimal(1))
if value == value.to_integral()
else value.normalize()
)
if separator:
value = f'{value:,}'
value = value.replace(',', separator)
else:
value = f'{value}'
if leading is not None:
try:
leading = int(leading)
value = '0' * leading + value
except ValueError:
pass
return value
return babel_format_decimal(
number, format=fmt, locale=babel_locale, numbering_system='latn'
)
@register.simple_tag

View File

@ -359,23 +359,41 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
for x in ['10.000000', ' 10 ', 10.000000, 10]:
self.assertEqual(fn(x), '10')
# Test with various formatting options
self.assertEqual(fn(1234), '1234')
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
self.assertEqual(fn(1234.5678, decimal_places=0, separator=True), '1,235')
self.assertEqual(fn(1234.5678, decimal_places=1), '1234.6')
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
self.assertEqual(
fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
fn(1234.5678, decimal_places=2, max_decimal_places=10), '1234.5678'
)
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
self.assertEqual(fn(-9999.5678, decimal_places=2, locale='fr-fr'), '-9999,57')
self.assertEqual(
fn(-9999.5678, decimal_places=2, locale='fr-fr', separator=True),
'-9\u202f999,57',
)
self.assertEqual(
fn(9988776655.4321, integer=True, locale='de-de', separator=True),
'9.988.776.655',
)
# Test with 'min_digits' option
self.assertEqual(fn(5, min_digits=3), '005')
self.assertEqual(fn(123, min_digits=5), '00123')
self.assertEqual(fn(1234, min_digits=2, decimal_places=4), '1234.0000')
# Test with multiplier
self.assertEqual(fn(1000, multiplier=1.5), '1500')
# Failure cases
self.assertEqual(fn('abc'), 'abc')
self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')
self.assertEqual(fn(1234.456, leading='a'), '1234.456')
self.assertEqual(fn(1234.456, decimal_places='a'), '1234')
self.assertEqual(
fn(1234.456, decimal_places='a', separator=True, locale='en-au'), '1,234'
)
self.assertEqual(fn(1234.456, min_digits='a'), '1234.456')
@override_settings(TIME_ZONE='America/New_York')
def test_date_tags(self):