diff --git a/app/config.py b/app/config.py index c19849a7e..201517a09 100644 --- a/app/config.py +++ b/app/config.py @@ -96,6 +96,7 @@ class Development(Config): class Test(Development): DEBUG = True + TESTING = True STATSD_ENABLED = True WTF_CSRF_ENABLED = False CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload' diff --git a/app/main/__init__.py b/app/main/__init__.py index c6b2161a9..506d07858 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -29,4 +29,5 @@ from app.main.views import ( platform_admin, letter_jobs, conversation, + notifications ) diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index d10028b35..266811651 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- -import ago -import dateutil from orderedset import OrderedSet -from datetime import datetime, timedelta, timezone from itertools import chain from flask import ( @@ -35,6 +32,7 @@ from app.utils import ( generate_notifications_csv, get_help_argument, get_template, + get_time_left, REQUESTED_STATUSES, FAILURE_STATUSES, SENDING_STATUSES, @@ -364,7 +362,7 @@ def get_job_partials(job): ) return { 'counts': render_template( - 'partials/jobs/count.html', + 'partials/count.html', counts=_get_job_counts(job, request.args.get('help', 0)), status=filter_args['status'] ), @@ -388,16 +386,3 @@ def get_job_partials(job): job=job ), } - - -def get_time_left(job_created_at): - return ago.human( - ( - datetime.now(timezone.utc).replace(hour=23, minute=59, second=59) - ) - ( - dateutil.parser.parse(job_created_at) + timedelta(days=8) - ), - future_tense='Data available for {}', - past_tense='Data no longer available', # No-one should ever see this - precision=1 - ) diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py new file mode 100644 index 000000000..ec51789bd --- /dev/null +++ b/app/main/views/notifications.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +from flask import ( + render_template, + jsonify, + request, + url_for, + current_app +) +from flask_login import login_required + +from app import ( + notification_api_client, + current_service +) +from app.main import main +from app.utils import ( + user_has_permissions, + get_help_argument, + get_template, + get_time_left, + REQUESTED_STATUSES, + FAILURE_STATUSES, + SENDING_STATUSES, + DELIVERED_STATUSES, +) + + +def get_status_arg(filter_args): + if 'status' not in filter_args or not filter_args['status']: + return REQUESTED_STATUSES + elif filter_args['status'] == 'sending': + return SENDING_STATUSES + elif filter_args['status'] == 'delivered': + return DELIVERED_STATUSES + elif filter_args['status'] == 'failed': + return FAILURE_STATUSES + else: + current_app.logger.info('Unrecognised status filter: {}'.format(filter_args['status'])) + return REQUESTED_STATUSES + + +@main.route("/services//one-off-notification/") +@login_required +@user_has_permissions('view_activity', admin_override=True) +def view_notification(service_id, notification_id): + notification = notification_api_client.get_notification(service_id, notification_id) + return render_template( + 'views/notifications/notification.html', + finished=(notification['status'] in (DELIVERED_STATUSES + FAILURE_STATUSES)), + uploaded_file_name='Report', + template=get_template( + notification['template'], + current_service, + letter_preview_url=url_for( + '.view_template_version_preview', + service_id=service_id, + template_id=notification['template']['id'], + version=notification['template_version'], + filetype='png', + ), + ), + status=request.args.get('status'), + updates_url=url_for( + ".view_notification_updates", + service_id=service_id, + notification_id=notification['id'], + status=request.args.get('status'), + help=get_help_argument() + ), + partials=get_single_notification_partials(notification), + help=get_help_argument() + ) + + +@main.route("/services//one-off-notification/.json") +@user_has_permissions('view_activity', admin_override=True) +def view_notification_updates(service_id, notification_id): + return jsonify(**get_single_notification_partials( + notification_api_client.get_notification(service_id, notification_id) + )) + + +def _get_single_notification_counts(notification, help_argument): + return [ + ( + label, + query_param, + url_for( + ".view_notification", + service_id=notification['service'], + notification_id=notification['id'], + status=query_param, + help=help_argument + ), + count + ) for label, query_param, count in [ + [ + 'total', '', + 1 + ], + [ + 'sending', 'sending', + int(notification['status'] in SENDING_STATUSES) + ], + [ + 'delivered', 'delivered', + int(notification['status'] in DELIVERED_STATUSES) + ], + [ + 'failed', 'failed', + int(notification['status'] in FAILURE_STATUSES) + ] + ] + ] + + +def get_single_notification_partials(notification): + status_args = get_status_arg(request.args) + + return { + 'counts': render_template( + 'partials/count.html', + counts=_get_single_notification_counts(notification, request.args.get('help', 0)), + status=status_args + ), + 'notifications': render_template( + 'partials/notifications/notifications.html', + notification=notification, + more_than_one_page=False, + percentage_complete=100, + time_left=get_time_left(notification['created_at']), + ), + 'status': render_template( + 'partials/notifications/status.html', + notification=notification + ), + } diff --git a/app/notify_client/notification_api_client.py b/app/notify_client/notification_api_client.py index 88ee80e21..1f026eb9d 100644 --- a/app/notify_client/notification_api_client.py +++ b/app/notify_client/notification_api_client.py @@ -56,6 +56,4 @@ class NotificationApiClient(NotifyAdminAPIClient): ) def get_notification(self, service_id, notification_id): - return self.get( - url='/service/{}/notifications/{}'.format(service_id, notification_id) - ) + return self.get(url='/service/{}/notifications/{}'.format(service_id, notification_id)) diff --git a/app/templates/partials/jobs/count.html b/app/templates/partials/count.html similarity index 100% rename from app/templates/partials/jobs/count.html rename to app/templates/partials/count.html diff --git a/app/templates/partials/notifications/notifications.html b/app/templates/partials/notifications/notifications.html new file mode 100644 index 000000000..d982e2a97 --- /dev/null +++ b/app/templates/partials/notifications/notifications.html @@ -0,0 +1,31 @@ +{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading, notification_status_field %} +{% from "components/page-footer.html" import page_footer %} + +
+
+ + {% call(item, row_number) list_table( + [notification], + caption=None, + caption_visible=False, + empty_message=None, + field_headings=[ + 'Recipient', + 'Status' + ], + field_headings_visible=False + ) %} + {% call row_heading() %} +

