diff --git a/app/formatters.py b/app/formatters.py index 9e7f0de33..28470c1ec 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -503,3 +503,9 @@ def iteration_count(count): return 'twice' else: return f'{count} times' + + +def character_count(count): + if count == 1: + return '1 character' + return f'{format_thousands(count)} characters' diff --git a/app/main/views/templates.py b/app/main/views/templates.py index f7f3936d5..ed69740ac 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -1,9 +1,17 @@ from functools import partial -from flask import abort, flash, redirect, render_template, request, url_for +from flask import ( + abort, + flash, + jsonify, + redirect, + render_template, + request, + url_for, +) from flask_login import current_user from notifications_python_client.errors import HTTPError -from notifications_utils import LETTER_MAX_PAGE_COUNT +from notifications_utils import LETTER_MAX_PAGE_COUNT, SMS_CHAR_COUNT_LIMIT from notifications_utils.pdf import is_letter_too_long from app import ( @@ -14,6 +22,7 @@ from app import ( template_folder_api_client, template_statistics_client, ) +from app.formatters import character_count, message_count from app.main import main, no_cookie from app.main.forms import ( BroadcastTemplateForm, @@ -644,6 +653,67 @@ def edit_service_template(service_id, template_id): ) +@main.route( + "/services//templates/count--length", + methods=['POST'], +) +@user_has_permissions() +def count_content_length(service_id, template_type): + if template_type not in {'sms', 'broadcast'}: + abort(404) + + error, message = _get_content_count_error_and_message_for_template( + get_template({ + 'template_type': template_type, + 'content': request.form.get('template_content', ''), + }, current_service) + ) + + return jsonify({ + 'html': render_template( + 'partials/templates/content-count-message.html', + error=error, + message=message, + ) + }) + + +def _get_content_count_error_and_message_for_template(template): + + if template.is_message_empty(): + return False, '' + + if template.template_type == 'sms': + if template.is_message_too_long(): + return True, ( + f'You have ' + f'{character_count(template.content_count_without_prefix - SMS_CHAR_COUNT_LIMIT)} ' + f'too many' + ) + if template.placeholders: + return False, ( + f'Will be charged as {message_count(template.fragment_count, template.template_type)} ' + f'(not including personalisation)' + ) + return False, ( + f'Will be charged as {message_count(template.fragment_count, template.template_type)} ' + ) + + if template.template_type == 'broadcast': + if template.content_too_long: + return True, ( + f'You have ' + f'{character_count(template.encoded_content_count - template.max_content_count)} ' + f'too many' + ) + else: + return False, ( + f'You have ' + f'{character_count(template.max_content_count - template.encoded_content_count)} ' + f'remaining' + ) + + @main.route("/services//templates//delete", methods=['GET', 'POST']) @user_has_permissions('manage_templates') def delete_service_template(service_id, template_id): diff --git a/app/navigation.py b/app/navigation.py index 79e534991..97938d829 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -164,6 +164,7 @@ class HeaderNavigation(Navigation): 'confirm_edit_user_email', 'confirm_edit_user_mobile_number', 'confirm_redact_template', + 'count_content_length', 'conversation', 'conversation_reply', 'conversation_reply_with_template', @@ -554,6 +555,7 @@ class MainNavigation(Navigation): 'choose_service', 'clear_cache', 'confirm_edit_organisation_name', + 'count_content_length', 'conversation_reply_with_template', 'conversation_updates', 'cookies', @@ -793,6 +795,7 @@ class CaseworkNavigation(Navigation): 'choose_service', 'choose_template_to_copy', 'clear_cache', + 'count_content_length', 'edit_organisation_agreement', 'edit_organisation_crown_status', 'edit_organisation_domains', @@ -1128,6 +1131,7 @@ class OrgNavigation(Navigation): 'confirm_edit_user_email', 'confirm_edit_user_mobile_number', 'confirm_redact_template', + 'count_content_length', 'conversation', 'conversation_reply', 'conversation_reply_with_template', diff --git a/app/templates/partials/templates/content-count-message.html b/app/templates/partials/templates/content-count-message.html new file mode 100644 index 000000000..6737ff40b --- /dev/null +++ b/app/templates/partials/templates/content-count-message.html @@ -0,0 +1,3 @@ + + {{ message }} + diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index a45fcb06e..1564ca376 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -1,7 +1,9 @@ +import json from functools import partial from unittest.mock import ANY, Mock import pytest +from bs4 import BeautifulSoup from flask import url_for from freezegun import freeze_time from notifications_python_client.errors import HTTPError @@ -2543,3 +2545,181 @@ def test_should_not_create_broadcast_template_with_placeholders( 'You can’t use ((double brackets)) to personalise this message' ) assert mock_create_service_template.called is False + + +@pytest.mark.parametrize( + 'template_type, prefix_sms, content, expected_message, expected_class', ( + ( + 'sms', False, '', + '', + None, + ), + ( + 'sms', False, 'a' * 160, + 'Will be charged as 1 text message', + None, + ), + ( + 'sms', False, 'a' * 161, + 'Will be charged as 2 text messages', + None, + ), + ( + # service name takes 13 characters, 147 + 13 = 160 + 'sms', True, 'a' * 147, + 'Will be charged as 1 text message', + None, + ), + ( + # service name takes 13 characters, 148 + 13 = 161 + 'sms', True, 'a' * 148, + 'Will be charged as 2 text messages', + None, + ), + ( + 'sms', False, 'a' * 918, + 'Will be charged as 6 text messages', + None, + ), + ( + # Service name increases fragment count but doesn’t count + # against total character limit + 'sms', True, 'a' * 918, + 'Will be charged as 7 text messages', + None, + ), + ( + # Can’t make a 7 fragment text template from content alone + 'sms', False, 'a' * 919, + 'You have 1 character too many', + 'govuk-error-message', + ), + ( + # Service name increases content count but character count + # is based on content alone + 'sms', True, 'a' * 919, + 'You have 1 character too many', + 'govuk-error-message', + ), + ( + # Service name increases content count but character count + # is based on content alone + 'sms', True, 'a' * 920, + 'You have 2 characters too many', + 'govuk-error-message', + ), + ( + 'sms', False, 'Ẅ' * 70, + 'Will be charged as 1 text message', + None, + ), + ( + 'sms', False, 'Ẅ' * 71, + 'Will be charged as 2 text messages', + None, + ), + ( + 'sms', False, 'Ẅ' * 918, + 'Will be charged as 14 text messages', + None, + ), + ( + 'sms', False, 'Ẅ' * 919, + 'You have 1 character too many', + 'govuk-error-message', + ), + ( + 'sms', False, 'Hello ((name))', + 'Will be charged as 1 text message (not including personalisation)', + None, + ), + ( + # Length of placeholder body doesn’t count towards fragment count + 'sms', False, f'Hello (( {"a" * 999} ))', + 'Will be charged as 1 text message (not including personalisation)', + None, + ), + ( + 'broadcast', False, '', + '', + None, + ), + ( + 'broadcast', False, 'a', + 'You have 1,394 characters remaining', + None, + ), + ( + 'broadcast', False, 'a' * 1395, + 'You have 0 characters remaining', + None, + ), + ( + 'broadcast', False, 'a' * 1396, + 'You have 1 character too many', + 'govuk-error-message', + ), + ( + 'broadcast', False, 'a' * 1397, + 'You have 2 characters too many', + 'govuk-error-message', + ), + ( + 'broadcast', False, 'Ẅ' * 615, + 'You have 0 characters remaining', + None, + ), + ( + 'broadcast', False, 'Ẅ' * 616, + 'You have 1 character too many', + 'govuk-error-message', + ), + ), +) +def test_content_count_json_endpoint( + logged_in_client, + service_one, + template_type, + prefix_sms, + content, + expected_message, + expected_class, +): + service_one['prefix_sms'] = prefix_sms + response = logged_in_client.post( + url_for( + 'main.count_content_length', + service_id=SERVICE_ONE_ID, + template_type=template_type, + ), + data={ + 'template_content': content, + }, + ) + assert response.status_code == 200 + + html = json.loads(response.get_data(as_text=True))['html'] + snippet = BeautifulSoup(html, 'html.parser').select_one('span') + + assert normalize_spaces(snippet.text) == expected_message + + if snippet.has_attr('class'): + assert snippet['class'] == [expected_class] + else: + assert expected_class is None + + +@pytest.mark.parametrize('template_type', ( + 'email', 'letter', 'banana', +)) +def test_content_count_json_endpoint_for_unsupported_template_types( + client_request, + template_type, +): + client_request.post( + 'main.count_content_length', + service_id=SERVICE_ONE_ID, + template_type=template_type, + content='foo', + _expected_status=404, + )