Merge pull request #1324 from alphagov/one-off

One off status page
This commit is contained in:
Leo Hemsted
2017-06-21 15:05:55 +01:00
committed by GitHub
14 changed files with 375 additions and 50 deletions

View File

@@ -96,6 +96,7 @@ class Development(Config):
class Test(Development): class Test(Development):
DEBUG = True DEBUG = True
TESTING = True
STATSD_ENABLED = True STATSD_ENABLED = True
WTF_CSRF_ENABLED = False WTF_CSRF_ENABLED = False
CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload' CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload'

View File

@@ -29,4 +29,5 @@ from app.main.views import (
platform_admin, platform_admin,
letter_jobs, letter_jobs,
conversation, conversation,
notifications
) )

View File

@@ -1,8 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import ago
import dateutil
from orderedset import OrderedSet from orderedset import OrderedSet
from datetime import datetime, timedelta, timezone
from itertools import chain from itertools import chain
from flask import ( from flask import (
@@ -35,6 +32,7 @@ from app.utils import (
generate_notifications_csv, generate_notifications_csv,
get_help_argument, get_help_argument,
get_template, get_template,
get_time_left,
REQUESTED_STATUSES, REQUESTED_STATUSES,
FAILURE_STATUSES, FAILURE_STATUSES,
SENDING_STATUSES, SENDING_STATUSES,
@@ -364,7 +362,7 @@ def get_job_partials(job):
) )
return { return {
'counts': render_template( 'counts': render_template(
'partials/jobs/count.html', 'partials/count.html',
counts=_get_job_counts(job, request.args.get('help', 0)), counts=_get_job_counts(job, request.args.get('help', 0)),
status=filter_args['status'] status=filter_args['status']
), ),
@@ -388,16 +386,3 @@ def get_job_partials(job):
job=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
)

View File

@@ -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/<service_id>/one-off-notification/<notification_id>")
@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/<service_id>/one-off-notification/<notification_id>.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
),
}

View File

@@ -56,6 +56,4 @@ class NotificationApiClient(NotifyAdminAPIClient):
) )
def get_notification(self, service_id, notification_id): def get_notification(self, service_id, notification_id):
return self.get( return self.get(url='/service/{}/notifications/{}'.format(service_id, notification_id))
url='/service/{}/notifications/{}'.format(service_id, notification_id)
)

View File

@@ -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 %}
<div class="ajax-block-container" aria-labelledby='pill-selected-item'>
<div class="dashboard-table bottom-gutter-3-2">
{% 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() %}
<p>{{ item.to }}</p>
{% endcall %}
{{ notification_status_field(item) }}
{% endcall %}
{% if more_than_one_page %}
<p class="table-show-more-link">
Only showing the first 50 rows
</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,6 @@
<div class="ajax-block-container">
<p class='heading-small bottom-gutter'>
Sent {% if notification.created_by %}by {{ notification.created_by.name }} {% endif %}
on {{ notification.created_at|format_datetime_short }}
</p>
</div>

View File

@@ -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 %}
<h1 class="heading-large">
Report
</h1>
{{ 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 %}

View File

@@ -1,11 +1,13 @@
import re import re
import csv import csv
from io import StringIO, BytesIO from io import StringIO
from os import path from os import path
from functools import wraps from functools import wraps
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime, timedelta, timezone
import dateutil
import ago
from flask import ( from flask import (
abort, abort,
current_app, current_app,
@@ -15,6 +17,11 @@ from flask import (
url_for url_for
) )
from flask_login import current_user 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 ( from notifications_utils.template import (
SMSPreviewTemplate, SMSPreviewTemplate,
@@ -23,12 +30,6 @@ from notifications_utils.template import (
LetterPreviewTemplate, LetterPreviewTemplate,
) )
import pyexcel
import pyexcel.ext.io
import pyexcel.ext.xls
import pyexcel.ext.xlsx
import pyexcel.ext.ods3
SENDING_STATUSES = ['created', 'pending', 'sending'] SENDING_STATUSES = ['created', 'pending', 'sending']
DELIVERED_STATUSES = ['delivered', 'sent'] DELIVERED_STATUSES = ['delivered', 'sent']
@@ -144,13 +145,14 @@ def generate_notifications_csv(**kwargs):
notification['status'], notification['status'],
notification['created_at'] notification['created_at']
] ]
line = ','.join([str(i) for i in values]) + '\n' line = ','.join(str(i) for i in values) + '\n'
yield line yield line
if notifications_resp['links'].get('next'): if notifications_resp['links'].get('next'):
kwargs['page'] += 1 kwargs['page'] += 1
else: else:
return return
raise Exception("Should never reach here")
def get_page_from_request(): def get_page_from_request():
@@ -301,3 +303,16 @@ def get_current_financial_year():
current_month = int(now.strftime('%-m')) current_month = int(now.strftime('%-m'))
current_year = int(now.strftime('%Y')) current_year = int(now.strftime('%Y'))
return current_year if current_month > 3 else current_year - 1 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
)

View File

@@ -245,18 +245,16 @@ def notification_json(
'notifications': [{ 'notifications': [{
'id': uuid.uuid4(), 'id': uuid.uuid4(),
'to': to, 'to': to,
'template': {
'id': template['id'],
'name': template['name'],
'template_type': template['template_type'],
},
'body': template['content'], 'body': template['content'],
'template': template,
'job': job_payload, 'job': job_payload,
'sent_at': sent_at, 'sent_at': sent_at,
'status': status, 'status': status,
'created_at': created_at, 'created_at': created_at,
'created_by': None,
'updated_at': updated_at, 'updated_at': updated_at,
'job_row_number': job_row_number, 'job_row_number': job_row_number,
'service': service_id,
'template_version': template['version'] 'template_version': template['version']
} for i in range(rows)], } for i in range(rows)],
'total': rows, 'total': rows,

View File

@@ -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]

View File

@@ -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') mock_get = mocker.patch('app.notify_client.notification_api_client.NotificationApiClient.get')
NotificationApiClient().get_notifications_for_service('abcd1234', **arguments) NotificationApiClient().get_notifications_for_service('abcd1234', **arguments)
mock_get.assert_called_once_with(**expected_call) 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'
)

View File

@@ -1,9 +1,12 @@
import os import os
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
from notifications_python_client.errors import HTTPError from notifications_python_client.errors import HTTPError
from flask import url_for
from bs4 import BeautifulSoup
from app import create_app from app import create_app
from app.notify_client.models import ( 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') @pytest.fixture(scope='function')
def mock_get_notifications_with_previous_next(mocker): def mock_get_notifications_with_previous_next(mocker):
def _get_notifications(service_id, 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') 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') @pytest.fixture(scope='function')
def client(app_): def client(app_):
with app_.test_request_context(), app_.test_client() as client: with app_.test_request_context(), app_.test_client() as client:
@@ -1671,3 +1685,29 @@ def os_environ():
os.environ = {} os.environ = {}
yield yield
os.environ = old_env 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