{{ item.to }}

+ {% endcall %} + {{ notification_status_field(item) }} + {% endcall %} + + {% if more_than_one_page %} + + {% endif %} + +
+
diff --git a/app/templates/partials/notifications/status.html b/app/templates/partials/notifications/status.html new file mode 100644 index 000000000..481b32446 --- /dev/null +++ b/app/templates/partials/notifications/status.html @@ -0,0 +1,6 @@ +
+

+ Sent {% if notification.created_by %}by {{ notification.created_by.name }} {% endif %} + on {{ notification.created_at|format_datetime_short }} +

+
diff --git a/app/templates/views/notifications/notification.html b/app/templates/views/notifications/notification.html new file mode 100644 index 000000000..f4936fb53 --- /dev/null +++ b/app/templates/views/notifications/notification.html @@ -0,0 +1,27 @@ +{% extends "withnav_template.html" %} +{% from "components/banner.html" import banner %} +{% from "components/ajax-block.html" import ajax_block %} +{% from "components/page-footer.html" import page_footer %} + +{% block service_page_title %} + Report +{% endblock %} + +{% block maincolumn_content %} + +

+ Report +

+ + {{ template|string }} + + {{ ajax_block(partials, updates_url, 'status', finished=finished) }} + {{ ajax_block(partials, updates_url, 'counts', finished=finished) }} + {{ ajax_block(partials, updates_url, 'notifications', finished=finished) }} + + {{ page_footer( + secondary_link=url_for('.view_template', service_id=current_service.id, template_id=template.id), + secondary_link_text='Back to {}'.format(template.name) + ) }} + +{% endblock %} diff --git a/app/utils.py b/app/utils.py index 5cec5e67c..5f6afc925 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,11 +1,13 @@ import re import csv -from io import StringIO, BytesIO +from io import StringIO from os import path from functools import wraps import unicodedata -from datetime import datetime +from datetime import datetime, timedelta, timezone +import dateutil +import ago from flask import ( abort, current_app, @@ -15,6 +17,11 @@ from flask import ( url_for ) from flask_login import current_user +import pyexcel +import pyexcel.ext.io +import pyexcel.ext.xls +import pyexcel.ext.xlsx +import pyexcel.ext.ods3 from notifications_utils.template import ( SMSPreviewTemplate, @@ -23,12 +30,6 @@ from notifications_utils.template import ( LetterPreviewTemplate, ) -import pyexcel -import pyexcel.ext.io -import pyexcel.ext.xls -import pyexcel.ext.xlsx -import pyexcel.ext.ods3 - SENDING_STATUSES = ['created', 'pending', 'sending'] DELIVERED_STATUSES = ['delivered', 'sent'] @@ -144,13 +145,14 @@ def generate_notifications_csv(**kwargs): notification['status'], notification['created_at'] ] - line = ','.join([str(i) for i in values]) + '\n' + line = ','.join(str(i) for i in values) + '\n' yield line if notifications_resp['links'].get('next'): kwargs['page'] += 1 else: return + raise Exception("Should never reach here") def get_page_from_request(): @@ -301,3 +303,16 @@ def get_current_financial_year(): current_month = int(now.strftime('%-m')) current_year = int(now.strftime('%Y')) return current_year if current_month > 3 else current_year - 1 + + +def get_time_left(created_at): + return ago.human( + ( + datetime.now(timezone.utc).replace(hour=23, minute=59, second=59) + ) - ( + dateutil.parser.parse(created_at) + timedelta(days=8) + ), + future_tense='Data available for {}', + past_tense='Data no longer available', # No-one should ever see this + precision=1 + ) diff --git a/tests/__init__.py b/tests/__init__.py index 8560ead2d..70fbaf095 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -245,18 +245,16 @@ def notification_json( 'notifications': [{ 'id': uuid.uuid4(), 'to': to, - 'template': { - 'id': template['id'], - 'name': template['name'], - 'template_type': template['template_type'], - }, 'body': template['content'], + 'template': template, 'job': job_payload, 'sent_at': sent_at, 'status': status, 'created_at': created_at, + 'created_by': None, 'updated_at': updated_at, 'job_row_number': job_row_number, + 'service': service_id, 'template_version': template['version'] } for i in range(rows)], 'total': rows, diff --git a/tests/app/main/views/test_notifications.py b/tests/app/main/views/test_notifications.py new file mode 100644 index 000000000..c03b4969a --- /dev/null +++ b/tests/app/main/views/test_notifications.py @@ -0,0 +1,78 @@ +from freezegun import freeze_time +import pytest +from werkzeug.datastructures import MultiDict + +from app.main.views.notifications import get_status_arg +from app.utils import ( + REQUESTED_STATUSES, + FAILURE_STATUSES, + SENDING_STATUSES, + DELIVERED_STATUSES, +) + +from tests.conftest import mock_get_notification + + +@pytest.mark.parametrize('multidict_args, expected_statuses', [ + ([], REQUESTED_STATUSES), + ([('status', '')], REQUESTED_STATUSES), + ([('status', 'garbage')], REQUESTED_STATUSES), + ([('status', 'sending')], SENDING_STATUSES), + ([('status', 'delivered')], DELIVERED_STATUSES), + ([('status', 'failed')], FAILURE_STATUSES), +]) +def test_status_filters(mocker, multidict_args, expected_statuses): + mocker.patch('app.main.views.notifications.current_app') + + args = MultiDict(multidict_args) + args['status'] = get_status_arg(args) + + assert sorted(args['status']) == sorted(expected_statuses) + + +@freeze_time("2016-01-01 11:09:00.061258") +def test_notification_status_page_shows_details( + client_request, + mock_get_notification, + service_one, + fake_uuid, +): + page = client_request.get( + 'main.view_notification', + service_id=service_one['id'], + notification_id=fake_uuid + ) + + assert page.find('div', {'class': 'sms-message-wrapper'}).text.strip() == 'service one: template content' + assert ' '.join(page.find('tbody').find('tr').text.split()) == '07123456789 Delivered 1 January at 11:10am' + + mock_get_notification.assert_called_with( + service_one['id'], + fake_uuid + ) + + +@pytest.mark.parametrize('notification_status, expected_big_number_vals', [ + ('created', [1, 1, 0, 0]), + ('sending', [1, 1, 0, 0]), + ('delivered', [1, 0, 1, 0]), + ('temporary-failure', [1, 0, 0, 1]), +]) +def test_notification_status_page_shows_correct_numbers( + client_request, + mocker, + service_one, + fake_uuid, + notification_status, + expected_big_number_vals +): + mock_get_notification(mocker, fake_uuid, notification_status=notification_status) + + page = client_request.get( + 'main.view_notification', + service_id=service_one['id'], + notification_id=fake_uuid + ) + + big_numbers = page.find_all('div', {'class': 'big-number-number'}) + assert expected_big_number_vals == [int(num.text.strip()) for num in big_numbers] diff --git a/tests/app/notify_client/test_notification_client.py b/tests/app/notify_client/test_notification_client.py index d0e2168dc..a794c4241 100644 --- a/tests/app/notify_client/test_notification_client.py +++ b/tests/app/notify_client/test_notification_client.py @@ -33,3 +33,11 @@ def test_client_gets_notifications_for_service_and_job_by_page(mocker, arguments mock_get = mocker.patch('app.notify_client.notification_api_client.NotificationApiClient.get') NotificationApiClient().get_notifications_for_service('abcd1234', **arguments) mock_get.assert_called_once_with(**expected_call) + + +def test_get_notification(mocker): + mock_get = mocker.patch('app.notify_client.notification_api_client.NotificationApiClient.get') + NotificationApiClient().get_notification('foo', 'bar') + mock_get.assert_called_once_with( + url='/service/foo/notifications/bar' + ) diff --git a/tests/conftest.py b/tests/conftest.py index c763008e5..7f556c22d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ + import os from datetime import date, datetime, timedelta from unittest.mock import Mock import pytest from notifications_python_client.errors import HTTPError +from flask import url_for +from bs4 import BeautifulSoup from app import create_app from app.notify_client.models import ( @@ -1096,22 +1099,6 @@ def mock_get_notifications(mocker, api_user_active): ) -@pytest.fixture(scope='function') -def mock_get_notification(mocker, api_user_active): - def _get_notification( - service_id, - notification_id, - ): - return single_notification_json( - service_id, - ) - - return mocker.patch( - 'app.notification_api_client.get_notification', - side_effect=_get_notification - ) - - @pytest.fixture(scope='function') def mock_get_notifications_with_previous_next(mocker): def _get_notifications(service_id, @@ -1630,6 +1617,33 @@ def mock_reset_failed_login_count(mocker): return mocker.patch('app.user_api_client.reset_failed_login_count') +@pytest.fixture +def mock_get_notification(mocker, fake_uuid, notification_status='delivered'): + def _get_notification( + service_id, + notification_id, + ): + noti = notification_json( + service_id, + rows=1, + status=notification_status + )['notifications'][0] + + noti['id'] = notification_id + noti['created_by'] = { + 'id': fake_uuid, + 'name': 'Test User', + 'email_address': 'test@user.gov.uk' + } + noti['template'] = template_json(service_id, str(generate_uuid())) + return noti + + return mocker.patch( + 'app.notification_api_client.get_notification', + side_effect=_get_notification + ) + + @pytest.fixture(scope='function') def client(app_): with app_.test_request_context(), app_.test_client() as client: @@ -1671,3 +1685,29 @@ def os_environ(): os.environ = {} yield os.environ = old_env + + +@pytest.fixture +def client_request(logged_in_client): + class ClientRequest: + + @staticmethod + def get(endpoint, _expected_status=200, _follow_redirects=False, **endpoint_kwargs): + resp = logged_in_client.get( + url_for(endpoint, **(endpoint_kwargs or {})), + follow_redirects=_follow_redirects, + ) + assert resp.status_code == _expected_status + return BeautifulSoup(resp.data.decode('utf-8'), 'html.parser') + + @staticmethod + def post(endpoint, _data=None, _expected_status=302, _follow_redirects=False, **endpoint_kwargs): + resp = logged_in_client.post( + url_for(endpoint, **(endpoint_kwargs or {})), + data=_data, + follow_redirects=_follow_redirects, + ) + assert resp.status_code == _expected_status + return BeautifulSoup(resp.data.decode('utf-8'), 'html.parser') + + return ClientRequest