Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User friendly numbers for EYB Leads #5890

Merged
merged 7 commits into from
Jan 15, 2025
Merged
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
168 changes: 168 additions & 0 deletions datahub/core/test/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
from datahub.core.test.support.models import MetadataModel
from datahub.core.utils import (
force_uuid,
format_currency,
format_currency_range,
format_currency_range_string,
get_financial_year,
join_truthy_strings,
load_constants_to_database,
log_to_sentry,
reverse_with_query_string,
slice_iterable_into_chunks,
upper_snake_case_to_sentence_case,
)


Expand Down Expand Up @@ -59,6 +63,170 @@ def test_join_truthy_strings(args, sep, res):
assert join_truthy_strings(*args, sep=sep) == res


@pytest.mark.parametrize(
'string,glue,expected',
(
('UPPER_SNAKE_CASE', '+', 'Upper snake case'),
(['UPPER_SNAKE_CASE', 'LINE_2'], '+', 'Upper snake case+Line 2'),
(['UPPER_SNAKE_CASE', 'LINE_2'], '\n', 'Upper snake case\nLine 2'),
(['UPPER_SNAKE_CASE', 'LINE_2'], '. ', 'Upper snake case. Line 2'),
),
)
def test_upper_snake_case_to_sentence_case(string, glue, expected):
"""Test formatting currency"""
assert upper_snake_case_to_sentence_case(string, glue) == expected


@pytest.mark.parametrize(
'string,expected',
(
('UPPER_SNAKE_CASE', 'Upper snake case'),
(['UPPER_SNAKE_CASE', 'LINE_2'], 'Upper snake case Line 2'),
),
)
def test_default_glue_upper_snake_case_to_sentence_case(string, expected):
"""Test formatting currency"""
assert upper_snake_case_to_sentence_case(string) == expected


@pytest.mark.parametrize(
'value,expected',
(
(0, '£0'),
(1, '£1'),
(1.5, '£1.50'),
(999999, '£999,999'),
(1000000, '£1 million'),
(1234567, '£1.23 million'),
(7000000, '£7 million'),
(999990000, '£999.99 million'),
(999999999, '£1 billion'),
(1000000000, '£1 billion'),
(1200000000, '£1.2 billion'),
(1234567890, '£1.23 billion'),
(7000000000, '£7 billion'),
(123000000000, '£123 billion'),
(1234000000000, '£1,234 billion'),
(1234500000000, '£1,234.5 billion'),
),
)
def test_format_currency(value, expected):
"""Test formatting currency"""
assert format_currency(str(value)) == expected
assert format_currency(value) == expected

# Test without currency symbols
assert format_currency(str(value), symbol='') == expected.replace('£', '')
assert format_currency(value, symbol='') == expected.replace('£', '')

# Test with different currency symbols
assert format_currency(str(value), symbol='A$') == expected.replace('£', 'A$')
assert format_currency(value, symbol='A$') == expected.replace('£', 'A$')


@pytest.mark.parametrize(
'values,expected',
(
([0, 1.5], '£0 to £1.50'),
([999999, 1000000], '£999,999 to £1 million'),
([1234567, 7000000], '£1.23 million to £7 million'),
([999990000, 999999999], '£999.99 million to £1 billion'),
([1200000000, 0.01], '£1.2 billion to £0.01'),
),
)
def test_format_currency_range(values, expected):
assert format_currency_range(values) == expected
assert format_currency_range(values, symbol='') == expected.replace('£', '')
assert format_currency_range(values, symbol='A$') == expected.replace('£', 'A$')


