mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-11 09:28:27 -04:00
`EmailPreviewTemplate.subject` returns a string of HTML, with any user-submitted HTML already escaped:b5a61bfb7b/notifications_utils/template.py (L672)What won’t be escaped is the HTML needed to redact the placeholders. We generate this HTML so we know its safe, and doesn’t need to be escaped. However when we pass it to Jinja, Jinja doesn’t know this, so will try to escape it. This means users will see the raw HTML. We can get around this by using Flask’s `Markup` class to tell Jinja that the string is already sanitised and doesn’t need escaping again. Text message templates don’t have this problem because they already return `Markup`:b5a61bfb7b/notifications_utils/template.py (L288)Letter templates don’t suffer from this problem (because they don’t support redaction) but without making the same change they would still double-escape ampersands, greater-than symbols, and so on.
453 lines
15 KiB
Python
453 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from functools import partial
|
|
|
|
from flask import (
|
|
Markup,
|
|
Response,
|
|
abort,
|
|
flash,
|
|
jsonify,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
stream_with_context,
|
|
url_for,
|
|
)
|
|
from flask_login import current_user
|
|
from notifications_python_client.errors import HTTPError
|
|
from notifications_utils.template import (
|
|
EmailPreviewTemplate,
|
|
LetterPreviewTemplate,
|
|
SMSBodyPreviewTemplate,
|
|
)
|
|
|
|
from app import (
|
|
current_service,
|
|
format_datetime_short,
|
|
format_thousands,
|
|
notification_api_client,
|
|
service_api_client,
|
|
)
|
|
from app.main import main
|
|
from app.main.forms import SearchNotificationsForm
|
|
from app.models.job import Job
|
|
from app.utils import (
|
|
generate_next_dict,
|
|
generate_notifications_csv,
|
|
generate_previous_dict,
|
|
get_letter_printing_statement,
|
|
get_page_from_request,
|
|
get_time_left,
|
|
parse_filter_args,
|
|
printing_today_or_tomorrow,
|
|
set_status_filters,
|
|
user_has_permissions,
|
|
)
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs")
|
|
@user_has_permissions()
|
|
def view_jobs(service_id):
|
|
return redirect(url_for(
|
|
'main.uploads',
|
|
service_id=current_service.id,
|
|
))
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>")
|
|
@user_has_permissions()
|
|
def view_job(service_id, job_id):
|
|
job = Job.from_id(job_id, service_id=current_service.id)
|
|
if job.cancelled:
|
|
abort(404)
|
|
|
|
filter_args = parse_filter_args(request.args)
|
|
filter_args['status'] = set_status_filters(filter_args)
|
|
|
|
just_sent_message = 'Your {} been sent. Printing starts {} at 5:30pm.'.format(
|
|
'letter has' if job.notification_count == 1 else 'letters have',
|
|
printing_today_or_tomorrow(job.created_at)
|
|
)
|
|
|
|
return render_template(
|
|
'views/jobs/job.html',
|
|
job=job,
|
|
status=request.args.get('status', ''),
|
|
updates_url=url_for(
|
|
".view_job_updates",
|
|
service_id=service_id,
|
|
job_id=job.id,
|
|
status=request.args.get('status', ''),
|
|
),
|
|
partials=get_job_partials(job),
|
|
just_sent=request.args.get('just_sent') == 'yes',
|
|
just_sent_message=just_sent_message,
|
|
)
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>.csv")
|
|
@user_has_permissions('view_activity')
|
|
def view_job_csv(service_id, job_id):
|
|
job = Job.from_id(job_id, service_id=service_id)
|
|
filter_args = parse_filter_args(request.args)
|
|
filter_args['status'] = set_status_filters(filter_args)
|
|
|
|
return Response(
|
|
stream_with_context(
|
|
generate_notifications_csv(
|
|
service_id=service_id,
|
|
job_id=job_id,
|
|
status=filter_args.get('status'),
|
|
page=request.args.get('page', 1),
|
|
page_size=5000,
|
|
format_for_csv=True,
|
|
template_type=job.template_type,
|
|
)
|
|
),
|
|
mimetype='text/csv',
|
|
headers={
|
|
'Content-Disposition': 'inline; filename="{} - {}.csv"'.format(
|
|
job.template['name'],
|
|
format_datetime_short(job.created_at)
|
|
)
|
|
}
|
|
)
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>", methods=['POST'])
|
|
@user_has_permissions('send_messages')
|
|
def cancel_job(service_id, job_id):
|
|
Job.from_id(job_id, service_id=service_id).cancel()
|
|
return redirect(url_for('main.service_dashboard', service_id=service_id))
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>/cancel", methods=['GET', 'POST'])
|
|
@user_has_permissions()
|
|
def cancel_letter_job(service_id, job_id):
|
|
if request.method == 'POST':
|
|
job = Job.from_id(job_id, service_id=service_id)
|
|
|
|
if job.status != 'finished' or job.notifications_created < job.notification_count:
|
|
flash("We are still processing these letters, please try again in a minute.", 'try again')
|
|
return view_job(service_id, job_id)
|
|
try:
|
|
number_of_letters = job.cancel()
|
|
except HTTPError as e:
|
|
flash(e.message, 'dangerous')
|
|
return redirect(url_for('main.view_job', service_id=service_id, job_id=job_id))
|
|
flash("Cancelled {} letters from {}".format(
|
|
format_thousands(number_of_letters), job.original_file_name
|
|
), 'default_with_tick')
|
|
return redirect(url_for('main.service_dashboard', service_id=service_id))
|
|
|
|
flash("Are you sure you want to cancel sending these letters?", 'cancel')
|
|
return view_job(service_id, job_id)
|
|
|
|
|
|
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>.json")
|
|
@user_has_permissions()
|
|
def view_job_updates(service_id, job_id):
|
|
|
|
job = Job.from_id(job_id, service_id=service_id)
|
|
|
|
return jsonify(**get_job_partials(job))
|
|
|
|
|
|
@main.route('/services/<uuid:service_id>/notifications', methods=['GET', 'POST'])
|
|
@main.route('/services/<uuid:service_id>/notifications/<template_type:message_type>', methods=['GET', 'POST'])
|
|
@user_has_permissions()
|
|
def view_notifications(service_id, message_type=None):
|
|
return render_template(
|
|
'views/notifications.html',
|
|
partials=get_notifications(service_id, message_type),
|
|
message_type=message_type,
|
|
status=request.args.get('status') or 'sending,delivered,failed',
|
|
page=request.args.get('page', 1),
|
|
to=request.form.get('to', ''),
|
|
search_form=SearchNotificationsForm(
|
|
message_type=message_type,
|
|
to=request.form.get('to', ''),
|
|
),
|
|
things_you_can_search_by={
|
|
'email': ['email address'],
|
|
'sms': ['phone number'],
|
|
'letter': ['postal address', 'file name'],
|
|
# We say recipient here because combining all 3 types, plus
|
|
# reference gets too long for the hint text
|
|
None: ['recipient'],
|
|
}.get(message_type) + {
|
|
True: ['reference'],
|
|
False: [],
|
|
}.get(bool(current_service.api_keys)),
|
|
download_link=url_for(
|
|
'.download_notifications_csv',
|
|
service_id=current_service.id,
|
|
message_type=message_type,
|
|
status=request.args.get('status')
|
|
)
|
|
)
|
|
|
|
|
|
@main.route('/services/<uuid:service_id>/notifications.json', methods=['GET', 'POST'])
|
|
@main.route('/services/<uuid:service_id>/notifications/<template_type:message_type>.json', methods=['GET', 'POST'])
|
|
@user_has_permissions()
|
|
def get_notifications_as_json(service_id, message_type=None):
|
|
return jsonify(get_notifications(
|
|
service_id, message_type, status_override=request.args.get('status')
|
|
))
|
|
|
|
|
|
@main.route('/services/<uuid:service_id>/notifications.csv', endpoint="view_notifications_csv")
|
|
@main.route(
|
|
'/services/<uuid:service_id>/notifications/<template_type:message_type>.csv',
|
|
endpoint="view_notifications_csv"
|
|
)
|
|
@user_has_permissions()
|
|
def get_notifications(service_id, message_type, status_override=None):
|
|
# TODO get the api to return count of pages as well.
|
|
page = get_page_from_request()
|
|
if page is None:
|
|
abort(404, "Invalid page argument ({}).".format(request.args.get('page')))
|
|
filter_args = parse_filter_args(request.args)
|
|
filter_args['status'] = set_status_filters(filter_args)
|
|
service_data_retention_days = None
|
|
search_term = request.form.get('to', '')
|
|
|
|
if message_type is not None:
|
|
service_data_retention_days = current_service.get_days_of_retention(message_type)
|
|
|
|
if request.path.endswith('csv') and current_user.has_permissions('view_activity'):
|
|
return Response(
|
|
generate_notifications_csv(
|
|
service_id=service_id,
|
|
page=page,
|
|
page_size=5000,
|
|
template_type=[message_type],
|
|
status=filter_args.get('status'),
|
|
limit_days=service_data_retention_days
|
|
),
|
|
mimetype='text/csv',
|
|
headers={
|
|
'Content-Disposition': 'inline; filename="notifications.csv"'}
|
|
)
|
|
notifications = notification_api_client.get_notifications_for_service(
|
|
service_id=service_id,
|
|
page=page,
|
|
template_type=[message_type] if message_type else [],
|
|
status=filter_args.get('status'),
|
|
limit_days=service_data_retention_days,
|
|
to=search_term,
|
|
)
|
|
url_args = {
|
|
'message_type': message_type,
|
|
'status': request.args.get('status')
|
|
}
|
|
prev_page = None
|
|
|
|
if 'links' in notifications and notifications['links'].get('prev', None):
|
|
prev_page = generate_previous_dict('main.view_notifications', service_id, page, url_args=url_args)
|
|
next_page = None
|
|
|
|
if 'links' in notifications and notifications['links'].get('next', None):
|
|
next_page = generate_next_dict('main.view_notifications', service_id, page, url_args)
|
|
|
|
if message_type:
|
|
download_link = url_for(
|
|
'.view_notifications_csv',
|
|
service_id=current_service.id,
|
|
message_type=message_type,
|
|
status=request.args.get('status')
|
|
)
|
|
else:
|
|
download_link = None
|
|
|
|
return {
|
|
'service_data_retention_days': service_data_retention_days,
|
|
'counts': render_template(
|
|
'views/activity/counts.html',
|
|
status=request.args.get('status'),
|
|
status_filters=get_status_filters(
|
|
current_service,
|
|
message_type,
|
|
service_api_client.get_service_statistics(
|
|
service_id,
|
|
today_only=False,
|
|
limit_days=service_data_retention_days
|
|
)
|
|
)
|
|
),
|
|
'notifications': render_template(
|
|
'views/activity/notifications.html',
|
|
notifications=list(add_preview_of_content_to_notifications(
|
|
notifications['notifications']
|
|
)),
|
|
page=page,
|
|
limit_days=service_data_retention_days,
|
|
prev_page=prev_page,
|
|
next_page=next_page,
|
|
show_pagination=(not search_term),
|
|
status=request.args.get('status'),
|
|
message_type=message_type,
|
|
download_link=download_link,
|
|
single_notification_url=partial(
|
|
url_for,
|
|
'.view_notification',
|
|
service_id=current_service.id,
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
def get_status_filters(service, message_type, statistics):
|
|
if message_type is None:
|
|
stats = {
|
|
key: sum(
|
|
statistics[message_type][key]
|
|
for message_type in {'email', 'sms', 'letter'}
|
|
)
|
|
for key in {'requested', 'delivered', 'failed'}
|
|
}
|
|
else:
|
|
stats = statistics[message_type]
|
|
stats['sending'] = stats['requested'] - stats['delivered'] - stats['failed']
|
|
|
|
filters = [
|
|
# key, label, option
|
|
('requested', 'total', 'sending,delivered,failed'),
|
|
('sending', 'sending', 'sending'),
|
|
('delivered', 'delivered', 'delivered'),
|
|
('failed', 'failed', 'failed'),
|
|
]
|
|
return [
|
|
# return list containing label, option, link, count
|
|
(
|
|
label,
|
|
option,
|
|
url_for(
|
|
'.view_notifications',
|
|
service_id=service.id,
|
|
message_type=message_type,
|
|
status=option
|
|
),
|
|
stats[key]
|
|
)
|
|
for key, label, option in filters
|
|
]
|
|
|
|
|
|
def _get_job_counts(job):
|
|
return [
|
|
(
|
|
label,
|
|
query_param,
|
|
url_for(
|
|
".view_job",
|
|
service_id=job.service,
|
|
job_id=job.id,
|
|
status=query_param,
|
|
),
|
|
count
|
|
) for label, query_param, count in [
|
|
[
|
|
'total', '',
|
|
job.notification_count
|
|
],
|
|
[
|
|
'sending', 'sending',
|
|
job.notifications_sending
|
|
],
|
|
[
|
|
'delivered', 'delivered',
|
|
job.notifications_delivered
|
|
],
|
|
[
|
|
'failed', 'failed',
|
|
job.notifications_failed
|
|
]
|
|
]
|
|
]
|
|
|
|
|
|
def get_job_partials(job):
|
|
filter_args = parse_filter_args(request.args)
|
|
filter_args['status'] = set_status_filters(filter_args)
|
|
notifications = job.get_notifications(status=filter_args['status'])
|
|
if job.template_type == 'letter':
|
|
counts = render_template(
|
|
'partials/jobs/count-letters.html',
|
|
job=job,
|
|
)
|
|
else:
|
|
counts = render_template(
|
|
'partials/count.html',
|
|
counts=_get_job_counts(job),
|
|
status=filter_args['status'],
|
|
notifications_deleted=(
|
|
job.status == 'finished' and not notifications['notifications']
|
|
),
|
|
)
|
|
service_data_retention_days = current_service.get_days_of_retention(job.template_type)
|
|
|
|
return {
|
|
'counts': counts,
|
|
'notifications': render_template(
|
|
'partials/jobs/notifications.html',
|
|
notifications=list(
|
|
add_preview_of_content_to_notifications(notifications['notifications'])
|
|
),
|
|
more_than_one_page=bool(notifications.get('links', {}).get('next')),
|
|
download_link=url_for(
|
|
'.view_job_csv',
|
|
service_id=current_service.id,
|
|
job_id=job.id,
|
|
status=request.args.get('status')
|
|
),
|
|
time_left=get_time_left(job.created_at, service_data_retention_days=service_data_retention_days),
|
|
job=job,
|
|
service_data_retention_days=service_data_retention_days,
|
|
),
|
|
'status': render_template(
|
|
'partials/jobs/status.html',
|
|
job=job,
|
|
letter_print_day=get_letter_printing_statement("created", job.created_at)
|
|
),
|
|
}
|
|
|
|
|
|
def add_preview_of_content_to_notifications(notifications):
|
|
|
|
for notification in notifications:
|
|
yield(dict(
|
|
preview_of_content=get_preview_of_content(notification),
|
|
**notification
|
|
))
|
|
|
|
|
|
def get_preview_of_content(notification):
|
|
|
|
if notification['template'].get('redact_personalisation'):
|
|
notification['personalisation'] = {}
|
|
|
|
if notification['template']['is_precompiled_letter']:
|
|
return notification['client_reference']
|
|
|
|
if notification['template']['template_type'] == 'sms':
|
|
return str(SMSBodyPreviewTemplate(
|
|
notification['template'],
|
|
notification['personalisation'],
|
|
))
|
|
|
|
if notification['template']['template_type'] == 'email':
|
|
return Markup(EmailPreviewTemplate(
|
|
notification['template'],
|
|
notification['personalisation'],
|
|
redact_missing_personalisation=True,
|
|
).subject)
|
|
|
|
if notification['template']['template_type'] == 'letter':
|
|
return Markup(LetterPreviewTemplate(
|
|
notification['template'],
|
|
notification['personalisation'],
|
|
).subject)
|