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..bea8506d9 100644 --- a/app/notify_client/notification_api_client.py +++ b/app/notify_client/notification_api_client.py @@ -55,7 +55,5 @@ class NotificationApiClient(NotifyAdminAPIClient): params=params ) - def get_notification(self, service_id, notification_id): - return self.get( - url='/service/{}/notifications/{}'.format(service_id, notification_id) - ) + def get_notification(self, service_id, notification_id):m + 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..6e2be2049 --- /dev/null +++ b/app/templates/partials/notifications/notifications.html @@ -0,0 +1,39 @@ +{% 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 %} + +
+
+ + {% if not help %} +

+ Download this report +   + {{ time_left }} +

+ {% endif %} + + {% 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..309427e72 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,31 @@ 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 generate_single_notification_csv(notification): + fieldnames = ['Recipient', 'Template', 'Type', 'Status', 'Time'] + yield ','.join(fieldnames) + '\n' + + values = [ + notification['to'], + notification['template']['name'], + notification['template']['template_type'], + notification['status'], + notification['created_at'] + ] + line = ','.join(str(i) for i in values) + '\n' + yield line + + return def get_page_from_request(): @@ -301,3 +320,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 + )