From 368762a8302d626d7c705b351e5169e5e89b7983 Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Wed, 8 Jan 2025 17:04:16 +0000 Subject: [PATCH 1/7] User friendly numbers for EYB Leads --- datahub/core/test/test_utils.py | 104 +++++++++++++++++++++++++ datahub/core/utils.py | 87 +++++++++++++++++++++ datahub/investment_lead/serializers.py | 8 ++ 3 files changed, 199 insertions(+) diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index 2fe813bb3..6bb2733aa 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,106 @@ 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$') + + 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..3ee8394a4 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -72,6 +72,93 @@ 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 + 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 = '' + 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)}' + return f'{prefix}{format_currency_range(values, symbol=symbol)}' + 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), + } From d7d53e8378defe81a4000dc390101ff8a146b1db Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Thu, 9 Jan 2025 08:50:35 +0000 Subject: [PATCH 2/7] Flake8 --- datahub/core/test/test_utils.py | 3 ++- datahub/core/utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index 6bb2733aa..c0e5f2fcd 100644 --- a/datahub/core/test/test_utils.py +++ b/datahub/core/test/test_utils.py @@ -123,12 +123,13 @@ def test_format_currency(value, expected): 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'), + ([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'), ), diff --git a/datahub/core/utils.py b/datahub/core/utils.py index 3ee8394a4..ae6013b30 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -137,7 +137,7 @@ def format_currency_range_string( 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. + 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. From daba52e5afa4de4127fbb6e2b78c5a94bc195101 Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Thu, 9 Jan 2025 10:08:49 +0000 Subject: [PATCH 3/7] Fix tests --- datahub/investment_lead/test/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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'] From 134670a74d519858c9a27f7507286521661572bc Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Thu, 9 Jan 2025 12:39:48 +0000 Subject: [PATCH 4/7] Add test coverage --- datahub/core/test/test_utils.py | 62 +++++++++++++++++++++++++++++++++ datahub/core/utils.py | 2 ++ 2 files changed, 64 insertions(+) diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index c0e5f2fcd..ca56ce2d8 100644 --- a/datahub/core/test/test_utils.py +++ b/datahub/core/test/test_utils.py @@ -164,6 +164,68 @@ def test_format_currency_range_string(string, expected): 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'), + ('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 ae6013b30..813ebd275 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -154,6 +154,8 @@ def format_currency_range_string( 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: + values = string.split(separator) return f'{prefix}{format_currency_range(values, symbol=symbol)}' except ValueError: return upper_snake_case_to_sentence_case(string, glue='\n') From c1a9e902a91e55415fd47a78f9d689111fa6d7e6 Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Thu, 9 Jan 2025 13:02:37 +0000 Subject: [PATCH 5/7] Simplify expected results in test --- datahub/core/test/test_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index ca56ce2d8..4a0814f42 100644 --- a/datahub/core/test/test_utils.py +++ b/datahub/core/test/test_utils.py @@ -203,7 +203,8 @@ def test_format_currency_range_string_separator(string, expected): ('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 + ('10000001+', False, False, '10000001+'), ('SPECIFIC_AMOUNT', False, False, 'Specific amount'), ), ) From d6ce78932b9f42b2a8e50ff9d5ac9f6f97b4004f Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Wed, 15 Jan 2025 09:18:59 +0000 Subject: [PATCH 6/7] Added + to formatting --- datahub/core/test/test_utils.py | 2 +- datahub/core/utils.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/datahub/core/test/test_utils.py b/datahub/core/test/test_utils.py index 4a0814f42..4c66ac8a7 100644 --- a/datahub/core/test/test_utils.py +++ b/datahub/core/test/test_utils.py @@ -203,8 +203,8 @@ def test_format_currency_range_string_separator(string, expected): ('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 - ('10000001+', False, False, '10000001+'), ('SPECIFIC_AMOUNT', False, False, 'Specific amount'), ), ) diff --git a/datahub/core/utils.py b/datahub/core/utils.py index 813ebd275..333d8e2b6 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -132,7 +132,9 @@ def format_currency_range_string( symbol='£', ): """ - Formats a range of ammounts according to Gov UK style guide + 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. @@ -145,6 +147,7 @@ def format_currency_range_string( """ try: prefix = '' + postfix = '' if more_or_less: if string[-1] == '+': prefix = 'More than ' @@ -155,8 +158,11 @@ def format_currency_range_string( 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)}' + return f'{prefix}{format_currency_range(values, symbol=symbol)}{postfix}' except ValueError: return upper_snake_case_to_sentence_case(string, glue='\n') From 06177b77b23331fa9e6217096d634492ba673269 Mon Sep 17 00:00:00 2001 From: Marijn Kampf Date: Wed, 15 Jan 2025 09:36:12 +0000 Subject: [PATCH 7/7] Flake 8 --- datahub/core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub/core/utils.py b/datahub/core/utils.py index 333d8e2b6..f73f3de5e 100644 --- a/datahub/core/utils.py +++ b/datahub/core/utils.py @@ -134,7 +134,7 @@ def format_currency_range_string( """ 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. + 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.