Skip to content

Commit 6bb9df5

Browse files
authored
User friendly numbers for EYB Leads (#5890)
* User friendly numbers and ranges for EYB Leads
1 parent 18244a7 commit 6bb9df5

File tree

4 files changed

+274
-2
lines changed

4 files changed

+274
-2
lines changed

datahub/core/test/test_utils.py

+168
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99
from datahub.core.test.support.models import MetadataModel
1010
from datahub.core.utils import (
1111
force_uuid,
12+
format_currency,
13+
format_currency_range,
14+
format_currency_range_string,
1215
get_financial_year,
1316
join_truthy_strings,
1417
load_constants_to_database,
1518
log_to_sentry,
1619
reverse_with_query_string,
1720
slice_iterable_into_chunks,
21+
upper_snake_case_to_sentence_case,
1822
)
1923

2024

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

6165

66+
@pytest.mark.parametrize(
67+
'string,glue,expected',
68+
(
69+
('UPPER_SNAKE_CASE', '+', 'Upper snake case'),
70+
(['UPPER_SNAKE_CASE', 'LINE_2'], '+', 'Upper snake case+Line 2'),
71+
(['UPPER_SNAKE_CASE', 'LINE_2'], '\n', 'Upper snake case\nLine 2'),
72+
(['UPPER_SNAKE_CASE', 'LINE_2'], '. ', 'Upper snake case. Line 2'),
73+
),
74+
)
75+
def test_upper_snake_case_to_sentence_case(string, glue, expected):
76+
"""Test formatting currency"""
77+
assert upper_snake_case_to_sentence_case(string, glue) == expected
78+
79+
80+
@pytest.mark.parametrize(
81+
'string,expected',
82+
(
83+
('UPPER_SNAKE_CASE', 'Upper snake case'),
84+
(['UPPER_SNAKE_CASE', 'LINE_2'], 'Upper snake case Line 2'),
85+
),
86+
)
87+
def test_default_glue_upper_snake_case_to_sentence_case(string, expected):
88+
"""Test formatting currency"""
89+
assert upper_snake_case_to_sentence_case(string) == expected
90+
91+
92+
@pytest.mark.parametrize(
93+
'value,expected',
94+
(
95+
(0, '£0'),
96+
(1, '£1'),
97+
(1.5, '£1.50'),
98+
(999999, '£999,999'),
99+
(1000000, '£1 million'),
100+
(1234567, '£1.23 million'),
101+
(7000000, '£7 million'),
102+
(999990000, '£999.99 million'),
103+
(999999999, '£1 billion'),
104+
(1000000000, '£1 billion'),
105+
(1200000000, '£1.2 billion'),
106+
(1234567890, '£1.23 billion'),
107+
(7000000000, '£7 billion'),
108+
(123000000000, '£123 billion'),
109+
(1234000000000, '£1,234 billion'),
110+
(1234500000000, '£1,234.5 billion'),
111+
),
112+
)
113+
def test_format_currency(value, expected):
114+
"""Test formatting currency"""
115+
assert format_currency(str(value)) == expected
116+
assert format_currency(value) == expected
117+
118+
# Test without currency symbols
119+
assert format_currency(str(value), symbol='') == expected.replace('£', '')
120+
assert format_currency(value, symbol='') == expected.replace('£', '')
121+
122+
# Test with different currency symbols
123+
assert format_currency(str(value), symbol='A$') == expected.replace('£', 'A$')
124+
assert format_currency(value, symbol='A$') == expected.replace('£', 'A$')
125+
126+
127+
@pytest.mark.parametrize(
128+
'values,expected',
129+
(
130+
([0, 1.5], '£0 to £1.50'),
131+
([999999, 1000000], '£999,999 to £1 million'),
132+
([1234567, 7000000], '£1.23 million to £7 million'),
133+
([999990000, 999999999], '£999.99 million to £1 billion'),
134+
([1200000000, 0.01], '£1.2 billion to £0.01'),
135+
),
136+
)
137+
def test_format_currency_range(values, expected):
138+
assert format_currency_range(values) == expected
139+
assert format_currency_range(values, symbol='') == expected.replace('£', '')
140+
assert format_currency_range(values, symbol='A$') == expected.replace('£', 'A$')
141+
142+
143+
@pytest.mark.parametrize(
144+
'string,expected',
145+
(
146+
('0-9999', 'Less than £10,000'),
147+
('0-10000', 'Less than £10,000'),
148+
('0-1000000', 'Less than £1 million'),
149+
('10000-500000', '£10,000 to £500,000'),
150+
('500001-1000000', '£500,001 to £1 million'),
151+
('1000001-2000000', '£1 million to £2 million'),
152+
('2000001-5000000', '£2 million to £5 million'),
153+
('5000001-10000000', '£5 million to £10 million'),
154+
('10000001+', 'More than £10 million'),
155+
('SPECIFIC_AMOUNT', 'Specific amount'),
156+
),
157+
)
158+
def test_format_currency_range_string(string, expected):
159+
"""
160+
Test range with and without currency symbol.
161+
"""
162+
assert format_currency_range_string(string) == expected
163+
assert format_currency_range_string(string, symbol='') == expected.replace('£', '')
164+
assert format_currency_range_string(string, symbol='A$') == expected.replace('£', 'A$')
165+
166+
167+
@pytest.mark.parametrize(
168+
'string,expected',
169+
(
170+
('0...9999', 'Less than £10,000'),
171+
('0...10000', 'Less than £10,000'),
172+
('0...1000000', 'Less than £1 million'),
173+
('10000...500000', '£10,000 to £500,000'),
174+
('500001...1000000', '£500,001 to £1 million'),
175+
('1000001...2000000', '£1 million to £2 million'),
176+
('2000001...5000000', '£2 million to £5 million'),
177+
('5000001...10000000', '£5 million to £10 million'),
178+
('10000001+', 'More than £10 million'),
179+
('SPECIFIC_AMOUNT', 'Specific amount'),
180+
),
181+
)
182+
def test_format_currency_range_string_separator(string, expected):
183+
"""
184+
Test range with separator symbol.
185+
"""
186+
assert format_currency_range_string(string, separator='...') == expected
187+
188+
189+
@pytest.mark.parametrize(
190+
'string,more_or_less,smart_more_or_less,expected',
191+
(
192+
('0-9999', True, True, 'Less than £10,000'),
193+
('0-10000', True, True, 'Less than £10,000'),
194+
('0-1000000', True, True, 'Less than £1 million'),
195+
('10000001+', True, True, 'More than £10 million'),
196+
('SPECIFIC_AMOUNT', True, True, 'Specific amount'),
197+
('0-9999', True, False, 'Less than £9,999'),
198+
('0-10000', True, False, 'Less than £10,000'),
199+
('0-1000000', True, False, 'Less than £1 million'),
200+
('10000001+', True, False, 'More than £10 million'),
201+
('SPECIFIC_AMOUNT', True, False, 'Specific amount'),
202+
# smart_more_or_less is not used when more_or_less is False.
203+
('0-9999', False, False, '£0 to £9,999'),
204+
('0-10000', False, False, '£0 to £10,000'),
205+
('0-1000000', False, False, '£0 to £1 million'),
206+
('10000001+', False, False, '£10 million+'),
207+
# Return string as Sentence case for invalid numbers
208+
('SPECIFIC_AMOUNT', False, False, 'Specific amount'),
209+
),
210+
)
211+
def test_format_currency_range_string_more_or_less_parameters(
212+
string,
213+
more_or_less,
214+
smart_more_or_less,
215+
expected,
216+
):
217+
"""
218+
Test range with and without currency symbol.
219+
"""
220+
assert format_currency_range_string(
221+
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less) == expected
222+
assert format_currency_range_string(
223+
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less, symbol='') == \
224+
expected.replace('£', '')
225+
assert format_currency_range_string(
226+
string, more_or_less=more_or_less, smart_more_or_less=smart_more_or_less, symbol='A$') == \
227+
expected.replace('£', 'A$')
228+
229+
62230
def test_slice_iterable_into_chunks():
63231
"""Test slice iterable into chunks."""
64232
size = 2

datahub/core/utils.py

+95
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,101 @@ def join_truthy_strings(*args, sep=' '):
7272
return sep.join(filter(None, args))
7373

7474

75+
def upper_snake_case_to_sentence_case(strings, glue=' '):
76+
"""
77+
Formats string or strings from UPPER_SNAKE_CASE to Sentence case
78+
"""
79+
if isinstance(strings, str):
80+
strings = [strings]
81+
return glue.join(list(map(lambda string: string.replace('_', ' ').capitalize(), strings)))
82+
83+
84+
def format_currency(value, symbol='£'):
85+
"""
86+
Formats currency according to Gov UK style guide
87+
value: (str, int, float)
88+
89+
https://www.gov.uk/guidance/style-guide/a-to-z#money and others
90+
"""
91+
if isinstance(value, str):
92+
try:
93+
value = int(value)
94+
except ValueError:
95+
value = float(value)
96+
97+
# add million or billion multiplier
98+
multiplier = ''
99+
if value >= 1000000:
100+
multiplier = ' million'
101+
value = value / 1000000
102+
# Check rounded value to avoid £1,000 million
103+
if round(value, 2) >= 1000:
104+
multiplier = ' billion'
105+
value = round(value / 1000, 2)
106+
107+
# Only use decimals when pence are included (£75.50 not £75.00)
108+
if (isinstance(value, float) and round(abs(value) % 1, 2) != 0.0):
109+
# Don't use two decimals with multiplier if it would result in trailing 0.
110+
if (multiplier != '' and round(abs(value * 10) % 1, 1) == 0.0):
111+
formatter = ',.1f'
112+
else:
113+
formatter = ',.2f'
114+
else:
115+
formatter = ',.0f'
116+
return f'{symbol}{value:{formatter}}{multiplier}'
117+
118+
119+
def format_currency_range(values, separator=' to ', symbol='£'):
120+
"""
121+
Formats a range of ammounts according to Gov UK style guide
122+
values: [(str, float, int), ...]
123+
"""
124+
return separator.join(list(map(lambda value: format_currency(value, symbol=symbol), values)))
125+
126+
127+
def format_currency_range_string(
128+
string,
129+
separator='-',
130+
more_or_less=True,
131+
smart_more_or_less=True,
132+
symbol='£',
133+
):
134+
"""
135+
Formats a range of ammounts according to Gov UK style guide.
136+
Note only numbers in specific formats are formatted, it doesn't detect number values within
137+
a string of mixed numbers and text.
138+
string: (string) the string containing the range to convert
139+
separator: (string) separator to use.
140+
more_or_less: (boolean) when true a range starting with 0 will be replace with Less than.
141+
E.g. '0 - 1000' will return 'Less than 1000'
142+
and a number with the sufix+ will be replaced with More than.
143+
E.g. '100+' will return 'More than 100'
144+
smart_more_or_less: (boolean) when true and more_or_less is set it will add one to any
145+
upper range ending on a 9.
146+
E.g. '0 - 9999' will return 'Less than 1000'
147+
"""
148+
try:
149+
prefix = ''
150+
postfix = ''
151+
if more_or_less:
152+
if string[-1] == '+':
153+
prefix = 'More than '
154+
string = string.rstrip('+')
155+
values = string.split(separator)
156+
if values[0] == '0':
157+
if smart_more_or_less and values[1][-1] == '9':
158+
values[1] = int(values[1]) + 1
159+
return f'Less than {format_currency(values[1], symbol=symbol)}'
160+
else:
161+
if string[-1] == '+':
162+
postfix = '+'
163+
string = string.rstrip('+')
164+
values = string.split(separator)
165+
return f'{prefix}{format_currency_range(values, symbol=symbol)}{postfix}'
166+
except ValueError:
167+
return upper_snake_case_to_sentence_case(string, glue='\n')
168+
169+
75170
def generate_enum_code_from_queryset(model_queryset):
76171
"""Generate the Enum code for a given constant model queryset.
77172

datahub/investment_lead/serializers.py

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
AddressSerializer,
77
NestedRelatedField,
88
)
9+
from datahub.core.utils import format_currency_range_string
910
from datahub.investment.project.models import InvestmentProject
1011
from datahub.investment_lead.models import EYBLead
1112
from datahub.metadata.models import (
@@ -506,3 +507,10 @@ class Meta(BaseEYBLeadSerializer.Meta):
506507
)
507508
company = NestedRelatedField(Company)
508509
investment_projects = NestedRelatedField(InvestmentProject, many=True)
510+
511+
def get_related_fields_representation(self, instance):
512+
"""Provides related fields in a representation-friendly format."""
513+
return {
514+
'hiring': format_currency_range_string(instance.hiring, symbol=''),
515+
'spend': format_currency_range_string(instance.spend),
516+
}

datahub/investment_lead/test/utils.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datahub.company.models.company import Company
44
from datahub.company.models.contact import Contact
5+
from datahub.core.utils import format_currency_range_string
56
from datahub.investment_lead.models import EYBLead
67
from datahub.metadata.models import Sector
78

@@ -124,8 +125,8 @@ def assert_retrieved_eyb_lead_data(instance: EYBLead, data: dict):
124125
assert str(instance.proposed_investment_region.id) == data['proposed_investment_region']['id']
125126
assert instance.proposed_investment_city == data['proposed_investment_city']
126127
assert instance.proposed_investment_location_none == data['proposed_investment_location_none']
127-
assert instance.hiring == data['hiring']
128-
assert instance.spend == data['spend']
128+
assert format_currency_range_string(instance.hiring, symbol='') == data['hiring']
129+
assert format_currency_range_string(instance.spend) == data['spend']
129130
assert instance.spend_other == data['spend_other']
130131
assert instance.is_high_value == data['is_high_value']
131132

0 commit comments

Comments
 (0)