@pytest.mark.parametrize(
'string,expected',
(
('0-9999', 'Less than £10,000'),
('0-10000', 'Less than £10,000'),
('0-1000000', 'Less than £1 million'),
('10000-500000', '£10,000 to £500,000'),
('500001-1000000', '£500,001 to £1 million'),
('1000001-2000000', '£1 million to £2 million'),
('2000001-5000000', '£2 million to £5 million'),
('5000001-10000000', '£5 million to £10 million'),
('10000001+', 'More than £10 million'),
('SPECIFIC_AMOUNT', 'Specific amount'),
),
)
def test_format_currency_range_string(string, expected):
"""
Test range with and without currency symbol.
"""
assert format_currency_range_string(string) == expected
assert format_currency_range_string(string, symbol='') == expected.replace('£', '')
assert format_currency_range_string(string, symbol='A$') == expected.replace('£', 'A$')


@pytest.mark.parametrize(
'string,expected',
(
('0...9999', 'Less than £10,000'),
('0...10000', 'Less than £10,000'),
('0...1000000', 'Less than £1 million'),
('10000...500000', '£10,000 to £500,000'),
('500001...1000000', '£500,001 to £1 million'),
('1000001...2000000', '£1 million to £2 million'),
('2000001...5000000', '£2 million to £5 million'),
('5000001...10000000', '£5 million to £10 million'),
('10000001+', 'More than £10 million'),
('SPECIFIC_AMOUNT', 'Specific amount'),
),
)
def test_format_currency_range_string_separator(string, expected):
"""
Test range with separator symbol.
"""
assert format_currency_range_string(string, separator='...') == expected


@pytest.mark.parametrize(
'string,more_or_less,smart_more_or_less,expected',
(
('0-9999', True, True, 'Less than £10,000'),
('0-10000', True, True, 'Less than £10,000'),
('0-1000000', True, True, 'Less than £1 million'),
('10000001+', True, True, 'More than £10 million'),
('SPECIFIC_AMOUNT', True, True, 'Specific amount'),
('0-9999', True, False, 'Less than £9,999'),
('0-10000', True, False, 'Less than £10,000'),
('0-1000000', True, False, 'Less than £1 million'),
('10000001+', True, False, 'More than £10 million'),
('SPECIFIC_AMOUNT', True, False, 'Specific amount'),
# smart_more_or_less is not used when more_or_less is False.
('0-9999', False, False, '£0 to £9,999'),
('0-10000', False, False, '£0 to £10,000'),
('0-1000000', False, False, '£0 to £1 million'),
('10000001+', False, False, '£10 million+'),
# Return string as Sentence case for invalid numbers
('SPECIFIC_AMOUNT', False, False, 'Specific amount'),
),
)
def test_format_currency_range_string_more_or_less_parameters(
string,
more_or_less,
smart_more_or_less,
expected,
):
"""
Test range with and without currency symbol.
"""
assert format_currency_range_string(
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less) == expected
assert format_currency_range_string(
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less, symbol='') == \
expected.replace('£', '')
assert format_currency_range_string(
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less, symbol='A$') == \
expected.replace('£', 'A$')


def test_slice_iterable_into_chunks():
"""Test slice iterable into chunks."""
size = 2
Expand Down
95 changes: 95 additions & 0 deletions datahub/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,101 @@ def join_truthy_strings(*args, sep=' '):
return sep.join(filter(None, args))


def upper_snake_case_to_sentence_case(strings, glue=' '):
"""
Formats string or strings from UPPER_SNAKE_CASE to Sentence case
"""
if isinstance(strings, str):
strings = [strings]
return glue.join(list(map(lambda string: string.replace('_', ' ').capitalize(), strings)))


def format_currency(value, symbol='£'):
"""
Formats currency according to Gov UK style guide
value: (str, int, float)

https://www.gov.uk/guidance/style-guide/a-to-z#money and others
"""
if isinstance(value, str):
try:
value = int(value)
except ValueError:
value = float(value)

# add million or billion multiplier
multiplier = ''
if value >= 1000000:
multiplier = ' million'
value = value / 1000000
# Check rounded value to avoid £1,000 million
if round(value, 2) >= 1000:
multiplier = ' billion'
value = round(value / 1000, 2)

