diff --git a/app/__init__.py b/app/__init__.py index 92397a688..0221981a8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -373,6 +373,7 @@ def format_notification_status(status, template_type): 'pending-virus-check': 'Pending virus check', 'virus-scan-failed': 'Virus detected', 'returned-letter': 'Delivered', + 'cancelled': 'Cancelled,' } }[template_type].get(status, status) @@ -398,6 +399,7 @@ def format_notification_status_as_field_status(status, notification_type): 'pending-virus-check': None, 'virus-scan-failed': 'error', 'returned-letter': None, + 'cancelled': 'error', } }.get( notification_type, diff --git a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss index 34bb42526..03584dcdf 100644 --- a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss +++ b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss @@ -51,6 +51,10 @@ min-height: 50px; } + .page-footer-delete-link-without-button { + margin-top: 10px; + } + .notification-status { margin: 0; } diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index 6b08fc86c..5b2a2d99e 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -8,7 +8,9 @@ from dateutil import parser from flask import ( Response, abort, + flash, jsonify, + redirect, render_template, request, stream_with_context, @@ -83,6 +85,11 @@ def view_notification(service_id, notification_id): letter_print_day = get_letter_printing_statement(notification['status'], notification['created_at']) + notification_created = parser.parse(notification['created_at']).replace(tzinfo=None) + + show_cancel_button = notification['notification_type'] == 'letter' and \ + letter_can_be_cancelled(notification['status'], notification_created) + return render_template( 'views/notifications/notification.html', finished=(notification['status'] in (DELIVERED_STATUSES + FAILURE_STATUSES)), @@ -110,10 +117,24 @@ def view_notification(service_id, notification_id): postage=notification['postage'], can_receive_inbound=(current_service.has_permission('inbound_sms')), is_precompiled_letter=notification['template']['is_precompiled_letter'], - letter_print_day=letter_print_day + letter_print_day=letter_print_day, + show_cancel_button=show_cancel_button ) +@main.route("/services//notification//cancel", methods=['GET', 'POST']) +@login_required +@user_has_permissions('view_activity', 'send_messages') +def cancel_letter(service_id, notification_id): + + if request.method == 'POST': + notification_api_client.update_notification_to_cancelled(current_service.id, notification_id) + return redirect(url_for('main.view_notification', service_id=service_id, notification_id=notification_id)) + + flash("Are you sure you want to cancel sending this letter?", 'cancel') + return view_notification(service_id, notification_id) + + def get_letter_printing_statement(status, created_at): created_at_dt = parser.parse(created_at).replace(tzinfo=None) diff --git a/app/navigation.py b/app/navigation.py index 5236a261a..2fb37551d 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -121,6 +121,7 @@ class HeaderNavigation(Navigation): 'cancel_invited_org_user', 'cancel_invited_user', 'cancel_job', + 'cancel_letter', 'check_and_resend_text_code', 'check_and_resend_verification_code', 'check_messages', @@ -397,6 +398,7 @@ class MainNavigation(Navigation): 'cancel_invited_org_user', 'cancel_invited_user', 'cancel_job', + 'cancel_letter', 'check_and_resend_text_code', 'check_and_resend_verification_code', 'check_messages_preview', @@ -569,6 +571,7 @@ class CaseworkNavigation(Navigation): 'cancel_invited_org_user', 'cancel_invited_user', 'cancel_job', + 'cancel_letter', 'check_and_resend_text_code', 'check_and_resend_verification_code', 'check_messages', @@ -806,6 +809,7 @@ class OrgNavigation(Navigation): 'cancel_invited_org_user', 'cancel_invited_user', 'cancel_job', + 'cancel_letter', 'check_and_resend_text_code', 'check_and_resend_verification_code', 'check_messages', diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py index 34dfdbe88..c40feebce 100644 --- a/app/notify_client/job_api_client.py +++ b/app/notify_client/job_api_client.py @@ -25,7 +25,8 @@ class JobApiClient(NotifyAdminAPIClient): def __convert_statistics(job): results = defaultdict(int) for outcome in job['statistics']: - if outcome['status'] in ['failed', 'technical-failure', 'temporary-failure', 'permanent-failure']: + if outcome['status'] in ['failed', 'technical-failure', 'temporary-failure', + 'permanent-failure', 'cancelled']: results['failed'] += outcome['count'] if outcome['status'] in ['sending', 'pending', 'created']: results['sending'] += outcome['count'] diff --git a/app/notify_client/notification_api_client.py b/app/notify_client/notification_api_client.py index 10f0d3e6f..dae4f5825 100644 --- a/app/notify_client/notification_api_client.py +++ b/app/notify_client/notification_api_client.py @@ -93,5 +93,10 @@ class NotificationApiClient(NotifyAdminAPIClient): return self.get(url=get_url) + def update_notification_to_cancelled(self, service_id, notification_id): + return self.post( + url='/service/{}/notifications/{}/cancel'.format(service_id, notification_id), + data={}) + notification_api_client = NotificationApiClient() diff --git a/app/templates/components/table.html b/app/templates/components/table.html index bb0c76196..cb5622c60 100644 --- a/app/templates/components/table.html +++ b/app/templates/components/table.html @@ -162,7 +162,7 @@ notification.template.template_type ) }} {% endif %} - {% if notification.notification_type == "letter" and notification.status in ['permanent-failure', 'validation-failed'] %} + {% if notification.notification_type == "letter" and notification.status in ['permanent-failure', 'validation-failed', 'cancelled'] %} Cancelled {% endif %} {% if notification.status|format_notification_status_as_url(notification.notification_type) %} diff --git a/app/templates/flash_messages.html b/app/templates/flash_messages.html index 55617e7fd..a95f2fb19 100644 --- a/app/templates/flash_messages.html +++ b/app/templates/flash_messages.html @@ -6,7 +6,7 @@ {{ banner( message if message is string else message[0], 'default' if ((category == 'default') or (category == 'default_with_tick')) else 'dangerous', - delete_button="Yes, {}".format(category) if category in ['delete', 'suspend', 'resume', 'remove', 'revoke this API key'] else None, + delete_button="Yes, {}".format(category) if category in ['cancel', 'delete', 'suspend', 'resume', 'remove', 'revoke this API key'] else None, with_tick=True if category == 'default_with_tick' else False, context=message[1] if message is not string )}} diff --git a/app/templates/views/notifications/notification.html b/app/templates/views/notifications/notification.html index 78a618908..1149ef9db 100644 --- a/app/templates/views/notifications/notification.html +++ b/app/templates/views/notifications/notification.html @@ -37,7 +37,7 @@

