diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index 2fe813bb3..4c66ac8a7 100644 --- a/datahub/core/test/test_utils.py +++ b/datahub/core/test/test_utils.py @@ -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, ) @@ -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 diff --git a/datahub/core/utils.py b/datahub/core/utils.py index 9e1f1c252..f73f3de5e 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -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. diff --git a/datahub/investment_lead/serializers.py b/datahub/investment_lead/serializers.py index 3a215a940..239623e70 100644 --- a/datahub/investment_lead/serializers.py +++ b/datahub/investment_lead/serializers.py @@ -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 ( @@ -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), + } diff --git a/datahub/investment_lead/test/utils.py b/datahub/investment_lead/test/utils.py index 3fb69ece0..d0c2060e4 100644 --- a/datahub/investment_lead/test/utils.py +++ b/datahub/investment_lead/test/utils.py @@ -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 @@ -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']