# Only use decimals when pence are included (£75.50 not £75.00)
if (isinstance(value, float) and round(abs(value) % 1, 2) != 0.0):
# Don't use two decimals with multiplier if it would result in trailing 0.
if (multiplier != '' and round(abs(value * 10) % 1, 1) == 0.0):
formatter = ',.1f'
else:
formatter = ',.2f'
else:
formatter = ',.0f'
return f'{symbol}{value:{formatter}}{multiplier}'


def format_currency_range(values, separator=' to ', symbol='£'):
"""
Formats a range of ammounts according to Gov UK style guide
values: [(str, float, int), ...]
"""
return separator.join(list(map(lambda value: format_currency(value, symbol=symbol), values)))


def format_currency_range_string(
string,
separator='-',
more_or_less=True,
smart_more_or_less=True,
symbol='£',
):
"""
Formats a range of ammounts according to Gov UK style guide.
Note only numbers in specific formats are formatted, it doesn't detect number values within
a string of mixed numbers and text.
string: (string) the string containing the range to convert
separator: (string) separator to use.
more_or_less: (boolean) when true a range starting with 0 will be replace with Less than.
E.g. '0 - 1000' will return 'Less than 1000'
and a number with the sufix+ will be replaced with More than.
E.g. '100+' will return 'More than 100'
smart_more_or_less: (boolean) when true and more_or_less is set it will add one to any
upper range ending on a 9.
E.g. '0 - 9999' will return 'Less than 1000'
"""
try:
prefix = ''
postfix = ''
if more_or_less:
if string[-1] == '+':
prefix = 'More than '
string = string.rstrip('+')
values = string.split(separator)
if values[0] == '0':
if smart_more_or_less and values[1][-1] == '9':
values[1] = int(values[1]) + 1
return f'Less than {format_currency(values[1], symbol=symbol)}'
else:
if string[-1] == '+':
postfix = '+'
string = string.rstrip('+')
values = string.split(separator)
return f'{prefix}{format_currency_range(values, symbol=symbol)}{postfix}'
except ValueError:
return upper_snake_case_to_sentence_case(string, glue='\n')


def generate_enum_code_from_queryset(model_queryset):
"""Generate the Enum code for a given constant model queryset.

Expand Down
8 changes: 8 additions & 0 deletions datahub/investment_lead/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AddressSerializer,
NestedRelatedField,
)
from datahub.core.utils import format_currency_range_string
from datahub.investment.project.models import InvestmentProject
from datahub.investment_lead.models import EYBLead
from datahub.metadata.models import (
Expand Down Expand Up @@ -506,3 +507,10 @@ class Meta(BaseEYBLeadSerializer.Meta):
)
company = NestedRelatedField(Company)
investment_projects = NestedRelatedField(InvestmentProject, many=True)

def get_related_fields_representation(self, instance):
"""Provides related fields in a representation-friendly format."""
return {
'hiring': format_currency_range_string(instance.hiring, symbol=''),
'spend': format_currency_range_string(instance.spend),
}
5 changes: 3 additions & 2 deletions datahub/investment_lead/test/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from datahub.company.models.company import Company
from datahub.company.models.contact import Contact
from datahub.core.utils import format_currency_range_string
from datahub.investment_lead.models import EYBLead
from datahub.metadata.models import Sector

Expand Down Expand Up @@ -124,8 +125,8 @@ def assert_retrieved_eyb_lead_data(instance: EYBLead, data: dict):
assert str(instance.proposed_investment_region.id) == data['proposed_investment_region']['id']
assert instance.proposed_investment_city == data['proposed_investment_city']
assert instance.proposed_investment_location_none == data['proposed_investment_location_none']
assert instance.hiring == data['hiring']
assert instance.spend == data['spend']
assert format_currency_range_string(instance.hiring, symbol='') == data['hiring']
assert format_currency_range_string(instance.spend) == data['spend']
assert instance.spend_other == data['spend_other']
assert instance.is_high_value == data['is_high_value']

Expand Down
Loading