{% if template.template_type == 'letter' %} - {% if notification_status == 'permanent-failure' %} + {% if notification_status in ('permanent-failure', 'cancelled') %}

Cancelled {{ updated_at|format_datetime_short }}

@@ -64,7 +64,13 @@ {% if template.template_type == 'letter' %}
diff --git a/app/utils.py b/app/utils.py index d15ed9cc6..0d771275c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -39,7 +39,8 @@ from werkzeug.datastructures import MultiDict SENDING_STATUSES = ['created', 'pending', 'sending', 'pending-virus-check'] DELIVERED_STATUSES = ['delivered', 'sent', 'returned-letter'] -FAILURE_STATUSES = ['failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed'] +FAILURE_STATUSES = ['failed', 'temporary-failure', 'permanent-failure', + 'technical-failure', 'virus-scan-failed', 'cancelled'] REQUESTED_STATUSES = SENDING_STATUSES + DELIVERED_STATUSES + FAILURE_STATUSES diff --git a/tests/app/main/views/test_activity.py b/tests/app/main/views/test_activity.py index 1a028867b..b6c2f1a52 100644 --- a/tests/app/main/views/test_activity.py +++ b/tests/app/main/views/test_activity.py @@ -52,7 +52,8 @@ from tests.conftest import ( [ 'created', 'pending', 'sending', 'pending-virus-check', 'delivered', 'sent', 'returned-letter', - 'failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed', + 'failed', 'temporary-failure', 'permanent-failure', 'technical-failure', + 'virus-scan-failed', 'cancelled', ] ), ( @@ -65,7 +66,7 @@ from tests.conftest import ( ), ( 'failed', - ['failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed'] + ['failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed', 'cancelled'] ) ] ) diff --git a/tests/app/main/views/test_jobs.py b/tests/app/main/views/test_jobs.py index 98729d3f4..22d5ac11a 100644 --- a/tests/app/main/views/test_jobs.py +++ b/tests/app/main/views/test_jobs.py @@ -156,7 +156,8 @@ def test_jobs_page_doesnt_show_scheduled_on_page_2( [ 'created', 'pending', 'sending', 'pending-virus-check', 'delivered', 'sent', 'returned-letter', - 'failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed', + 'failed', 'temporary-failure', 'permanent-failure', 'technical-failure', + 'virus-scan-failed', 'cancelled', ] ), ( @@ -169,7 +170,7 @@ def test_jobs_page_doesnt_show_scheduled_on_page_2( ), ( 'failed', - ['failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed'] + ['failed', 'temporary-failure', 'permanent-failure', 'technical-failure', 'virus-scan-failed', 'cancelled'] ) ] ) @@ -339,6 +340,7 @@ def test_should_show_letter_job( 'permanent-failure', 'technical-failure', 'virus-scan-failed', + 'cancelled', ], ) diff --git a/tests/app/main/views/test_notifications.py b/tests/app/main/views/test_notifications.py index 48c234b27..353c6bc94 100644 --- a/tests/app/main/views/test_notifications.py +++ b/tests/app/main/views/test_notifications.py @@ -195,6 +195,10 @@ def test_notification_page_shows_page_for_letter_notification( 'permanent-failure', 'Cancelled 1 January at 1:02am', ), + ( + 'cancelled', + 'Cancelled 1 January at 1:02am', + ), ( 'validation-failed', 'Cancelled 1 January at 1:02am (letter has content outside the printable area)', @@ -237,6 +241,82 @@ def test_notification_page_shows_cancelled_letter( assert page.select_one('main img')['src'].endswith('.png?page=1') +@pytest.mark.parametrize('notification_type', ['email', 'sms']) +@freeze_time('2016-01-01 15:00') +def test_notification_page_does_not_show_cancel_link_for_sms_or_email_notifications( + client_request, + mocker, + fake_uuid, + notification_type, +): + mock_get_notification( + mocker, + fake_uuid, + template_type=notification_type, + notification_status='created', + ) + + page = client_request.get( + 'main.view_notification', + service_id=SERVICE_ONE_ID, + notification_id=fake_uuid, + ) + + assert 'Cancel sending this letter' not in normalize_spaces(page.text) + + +@freeze_time('2016-01-01 15:00') +def test_notification_page_shows_cancel_link_for_letter_which_can_be_cancelled( + client_request, + mocker, + fake_uuid, +): + mock_get_notification( + mocker, + fake_uuid, + template_type='letter', + notification_status='created', + ) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) + + page = client_request.get( + 'main.view_notification', + service_id=SERVICE_ONE_ID, + notification_id=fake_uuid, + ) + + assert 'Cancel sending this letter' in normalize_spaces(page.text) + + +@freeze_time('2016-01-01 15:00') +def test_notification_page_does_not_show_cancel_link_for_letter_which_cannot_be_cancelled( + client_request, + mocker, + fake_uuid, +): + mock_get_notification( + mocker, + fake_uuid, + template_type='letter', + notification_status='delivered', + ) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) + + page = client_request.get( + 'main.view_notification', + service_id=SERVICE_ONE_ID, + notification_id=fake_uuid, + ) + + assert 'Cancel sending this letter' not in normalize_spaces(page.text) + + @freeze_time("2016-01-01 18:00") def test_notification_page_shows_page_for_first_class_letter_notification( client_request, @@ -527,3 +607,62 @@ def test_get_letter_printing_statement_for_letter_that_has_been_sent(created_at, statement = get_letter_printing_statement('delivered', created_at) assert statement == 'Printed on {}'.format(print_day) + + +@freeze_time('2016-01-01 15:00') +def test_show_cancel_letter_confirmation( + client_request, + mocker, + fake_uuid, +): + mock_get_notification( + mocker, + fake_uuid, + template_type='letter', + notification_status='created', + ) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) + + page = client_request.get( + 'main.cancel_letter', + service_id=SERVICE_ONE_ID, + notification_id=fake_uuid, + ) + + flash_message = normalize_spaces(page.find('div', class_='banner-dangerous').text) + + assert 'Are you sure you want to cancel sending this letter?' in flash_message + + +@freeze_time('2016-01-01 15:00') +def test_cancelling_a_letter_calls_the_api( + client_request, + mocker, + fake_uuid, +): + mock_get_notification( + mocker, + fake_uuid, + template_type='letter', + notification_status='created', + ) + mocker.patch( + 'app.main.views.notifications.get_page_count_for_letter', + return_value=1 + ) + cancel_endpoint = mocker.patch( + 'app.main.views.notifications.notification_api_client.update_notification_to_cancelled' + ) + + client_request.post( + 'main.cancel_letter', + service_id=SERVICE_ONE_ID, + notification_id=fake_uuid, + _follow_redirects=True, + _expected_redirect=None, + ) + + assert cancel_endpoint.called diff --git a/tests/app/notify_client/test_notification_client.py b/tests/app/notify_client/test_notification_client.py index aeb051994..6486741e3 100644 --- a/tests/app/notify_client/test_notification_client.py +++ b/tests/app/notify_client/test_notification_client.py @@ -85,3 +85,12 @@ def test_get_api_notifications_changes_letter_statuses(mocker): assert ret['notifications'][0]['status'] == 'created' assert ret['notifications'][1]['status'] == 'created' assert ret['notifications'][2]['status'] == 'accepted' + + +def test_update_notification_to_cancelled(mocker): + mock_post = mocker.patch('app.notify_client.notification_api_client.NotificationApiClient.post') + NotificationApiClient().update_notification_to_cancelled('foo', 'bar') + mock_post.assert_called_once_with( + url='/service/foo/notifications/bar/cancel', + data={}, + )