Merge branch 'master' into flask-login-again

This commit is contained in:
Chris Hill-Scott
2020-04-01 14:29:16 +01:00
committed by GitHub
81 changed files with 3384 additions and 739 deletions
+4
View File
@@ -64,6 +64,7 @@ from app.notify_client import InviteTokenError
from app.notify_client.api_key_api_client import api_key_api_client
from app.notify_client.billing_api_client import billing_api_client
from app.notify_client.complaint_api_client import complaint_api_client
from app.notify_client.contact_list_api_client import contact_list_api_client
from app.notify_client.email_branding_client import email_branding_client
from app.notify_client.events_api_client import events_api_client
from app.notify_client.inbound_number_client import inbound_number_client
@@ -91,6 +92,7 @@ from app.url_converters import (
LetterFileExtensionConverter,
SimpleDateTypeConverter,
TemplateTypeConverter,
TicketTypeConverter,
)
from app.utils import format_thousands, get_logo_cdn_domain, id_safe
@@ -140,6 +142,7 @@ def create_app(application):
# API clients
api_key_api_client,
billing_api_client,
contact_list_api_client,
complaint_api_client,
email_branding_client,
events_api_client,
@@ -225,6 +228,7 @@ def init_app(application):
application.url_map.converters['uuid'].to_python = lambda self, value: value
application.url_map.converters['template_type'] = TemplateTypeConverter
application.url_map.converters['ticket_type'] = TicketTypeConverter
application.url_map.converters['letter_file_extension'] = LetterFileExtensionConverter
application.url_map.converters['simple_date'] = SimpleDateTypeConverter
@@ -89,3 +89,8 @@ $iso-paper-ratio: 141.42135624%;
}
}
.letter-recipient-summary {
line-height: 28px;
margin-bottom: 0;
}
+12 -1
View File
@@ -27,7 +27,7 @@
text-align: left;
&-bar {
@include bold-27;
@include bold-27($tabular-numbers: true);
box-sizing: border-box;
display: inline-block;
overflow: visible;
@@ -83,6 +83,17 @@
max-width: 580px;
}
&-hint-large {
@include core-19;
display: block;
color: $secondary-text-colour;
pointer-events: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 580px;
}
}
.failure-highlight {
+6
View File
@@ -68,6 +68,7 @@ class Config(object):
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None
CSV_UPLOAD_BUCKET_NAME = 'local-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'local-contact-list'
ACTIVITY_STATS_LIMIT_DAYS = 7
TEST_MESSAGE_FILENAME = 'Report'
@@ -98,6 +99,7 @@ class Development(Config):
SESSION_PROTECTION = None
STATSD_ENABLED = False
CSV_UPLOAD_BUCKET_NAME = 'development-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'development-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-tools'
MOU_BUCKET_NAME = 'notify.tools-mou'
TRANSIENT_UPLOADED_LETTERS = 'development-transient-uploaded-letters'
@@ -121,6 +123,7 @@ class Test(Development):
STATSD_ENABLED = False
WTF_CSRF_ENABLED = False
CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'test-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-test'
MOU_BUCKET_NAME = 'test-mou'
TRANSIENT_UPLOADED_LETTERS = 'test-transient-uploaded-letters'
@@ -140,6 +143,7 @@ class Preview(Config):
HEADER_COLOUR = '#F499BE' # $baby-pink
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'preview-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'preview-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-preview'
MOU_BUCKET_NAME = 'notify.works-mou'
TRANSIENT_UPLOADED_LETTERS = 'preview-transient-uploaded-letters'
@@ -158,6 +162,7 @@ class Staging(Config):
HEADER_COLOUR = '#6F72AF' # $mauve
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'staging-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'staging-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-staging'
MOU_BUCKET_NAME = 'staging-notify.works-mou'
TRANSIENT_UPLOADED_LETTERS = 'staging-transient-uploaded-letters'
@@ -173,6 +178,7 @@ class Live(Config):
HTTP_PROTOCOL = 'https'
STATSD_ENABLED = True
CSV_UPLOAD_BUCKET_NAME = 'live-notifications-csv-upload'
CONTACT_LIST_UPLOAD_BUCKET_NAME = 'production-contact-list'
LOGO_UPLOAD_BUCKET_NAME = 'public-logos-production'
MOU_BUCKET_NAME = 'notifications.service.gov.uk-mou'
TRANSIENT_UPLOADED_LETTERS = 'production-transient-uploaded-letters'
+78 -10
View File
@@ -1,3 +1,4 @@
import re
import weakref
from datetime import datetime, timedelta
from itertools import chain
@@ -9,12 +10,17 @@ from flask_wtf import FlaskForm as Form
from flask_wtf.file import FileAllowed
from flask_wtf.file import FileField as FileField_wtf
from notifications_utils.columns import Columns
from notifications_utils.formatters import strip_whitespace
from notifications_utils.formatters import (
normalise_whitespace_and_newlines,
remove_whitespace_before_punctuation,
strip_whitespace,
)
from notifications_utils.recipients import (
InvalidPhoneError,
normalise_phone_number,
validate_phone_number,
)
from notifications_utils.take import Take
from wtforms import (
BooleanField,
DateField,
@@ -48,6 +54,7 @@ from app.main.validators import (
ValidEmail,
ValidGovEmail,
)
from app.models.feedback import PROBLEM_TICKET_TYPE, QUESTION_TICKET_TYPE
from app.models.organisation import Organisation
from app.models.roles_and_permissions import permissions, roles
from app.utils import guess_name_from_email_address
@@ -361,6 +368,23 @@ class StripWhitespaceStringField(StringField):
super(StringField, self).__init__(label, **kwargs)
class StripWhitespaceTextAreaField(TextAreaField):
def process_formdata(self, valuelist):
if valuelist:
self.data = Take(
valuelist[0]
).then(
remove_whitespace_before_punctuation
).then(
normalise_whitespace_and_newlines
).then(
# similar to normalise_multiple_newlines but taking everything down to one `\n` instead of two
lambda value: re.compile(r'\n{2,}').sub('\n', value)
).then(
str.strip
)
class OnOffField(RadioField):
def __init__(self, label, choices=None, *args, **kwargs):
@@ -740,6 +764,43 @@ class SMSTemplateForm(BaseTemplateForm):
OnlySMSCharacters()(None, field)
class LetterAddressForm(StripWhitespaceForm):
MIN_ADDRESS_LINES = 3
MAX_ADDRESS_LINES = 7
address = StripWhitespaceTextAreaField(
'Address',
validators=[DataRequired(message="Cannot be empty")]
)
def validate_address(self, field):
lines = field.data.splitlines()
if len(lines) < self.MIN_ADDRESS_LINES:
raise ValidationError('Address must be at least 3 lines long')
if len(lines) > self.MAX_ADDRESS_LINES:
raise ValidationError('Address must be no more than 7 lines long')
@property
def as_address_lines_1_to_7_with_postcode(self):
lines = self.address.data.splitlines()
placeholders = {}
# set all placeholders to empty strings, or all_placeholders_in_session will always return false.
# note that it must be `address line #` with spaces, not underscores or dashes
for i in range(1, 7):
placeholders[f'address line {i}'] = ''
# unroll the address into lines, and place into the session in the underlying placeholder names
# postcode is required so make sure we put the last value in that
# TODO: When postcode is no longer a required field, remove this special case and just use `address line #`
address_lines, last_address_line = lines[:-1], lines[-1]
for i, line in enumerate(address_lines, start=1):
placeholders[f'address line {i}'] = line
placeholders['postcode'] = last_address_line
return placeholders
class EmailTemplateForm(BaseTemplateForm):
subject = TextAreaField(
u'Subject',
@@ -885,23 +946,30 @@ class SupportType(StripWhitespaceForm):
support_type = RadioField(
'How can we help you?',
choices=[
('report-problem', 'Report a problem'),
('ask-question-give-feedback', 'Ask a question or give feedback'),
(PROBLEM_TICKET_TYPE, 'Report a problem'),
(QUESTION_TICKET_TYPE, 'Ask a question or give feedback'),
],
validators=[DataRequired()]
)
class Feedback(StripWhitespaceForm):
name = StringField('Name')
email_address = email_address(label='Email address', gov_user=False, required=False)
class SupportRedirect(StripWhitespaceForm):
who = RadioField(
'What do you need help with?',
choices=[
('public-sector', 'I work in the public sector and need to send emails, text messages or letters'),
('public', 'Im a member of the public with a question for the government'),
],
validators=[DataRequired()]
)
class FeedbackOrProblem(StripWhitespaceForm):
name = StringField('Name (optional)')
email_address = email_address(label='Email address', gov_user=False, required=True)
feedback = TextAreaField('Your message', validators=[DataRequired(message="Cannot be empty")])
class Problem(Feedback):
email_address = email_address(label='Email address', gov_user=False)
class Triage(StripWhitespaceForm):
severe = RadioField(
'Is it an emergency?',
+4 -5
View File
@@ -43,11 +43,10 @@ class ValidGovEmail:
return
from flask import url_for
message = (
'Enter a government email address.'
' If you think you should have access'
' <a class="govuk-link govuk-link--no-visited-state" href="{}">contact us</a>'
).format(url_for('main.support'))
message = '''
Enter a public sector email address or
<a class="govuk-link govuk-link--no-visited-state" href="{}">find out who can use Notify</a>
'''.format(url_for('main.who_its_for'))
if not is_gov_user(field.data.lower()):
raise ValidationError(message)
-4
View File
@@ -309,10 +309,6 @@ def get_dashboard_partials(service_id):
[row['count'] for row in template_statistics] or [0]
),
),
'jobs': render_template(
'views/dashboard/_jobs.html',
jobs=current_service.immediate_jobs,
),
'usage': render_template(
'views/dashboard/_usage.html',
**calculate_usage(yearly_usage, free_sms_allowance),
+65 -47
View File
@@ -1,67 +1,79 @@
from datetime import datetime
import pytz
from flask import abort, redirect, render_template, request, session, url_for
from flask import redirect, render_template, request, session, url_for
from flask_login import current_user
from app import convert_to_boolean, current_service, service_api_client
from app.extensions import zendesk_client
from app.main import main
from app.main.forms import Feedback, Problem, SupportType, Triage
QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
PROBLEM_TICKET_TYPE = "report-problem"
def get_prefilled_message():
return {
'agreement': (
'Please can you tell me if theres an agreement in place '
'between GOV.UK Notify and my organisation?'
),
'letter-branding': (
'I would like my own logo on my letter templates.'
),
}.get(
request.args.get('body'), ''
)
from app.main.forms import (
FeedbackOrProblem,
SupportRedirect,
SupportType,
Triage,
)
from app.models.feedback import (
GENERAL_TICKET_TYPE,
PROBLEM_TICKET_TYPE,
QUESTION_TICKET_TYPE,
)
@main.route('/support', methods=['GET', 'POST'])
def support():
form = SupportType()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
ticket_type=form.support_type.data,
))
if current_user.is_authenticated:
form = SupportType()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
ticket_type=form.support_type.data,
))
else:
form = SupportRedirect()
if form.validate_on_submit():
if form.who.data == 'public':
return redirect(url_for(
'.support_public'
))
else:
return redirect(url_for(
'.feedback',
ticket_type=GENERAL_TICKET_TYPE,
))
return render_template('views/support/index.html', form=form)
@main.route('/support/public')
def support_public():
return render_template('views/support/public.html')
@main.route('/support/triage', methods=['GET', 'POST'])
def triage():
@main.route('/support/triage/<ticket_type:ticket_type>', methods=['GET', 'POST'])
def triage(ticket_type=PROBLEM_TICKET_TYPE):
form = Triage()
if form.validate_on_submit():
return redirect(url_for(
'.feedback',
ticket_type=PROBLEM_TICKET_TYPE,
ticket_type=ticket_type,
severe=form.severe.data
))
return render_template(
'views/support/triage.html',
form=form
form=form,
page_title={
PROBLEM_TICKET_TYPE: 'Report a problem',
GENERAL_TICKET_TYPE: 'Contact GOV.UK Notify support',
}.get(ticket_type)
)
@main.route('/support/<ticket_type>', methods=['GET', 'POST'])
@main.route('/support/<ticket_type:ticket_type>', methods=['GET', 'POST'])
def feedback(ticket_type):
try:
form = {
QUESTION_TICKET_TYPE: Feedback,
PROBLEM_TICKET_TYPE: Problem,
}[ticket_type]()
except KeyError:
abort(404)
form = FeedbackOrProblem()
if not form.feedback.data:
form.feedback.data = session.pop('feedback_message', '')
@@ -72,14 +84,14 @@ def feedback(ticket_type):
severe = None
out_of_hours_emergency = all((
ticket_type == PROBLEM_TICKET_TYPE,
ticket_type != QUESTION_TICKET_TYPE,
not in_business_hours(),
severe,
))
if needs_triage(ticket_type, severe):
session['feedback_message'] = form.feedback.data
return redirect(url_for('.triage'))
return redirect(url_for('.triage', ticket_type=ticket_type))
if needs_escalation(ticket_type, severe):
return redirect(url_for('.bat_phone'))
@@ -99,10 +111,9 @@ def feedback(ticket_type):
else:
service_string = ''
feedback_msg = '{}\n{}{}'.format(
feedback_msg = '{}\n{}'.format(
form.feedback.data,
service_string,
'' if user_email else '{} (no email address supplied)'.format(form.name.data)
)
zendesk_client.create_ticket(
@@ -121,13 +132,20 @@ def feedback(ticket_type):
),
))
if not form.feedback.data:
form.feedback.data = get_prefilled_message()
return render_template(
'views/support/{}.html'.format(ticket_type),
'views/support/form.html',
form=form,
ticket_type=ticket_type,
back_link=(
url_for('.support')
if severe is None else
url_for('.triage', ticket_type=ticket_type)
),
show_status_page_banner=(ticket_type == PROBLEM_TICKET_TYPE),
page_title={
GENERAL_TICKET_TYPE: 'Contact GOV.UK Notify support',
PROBLEM_TICKET_TYPE: 'Report a problem',
QUESTION_TICKET_TYPE: 'Ask a question or give feedback',
}.get(ticket_type),
)
@@ -237,7 +255,7 @@ def has_live_services(user_id):
def needs_triage(ticket_type, severe):
return all((
ticket_type == PROBLEM_TICKET_TYPE,
ticket_type != QUESTION_TICKET_TYPE,
severe is None,
(
not current_user.is_authenticated or has_live_services(current_user.id)
@@ -248,7 +266,7 @@ def needs_triage(ticket_type, severe):
def needs_escalation(ticket_type, severe):
return all((
ticket_type == PROBLEM_TICKET_TYPE,
ticket_type != QUESTION_TICKET_TYPE,
severe,
not current_user.is_authenticated,
not in_business_hours(),
+9 -1
View File
@@ -15,12 +15,12 @@ from notifications_utils.template import HTMLEmailTemplate, LetterImageTemplate
from app import email_branding_client, letter_branding_client, status_api_client
from app.main import main
from app.main.forms import FieldWithNoneOption, SearchByNameForm
from app.main.views.feedback import QUESTION_TICKET_TYPE
from app.main.views.sub_navigation_dictionaries import (
features_nav,
pricing_nav,
using_notify_nav,
)
from app.models.feedback import QUESTION_TICKET_TYPE
from app.utils import get_logo_cdn_domain
@@ -320,6 +320,14 @@ def get_started():
)
@main.route('/using-notify/who-its-for')
def who_its_for():
return render_template(
'views/guidance/who-its-for.html',
navigation_links=using_notify_nav(),
)
@main.route('/trial-mode')
@main.route('/features/trial-mode')
def trial_mode():
+4 -20
View File
@@ -42,26 +42,10 @@ from app.utils import (
@main.route("/services/<uuid:service_id>/jobs")
@user_has_permissions()
def view_jobs(service_id):
jobs = current_service.get_page_of_jobs(page=request.args.get('page'))
prev_page = None
if jobs.prev_page:
prev_page = generate_previous_dict('main.view_jobs', service_id, jobs.current_page)
next_page = None
if jobs.next_page:
next_page = generate_next_dict('main.view_jobs', service_id, jobs.current_page)
return render_template(
'views/jobs/jobs.html',
jobs=jobs,
prev_page=prev_page,
next_page=next_page,
show_scheduled_jobs=(
jobs.current_page == 1
and not current_user.has_permissions('view_activity')
and current_service.scheduled_jobs
),
)
return redirect(url_for(
'main.uploads',
service_id=current_service.id,
))
@main.route("/services/<uuid:service_id>/jobs/<uuid:job_id>")
+13
View File
@@ -42,6 +42,14 @@ ZERO_FAILURE_THRESHOLD = 0
@main.route("/platform-admin")
@user_is_platform_admin
def platform_admin_splash_page():
return render_template(
'views/platform-admin/splash-page.html',
)
@main.route("/platform-admin/summary")
@user_is_platform_admin
def platform_admin():
form = DateFilterForm(request.args, meta={'csrf': False})
api_args = {}
@@ -360,6 +368,9 @@ def platform_admin_returned_letters():
try:
letter_jobs_client.submit_returned_letters(references)
redis_client.delete_cache_keys_by_pattern(
'service-????????-????-????-????-????????????-returned-letters-statistics'
)
redis_client.delete_cache_keys_by_pattern(
'service-????????-????-????-????-????????????-returned-letters-summary'
)
@@ -397,6 +408,8 @@ def clear_cache():
'service-????????-????-????-????-????????????-templates',
'service-????????-????-????-????-????????????-data-retention',
'service-????????-????-????-????-????????????-template-folders',
'service-????????-????-????-????-????????????-returned-letters-statistics',
'service-????????-????-????-????-????????????-returned-letters-summary',
]),
('template', [
'service-????????-????-????-????-????????????-templates',
+118 -2
View File
@@ -39,9 +39,11 @@ from app.main import main, no_cookie
from app.main.forms import (
ChooseTimeForm,
CsvUploadForm,
LetterAddressForm,
SetSenderForm,
get_placeholder_form_instance,
)
from app.models.contact_list import ContactList, ContactListsAlphabetical
from app.models.user import Users
from app.s3_client.s3_csv_client import (
s3download,
@@ -151,7 +153,7 @@ def send_messages(service_id, template_id):
try:
upload_id = s3upload(
service_id,
Spreadsheet.from_file(form.file.data, filename=form.file.data.filename).as_dict,
Spreadsheet.from_file_form(form).as_dict,
current_app.config['AWS_REGION']
)
return redirect(url_for(
@@ -306,6 +308,11 @@ def send_test(service_id, template_id):
return_to='view_template',
template_id=template_id))
if db_template['template_type'] == 'letter':
return redirect(
url_for('.send_one_off_letter_address', service_id=service_id, template_id=template_id)
)
return redirect(url_for(
{
'main.send_test': '.send_test_step',
@@ -328,6 +335,72 @@ def get_notification_check_endpoint(service_id, template):
))
@main.route(
"/services/<uuid:service_id>/send/<uuid:template_id>/one-off/address",
methods=['GET', 'POST']
)
@user_has_permissions('send_messages', restrict_admin_usage=True)
def send_one_off_letter_address(service_id, template_id):
if {'recipient', 'placeholders'} - set(session.keys()):
# if someone has come here via a bookmark or back button they might have some stuff still in their session
return redirect(url_for('.send_one_off', service_id=service_id, template_id=template_id))
db_template = current_service.get_template_with_user_permission_or_403(template_id, current_user)
session['send_test_letter_page_count'] = get_page_count_for_letter(db_template)
template = get_template(
db_template,
current_service,
show_recipient=True,
letter_preview_url=url_for(
'no_cookie.send_test_preview',
service_id=service_id,
template_id=template_id,
filetype='png',
),
page_count=session['send_test_letter_page_count'],
email_reply_to=None,
sms_sender=None
)
form = LetterAddressForm()
if form.validate_on_submit():
session['placeholders'].update(form.as_address_lines_1_to_7_with_postcode)
placeholders = fields_to_fill_in(
template,
prefill_current_user=(request.endpoint == 'main.send_test_step'),
)
if all_placeholders_in_session(placeholders):
return get_notification_check_endpoint(service_id, template)
first_non_address_placeholder_index = len(first_column_headings['letter'])
return redirect(url_for(
'main.send_one_off_step',
service_id=service_id,
template_id=template_id,
step_index=first_non_address_placeholder_index,
))
return render_template(
'views/send-one-off-letter-address.html',
page_title=get_send_test_page_title(
template_type='letter',
help_argument=None,
entering_recipient=True,
name=template.name,
),
template=template,
form=form,
optional_placeholder=False,
back_link=get_back_link(service_id, template, 0),
help=False,
link_to_upload=True,
)
@main.route(
"/services/<uuid:service_id>/send/<uuid:template_id>/test/step-<int:step_index>",
methods=['GET', 'POST'],
@@ -491,6 +564,48 @@ def send_test_preview(service_id, template_id, filetype):
return TemplatePreview.from_utils_template(template, filetype, page=request.args.get('page'))
@main.route(
'/services/<uuid:service_id>/send/<uuid:template_id>'
'/from-contact-list'
)
@user_has_permissions('send_messages')
def choose_from_contact_list(service_id, template_id):
db_template = current_service.get_template_with_user_permission_or_403(
template_id, current_user
)
template = get_template(
db_template, current_service,
)
return render_template(
'views/send-contact-list.html',
contact_lists=ContactListsAlphabetical(
current_service.id,
template_type=template.template_type,
),
template=template,
)
@main.route(
'/services/<uuid:service_id>/send/<uuid:template_id>'
'/from-contact-list/<uuid:contact_list_id>'
)
@user_has_permissions('send_messages')
def send_from_contact_list(service_id, template_id, contact_list_id):
contact_list = ContactList.from_id(
contact_list_id,
service_id=current_service.id,
)
return redirect(url_for(
'main.check_messages',
service_id=current_service.id,
template_id=template_id,
upload_id=contact_list.copy_to_uploads(),
original_file_name=contact_list.original_file_name,
contact_list_id=contact_list.id,
))
def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_pdf=False):
try:
@@ -700,7 +815,8 @@ def start_job(service_id, upload_id):
job_api_client.create_job(
upload_id,
service_id,
scheduled_for=request.form.get('scheduled_for', '')
scheduled_for=request.form.get('scheduled_for', ''),
contact_list_id=request.form.get('contact_list_id', ''),
)
session.pop('sender_id', None)
+4 -6
View File
@@ -391,9 +391,8 @@ def service_add_email_reply_to(service_id):
service_id, form.email_address.data
)["data"]["id"]
except HTTPError as e:
error_msg = "Your service already uses '{}' as an email reply-to address.".format(form.email_address.data)
if e.status_code == 400 and error_msg == e.message:
flash(error_msg, 'error')
if e.status_code == 409 or e.status_code == 400:
flash(e.message, 'error')
return redirect(url_for('.service_email_reply_to', service_id=service_id))
else:
raise e
@@ -519,9 +518,8 @@ def service_edit_email_reply_to(service_id, reply_to_email_id):
service_id, form.email_address.data
)["data"]["id"]
except HTTPError as e:
error_msg = "Your service already uses {} as a reply-to email address.".format(form.email_address.data)
if e.status_code == 400 and error_msg == e.message:
flash(error_msg, 'error')
if e.status_code == 400 or e.status_code == 409:
flash(e.message, 'error')
return redirect(url_for('.service_email_reply_to', service_id=service_id))
else:
raise e
@@ -52,6 +52,10 @@ def using_notify_nav():
"name": "Get started",
"link": "main.get_started",
},
{
"name": "Who its for",
"link": "main.who_its_for",
},
{
"name": "Trial mode",
"link": "main.trial_mode_new",
-2
View File
@@ -111,7 +111,5 @@ def redirect_when_logged_in(platform_admin):
next_url = request.args.get('next')
if next_url and _is_safe_redirect_url(next_url):
return redirect(next_url)
if platform_admin:
return redirect(url_for('main.platform_admin'))
return redirect(url_for('main.show_accounts_or_dashboard'))
+195 -2
View File
@@ -1,25 +1,35 @@
import base64
import itertools
import json
import urllib
import uuid
from io import BytesIO
from zipfile import BadZipFile
from flask import (
abort,
current_app,
flash,
redirect,
render_template,
request,
send_file,
url_for,
)
from notifications_utils.columns import Columns
from notifications_utils.pdf import pdf_page_count
from notifications_utils.recipients import RecipientCSV
from notifications_utils.sanitise_text import SanitiseASCII
from PyPDF2.utils import PdfReadError
from requests import RequestException
from xlrd.biffh import XLRDError
from xlrd.xldate import XLDateError
from app import current_service, notification_api_client, service_api_client
from app.extensions import antivirus_client
from app.main import main
from app.main.forms import LetterUploadPostageForm, PDFUploadForm
from app.main.forms import CsvUploadForm, LetterUploadPostageForm, PDFUploadForm
from app.models.contact_list import ContactList
from app.s3_client.s3_letter_upload_client import (
get_letter_metadata,
get_letter_pdf_and_metadata,
@@ -28,10 +38,13 @@ from app.s3_client.s3_letter_upload_client import (
)
from app.template_previews import TemplatePreview, sanitise_letter
from app.utils import (
Spreadsheet,
generate_next_dict,
generate_previous_dict,
get_errors_for_csv,
get_letter_validation_error,
get_template,
unicode_truncate,
user_has_permissions,
)
@@ -53,7 +66,11 @@ def uploads(service_id):
next_page = generate_next_dict('main.uploads', service_id, uploads.current_page)
if uploads.current_page == 1:
listed_uploads = current_service.scheduled_jobs + uploads
listed_uploads = (
current_service.contact_lists +
current_service.scheduled_jobs +
uploads
)
else:
listed_uploads = uploads
@@ -282,3 +299,179 @@ def send_uploaded_letter(service_id):
service_id=service_id,
notification_id=file_id,
))
@main.route("/services/<uuid:service_id>/upload-contact-list", methods=['GET', 'POST'])
@user_has_permissions('send_messages')
def upload_contact_list(service_id):
form = CsvUploadForm()
if form.validate_on_submit():
try:
upload_id = ContactList.upload(
current_service.id,
Spreadsheet.from_file_form(form).as_dict,
)
return redirect(url_for(
'.check_contact_list',
service_id=service_id,
upload_id=upload_id,
original_file_name=form.file.data.filename,
))
except (UnicodeDecodeError, BadZipFile, XLRDError):
flash('Could not read {}. Try using a different file format.'.format(
form.file.data.filename
))
except (XLDateError):
flash((
'{} contains numbers or dates that Notify cannot understand. '
'Try formatting all columns as text or export your file as CSV.'
).format(
form.file.data.filename
))
return render_template(
'views/uploads/contact-list/upload.html',
form=form,
)
@main.route(
"/services/<uuid:service_id>/check-contact-list/<uuid:upload_id>",
methods=['GET', 'POST'],
)
@user_has_permissions('send_messages')
def check_contact_list(service_id, upload_id):
form = CsvUploadForm()
contents = ContactList.download(service_id, upload_id)
first_row = contents.splitlines()[0].strip().rstrip(',') if contents else ''
template_type = {
'emailaddress': 'email',
'phonenumber': 'sms',
}.get(Columns.make_key(first_row))
original_file_name = SanitiseASCII.encode(request.args.get('original_file_name', ''))
recipients = RecipientCSV(
contents,
template_type=template_type or 'sms',
whitelist=itertools.chain.from_iterable(
[user.name, user.mobile_number, user.email_address]
for user in current_service.active_users
) if current_service.trial_mode else None,
international_sms=current_service.has_permission('international_sms'),
max_initial_rows_shown=50,
max_errors_shown=50,
)
non_empty_column_headers = list(filter(None, recipients.column_headers))
if len(non_empty_column_headers) > 1 or not template_type or not recipients:
return render_template(
'views/uploads/contact-list/too-many-columns.html',
recipients=recipients,
original_file_name=original_file_name,
template_type=template_type,
form=form,
)
if recipients.too_many_rows or not len(recipients):
return render_template(
'views/uploads/contact-list/column-errors.html',
recipients=recipients,
original_file_name=original_file_name,
form=form,
)
row_errors = get_errors_for_csv(recipients, template_type)
if row_errors:
return render_template(
'views/uploads/contact-list/row-errors.html',
recipients=recipients,
original_file_name=original_file_name,
row_errors=row_errors,
form=form,
)
if recipients.has_errors:
return render_template(
'views/uploads/contact-list/column-errors.html',
recipients=recipients,
original_file_name=original_file_name,
form=form,
)
metadata_kwargs = {
'row_count': len(recipients),
'valid': True,
'original_file_name': unicode_truncate(
original_file_name,
1600,
),
'template_type': template_type
}
ContactList.set_metadata(service_id, upload_id, **metadata_kwargs)
return render_template(
'views/uploads/contact-list/ok.html',
recipients=recipients,
original_file_name=original_file_name,
upload_id=upload_id,
)
@main.route("/services/<uuid:service_id>/save-contact-list/<uuid:upload_id>", methods=['POST'])
@user_has_permissions('send_messages')
def save_contact_list(service_id, upload_id):
ContactList.create(current_service.id, upload_id)
return redirect(url_for(
'.uploads',
service_id=current_service.id,
))
@main.route("/services/<uuid:service_id>/contact-list/<uuid:contact_list_id>", methods=['GET'])
@user_has_permissions('send_messages')
def contact_list(service_id, contact_list_id):
return render_template(
'views/uploads/contact-list/contact-list.html',
contact_list=ContactList.from_id(contact_list_id, service_id=service_id),
)
@main.route("/services/<uuid:service_id>/contact-list/<uuid:contact_list_id>/delete", methods=['GET', 'POST'])
@user_has_permissions('manage_templates')
def delete_contact_list(service_id, contact_list_id):
contact_list = ContactList.from_id(contact_list_id, service_id=service_id)
if request.method == 'POST':
contact_list.delete()
return redirect(url_for(
'.uploads',
service_id=service_id,
))
flash([
f"Are you sure you want to delete {contact_list.original_file_name}?",
], 'delete')
return render_template(
'views/uploads/contact-list/contact-list.html',
contact_list=contact_list,
confirm_delete_banner=True,
)
@main.route("/services/<uuid:service_id>/contact-list/<uuid:contact_list_id>.csv", methods=['GET'])
@user_has_permissions('send_messages')
def download_contact_list(service_id, contact_list_id):
contact_list = ContactList.from_id(contact_list_id, service_id=service_id)
return send_file(
filename_or_fp=BytesIO(contact_list.contents.encode('utf-8')),
attachment_filename=contact_list.saved_file_name,
as_attachment=True,
)
+156
View File
@@ -0,0 +1,156 @@
from functools import partial
from os import path
from flask import abort, current_app
from notifications_utils.formatters import strip_whitespace
from notifications_utils.recipients import RecipientCSV
from werkzeug.utils import cached_property
from app.models import JSONModel, ModelList
from app.notify_client.contact_list_api_client import contact_list_api_client
from app.s3_client.s3_csv_client import (
get_csv_metadata,
s3download,
s3upload,
set_metadata_on_csv_upload,
)
class ContactList(JSONModel):
ALLOWED_PROPERTIES = {
'id',
'created_at',
'created_by',
'service_id',
'original_file_name',
'row_count',
'template_type',
}
upload_type = 'contact_list'
@classmethod
def from_id(cls, contact_list_id, *, service_id):
return cls(contact_list_api_client.get_contact_list(
service_id=service_id,
contact_list_id=contact_list_id,
))
@staticmethod
def get_bucket_name():
return current_app.config['CONTACT_LIST_UPLOAD_BUCKET_NAME']
@staticmethod
def upload(service_id, file_dict):
return s3upload(
service_id,
file_dict,
current_app.config['AWS_REGION'],
bucket=ContactList.get_bucket_name(),
)
@staticmethod
def download(service_id, upload_id):
return strip_whitespace(s3download(
service_id,
upload_id,
bucket=ContactList.get_bucket_name(),
))
@staticmethod
def set_metadata(service_id, upload_id, **kwargs):
return set_metadata_on_csv_upload(
service_id,
upload_id,
bucket=ContactList.get_bucket_name(),
**kwargs,
)
@staticmethod
def get_metadata(service_id, upload_id):
return get_csv_metadata(
service_id,
upload_id,
bucket=ContactList.get_bucket_name(),
)
def copy_to_uploads(self):
metadata = self.get_metadata(self.service_id, self.id)
new_upload_id = s3upload(
self.service_id,
{'data': self.contents},
current_app.config['AWS_REGION'],
)
set_metadata_on_csv_upload(
self.service_id,
new_upload_id,
**metadata,
)
return new_upload_id
@classmethod
def create(cls, service_id, upload_id):
metadata = cls.get_metadata(service_id, upload_id)
if not metadata.get('valid'):
abort(403)
return cls(contact_list_api_client.create_contact_list(
service_id=service_id,
upload_id=upload_id,
original_file_name=metadata['original_file_name'],
row_count=int(metadata['row_count']),
template_type=metadata['template_type'],
))
def delete(self):
contact_list_api_client.delete_contact_list(
service_id=self.service_id,
contact_list_id=self.id,
)
@property
def contents(self):
return self.download(self.service_id, self.id)
@cached_property
def recipients(self):
return RecipientCSV(
self.contents,
template_type=self.template_type,
international_sms=True,
max_initial_rows_shown=50,
)
@property
def saved_file_name(self):
file_name, extention = path.splitext(self.original_file_name)
return f'{file_name}.csv'
class ContactLists(ModelList):
client_method = contact_list_api_client.get_contact_lists
model = ContactList
sort_function = partial(
sorted,
key=lambda item: item['created_at'],
reverse=True,
)
def __init__(self, service_id, template_type=None):
super().__init__(service_id)
self.items = self.sort_function([
item for item in self.items
if template_type in {item['template_type'], None}
])
class ContactListsAlphabetical(ContactLists):
sort_function = partial(
sorted,
key=lambda item: item['original_file_name'].lower(),
)
+3
View File
@@ -0,0 +1,3 @@
QUESTION_TICKET_TYPE = 'ask-question-give-feedback'
PROBLEM_TICKET_TYPE = 'report-problem'
GENERAL_TICKET_TYPE = 'general'
+17 -25
View File
@@ -1,11 +1,8 @@
from datetime import datetime, timedelta
from dateutil.parser import parse
from flask import abort, current_app
from notifications_utils.timezones import local_timezone
from werkzeug.utils import cached_property
from app.models import JSONModel
from app.models.contact_list import ContactLists
from app.models.job import (
ImmediateJobs,
PaginatedJobs,
@@ -497,10 +494,6 @@ class Service(JSONModel):
key=lambda folder: folder['name'].lower(),
)
@property
def can_upload_letters(self):
return self.has_permission('letter') and self.has_permission('upload_letters')
@cached_property
def all_template_folder_ids(self):
return {folder['id'] for folder in self.all_template_folders}
@@ -671,27 +664,26 @@ class Service(JSONModel):
if test:
yield BASE + '_incomplete' + tag
@cached_property
def returned_letter_statistics(self):
return service_api_client.get_returned_letter_statistics(self.id)
@cached_property
def returned_letter_summary(self):
return service_api_client.get_returned_letter_summary(self.id)
@property
def most_recent_returned_letter_report(self):
if not self.returned_letter_summary:
return None
return parse(
self.returned_letter_summary[0]['reported_at'] + " 00:00:00"
).replace(tzinfo=local_timezone)
def count_of_returned_letters_in_last_7_days(self):
return self.returned_letter_statistics['returned_letter_count']
@property
def count_of_returned_letters_in_last_7_days(self):
seven_days_ago = (
datetime.now() - timedelta(days=7)
).replace(
hour=0, minute=0, second=0
)
return sum(
report['returned_letter_count']
for report in self.returned_letter_summary
if parse(report['reported_at'] + " 00:00:00") >= seven_days_ago
)
def date_of_most_recent_returned_letter_report(self):
return self.returned_letter_statistics['most_recent_report']
@property
def has_returned_letters(self):
return bool(self.date_of_most_recent_returned_letter_report)
@property
def contact_lists(self):
return ContactLists(self.id)
+48
View File
@@ -47,6 +47,7 @@ class HeaderNavigation(Navigation):
'bat_phone',
'feedback',
'support',
'support_public',
'thanks',
'triage',
},
@@ -101,6 +102,7 @@ class HeaderNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
'platform_admin_splash_page',
'suspend_service',
'trial_services',
'update_email_branding',
@@ -149,6 +151,7 @@ class HeaderNavigation(Navigation):
'check_notification',
'no_cookie.check_notification_preview',
'choose_account',
'choose_from_contact_list',
'choose_service',
'choose_template',
'choose_template_to_copy',
@@ -169,6 +172,7 @@ class HeaderNavigation(Navigation):
'delivery_and_failure',
'delivery_status_callback',
'design_content',
'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -255,10 +259,12 @@ class HeaderNavigation(Navigation):
'send_messages',
'send_notification',
'send_one_off',
'send_one_off_letter_address',
'send_one_off_step',
'send_test',
'no_cookie.send_test_preview',
'send_test_step',
'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -317,6 +323,11 @@ class HeaderNavigation(Navigation):
'template_history',
'template_usage',
'trial_mode',
'upload_contact_list',
'check_contact_list',
'save_contact_list',
'contact_list',
'delete_contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -337,6 +348,7 @@ class HeaderNavigation(Navigation):
'no_cookie.view_template_version_preview',
'view_template_versions',
'whitelist',
'who_its_for',
}
# header HTML now comes from GOVUK Frontend so requires a boolean, not an attribute
@@ -363,6 +375,7 @@ class MainNavigation(Navigation):
'add_service_template',
'check_messages',
'check_notification',
'choose_from_contact_list',
'choose_template',
'choose_template_to_copy',
'confirm_redact_template',
@@ -374,6 +387,7 @@ class MainNavigation(Navigation):
'manage_template_folder',
'send_messages',
'send_one_off',
'send_one_off_letter_address',
'send_one_off_step',
'send_test',
'no_cookie.send_test_preview',
@@ -385,6 +399,11 @@ class MainNavigation(Navigation):
'view_template_versions',
},
'uploads': {
'upload_contact_list',
'check_contact_list',
'save_contact_list',
'contact_list',
'delete_contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -495,6 +514,7 @@ class MainNavigation(Navigation):
'delivery_and_failure',
'design_content',
'documentation',
'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -567,6 +587,7 @@ class MainNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -586,6 +607,7 @@ class MainNavigation(Navigation):
'robots',
'security',
'send_notification',
'send_from_contact_list',
'send_uploaded_letter',
'service_dashboard_updates',
'service_delete_email_reply_to',
@@ -603,6 +625,7 @@ class MainNavigation(Navigation):
'start_tour',
'styleguide',
'support',
'support_public',
'suspend_service',
'template_history',
'terms',
@@ -641,6 +664,7 @@ class MainNavigation(Navigation):
'view_provider',
'view_providers',
'no_cookie.view_template_version_preview',
'who_its_for',
}
@@ -648,8 +672,10 @@ class CaseworkNavigation(Navigation):
mapping = {
'send-one-off': {
'choose_from_contact_list',
'choose_template',
'send_one_off',
'send_one_off_letter_address',
'send_one_off_step',
'send_test',
'send_test_step',
@@ -661,6 +687,11 @@ class CaseworkNavigation(Navigation):
'uploads': {
'view_jobs',
'view_job',
'upload_contact_list',
'check_contact_list',
'save_contact_list',
'contact_list',
'delete_contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -727,6 +758,7 @@ class CaseworkNavigation(Navigation):
'delivery_status_callback',
'design_content',
'documentation',
'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_organisation_agreement',
@@ -808,6 +840,7 @@ class CaseworkNavigation(Navigation):
'platform_admin_reports',
'platform_admin_returned_letters',
'platform_admin',
'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -835,6 +868,7 @@ class CaseworkNavigation(Navigation):
'send_messages',
'send_notification',
'no_cookie.send_test_preview',
'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -892,6 +926,7 @@ class CaseworkNavigation(Navigation):
'styleguide',
'submit_request_to_go_live',
'support',
'support_public',
'suspend_service',
'template_history',
'template_usage',
@@ -936,6 +971,7 @@ class CaseworkNavigation(Navigation):
'no_cookie.view_template_version_preview',
'view_template_versions',
'whitelist',
'who_its_for',
}
@@ -1003,6 +1039,7 @@ class OrgNavigation(Navigation):
'check_notification',
'no_cookie.check_notification_preview',
'choose_account',
'choose_from_contact_list',
'choose_service',
'choose_template',
'choose_template_to_copy',
@@ -1026,6 +1063,7 @@ class OrgNavigation(Navigation):
'delivery_status_callback',
'design_content',
'documentation',
'download_contact_list',
'download_notifications_csv',
'edit_data_retention',
'edit_provider',
@@ -1094,6 +1132,7 @@ class OrgNavigation(Navigation):
'platform_admin_list_complaints',
'platform_admin_reports',
'platform_admin_returned_letters',
'platform_admin_splash_page',
'pricing',
'privacy',
'public_agreement',
@@ -1120,10 +1159,12 @@ class OrgNavigation(Navigation):
'send_messages',
'send_notification',
'send_one_off',
'send_one_off_letter_address',
'send_one_off_step',
'send_test',
'no_cookie.send_test_preview',
'send_test_step',
'send_from_contact_list',
'send_uploaded_letter',
'service_add_email_reply_to',
'service_add_letter_contact',
@@ -1181,6 +1222,7 @@ class OrgNavigation(Navigation):
'styleguide',
'submit_request_to_go_live',
'support',
'support_public',
'suspend_service',
'template_history',
'template_usage',
@@ -1195,6 +1237,11 @@ class OrgNavigation(Navigation):
'two_factor_email_sent',
'update_email_branding',
'update_letter_branding',
'upload_contact_list',
'check_contact_list',
'save_contact_list',
'contact_list',
'delete_contact_list',
'upload_letter',
'uploaded_letter_preview',
'uploads',
@@ -1232,4 +1279,5 @@ class OrgNavigation(Navigation):
'no_cookie.view_template_version_preview',
'view_template_versions',
'whitelist',
'who_its_for',
}
@@ -0,0 +1,37 @@
from app.notify_client import NotifyAdminAPIClient, _attach_current_user
class ContactListApiClient(NotifyAdminAPIClient):
def create_contact_list(
self,
*,
service_id,
upload_id,
original_file_name,
row_count,
template_type,
):
data = {
"id": upload_id,
"original_file_name": original_file_name,
"row_count": row_count,
"template_type": template_type,
}
data = _attach_current_user(data)
job = self.post(url='/service/{}/contact-list'.format(service_id), data=data)
return job
def get_contact_lists(self, service_id):
return self.get(f'/service/{service_id}/contact-list')
def get_contact_list(self, *, service_id, contact_list_id):
return self.get(f'/service/{service_id}/contact-list/{contact_list_id}')
def delete_contact_list(self, *, service_id, contact_list_id):
return self.delete(f'/service/{service_id}/contact-list/{contact_list_id}')
contact_list_api_client = ContactListApiClient()
+4 -1
View File
@@ -78,12 +78,15 @@ class JobApiClient(NotifyAdminAPIClient):
def has_jobs(self, service_id):
return bool(self.get_jobs(service_id)['data'])
def create_job(self, job_id, service_id, scheduled_for=None):
def create_job(self, job_id, service_id, scheduled_for=None, contact_list_id=None):
data = {"id": job_id}
if scheduled_for:
data.update({'scheduled_for': scheduled_for})
if contact_list_id:
data.update({'contact_list_id': contact_list_id})
data = _attach_current_user(data)
job = self.post(url='/service/{}/job'.format(service_id), data=data)
+4
View File
@@ -569,6 +569,10 @@ class ServiceAPIClient(NotifyAdminAPIClient):
def get_service_data_retention(self, service_id):
return self.get("/service/{}/data-retention".format(service_id))
@cache.set('service-{service_id}-returned-letters-statistics')
def get_returned_letter_statistics(self, service_id):
return self.get("service/{}/returned-letter-statistics".format(service_id))
@cache.set('service-{service_id}-returned-letters-summary')
def get_returned_letter_summary(self, service_id):
return self.get("service/{}/returned-letter-summary".format(service_id))
+30 -11
View File
@@ -9,20 +9,20 @@ from app.s3_client.s3_logo_client import get_s3_object
FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv'
def get_csv_location(service_id, upload_id):
def get_csv_location(service_id, upload_id, bucket=None):
return (
current_app.config['CSV_UPLOAD_BUCKET_NAME'],
bucket or current_app.config['CSV_UPLOAD_BUCKET_NAME'],
FILE_LOCATION_STRUCTURE.format(service_id, upload_id),
)
def get_csv_upload(service_id, upload_id):
return get_s3_object(*get_csv_location(service_id, upload_id))
def get_csv_upload(service_id, upload_id, bucket=None):
return get_s3_object(*get_csv_location(service_id, upload_id, bucket))
def s3upload(service_id, filedata, region):
def s3upload(service_id, filedata, region, bucket=None):
upload_id = str(uuid.uuid4())
bucket_name, file_location = get_csv_location(service_id, upload_id)
bucket_name, file_location = get_csv_location(service_id, upload_id, bucket)
utils_s3upload(
filedata=filedata['data'],
region=region,
@@ -32,10 +32,10 @@ def s3upload(service_id, filedata, region):
return upload_id
def s3download(service_id, upload_id):
def s3download(service_id, upload_id, bucket=None):
contents = ''
try:
key = get_csv_upload(service_id, upload_id)
key = get_csv_upload(service_id, upload_id, bucket)
contents = key.get()['Body'].read().decode('utf-8')
except botocore.exceptions.ClientError as e:
current_app.logger.error("Unable to download s3 file {}".format(
@@ -44,14 +44,33 @@ def s3download(service_id, upload_id):
return contents
def set_metadata_on_csv_upload(service_id, upload_id, **kwargs):
def set_metadata_on_csv_upload(service_id, upload_id, bucket=None, **kwargs):
get_csv_upload(
service_id, upload_id
service_id, upload_id, bucket=bucket
).copy_from(
CopySource='{}/{}'.format(*get_csv_location(service_id, upload_id)),
CopySource='{}/{}'.format(*get_csv_location(service_id, upload_id, bucket=bucket)),
ServerSideEncryption='AES256',
Metadata={
key: str(value) for key, value in kwargs.items()
},
MetadataDirective='REPLACE',
)
def set_metadata_on_contact_list(service_id, upload_id, **kwargs):
return set_metadata_on_csv_upload(
service_id,
upload_id,
bucket=current_app.config['CONTACT_LIST_UPLOAD_BUCKET_NAME'],
**kwargs,
)
def get_csv_metadata(service_id, upload_id, bucket=None):
try:
key = get_csv_upload(service_id, upload_id, bucket)
return key.get()['Metadata']
except botocore.exceptions.ClientError as e:
current_app.logger.error("Unable to download s3 file {}".format(
FILE_LOCATION_STRUCTURE.format(service_id, upload_id)))
raise e
+5 -1
View File
@@ -55,7 +55,7 @@
"active": header_navigation.is_selected('user-profile')
},
{
"href": url_for('main.platform_admin'),
"href": url_for('main.platform_admin_splash_page'),
"text": "Platform admin",
"active": header_navigation.is_selected('platform-admin')
},
@@ -217,6 +217,10 @@
"href": url_for("main.get_started"),
"text": "Get started"
},
{
"href": url_for("main.who_its_for"),
"text": "Who its for",
},
{
"href": url_for("main.trial_mode_new"),
"text": "Trial mode"
+1 -1
View File
@@ -1,4 +1,4 @@
{% macro ajax_block(partials, url, key, interval=2, finished=False, form='') %}
{% macro ajax_block(partials, url, key, interval=5, finished=False, form='') %}
{% if not finished %}
<div
data-module="update-content"
+2 -6
View File
@@ -8,14 +8,10 @@
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('dashboard') }}" href="{{ url_for('.service_dashboard', service_id=current_service.id) }}">Dashboard</a></li>
{% endif %}
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('templates') }}" href="{{ url_for('.choose_template', service_id=current_service.id) }}">Templates</a></li>
{% if current_user.has_permissions('view_activity') %}
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('uploads') }}" href="{{ url_for('main.uploads', service_id=current_service.id) }}">Uploads</a></li>
{% else %}
{% if not current_user.has_permissions('view_activity') %}
<li><a class="govuk-link govuk-link--no-visited-state{{ casework_navigation.is_selected('sent-messages') }}" href="{{ url_for('.view_notifications', service_id=current_service.id, status='sending,delivered,failed') }}">Sent messages</a></li>
{% if current_service.has_jobs or current_service.can_upload_letters %}
<li><a class="govuk-link govuk-link--no-visited-state{{ casework_navigation.is_selected('uploads') }}" href="{{ url_for('main.uploads', service_id=current_service.id) }}">Uploads</a></li>
{% endif %}
{% endif %}
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('uploads') }}" href="{{ url_for('main.uploads', service_id=current_service.id) }}">Uploads</a></li>
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('team-members') }}" href="{{ url_for('.manage_users', service_id=current_service.id) }}">Team members</a></li>
{% if current_user.has_permissions('manage_service', allow_org_user=True) %}
<li><a class="govuk-link govuk-link--no-visited-state{{ main_navigation.is_selected('usage') }}" href="{{ url_for('.usage', service_id=current_service.id) }}">Usage</a></li>
+1
View File
@@ -37,6 +37,7 @@
<form method="post" enctype="multipart/form-data" action="{{url_for('main.start_job', service_id=current_service.id, upload_id=upload_id, original_file_name=original_file_name)}}" class='page-footer'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="help" value="{{ '3' if help else 0 }}" />
<input type="hidden" name="contact_list_id" value="{{ request.args.get('contact_list_id', '') }}" />
{% if choose_time_form and template.template_type != 'letter' %}
{{ radio_select(
choose_time_form.scheduled_for,
+2 -2
View File
@@ -17,7 +17,7 @@
{% endif %}
</a>
{% endif %}
{% if current_service.returned_letter_summary %}
{% if current_service.has_returned_letters %}
<a id="total-returned-letters" class="govuk-link govuk-link--no-visited-state banner-dashboard" href="{{ url_for('main.returned_letter_summary', service_id=current_service.id) }}">
<span class="banner-dashboard-count">
{{ current_service.count_of_returned_letters_in_last_7_days|format_thousands }}
@@ -26,7 +26,7 @@
returned {{ message_count_label(current_service.count_of_returned_letters_in_last_7_days, 'letter', suffix='') }}
</span>
<span class="banner-dashboard-meta">
latest report {{ current_service.most_recent_returned_letter_report|format_delta_days }}
latest report {{ current_service.date_of_most_recent_returned_letter_report|format_delta_days }}
</span>
</a>
{% endif %}
+21 -4
View File
@@ -1,6 +1,6 @@
{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading %}
{% from "components/big-number.html" import big_number -%}
{% from "components/message-count-label.html" import message_count_label -%}
{% from "components/message-count-label.html" import message_count_label, recipient_count_label -%}
<div class='dashboard-table ajax-block-container'>
{% call(item, row_number) list_table(
@@ -20,17 +20,25 @@
<div class="file-list">
{% if item.upload_type == 'letter' %}
<a class="file-list-filename-large govuk-link govuk-link--no-visited-state" href="{{ url_for('.view_notification', service_id=current_service.id, notification_id=item.id) }}">{{ item.original_file_name }}</a>
{% elif item.upload_type == 'contact_list' %}
<a class="file-list-filename-large govuk-link govuk-link--no-visited-state" href="{{ url_for('.contact_list', service_id=current_service.id, contact_list_id=item.id) }}">{{ item.original_file_name }}</a>
{% else %}
<a class="file-list-filename-large govuk-link govuk-link--no-visited-state" href="{{ url_for('.view_job', service_id=current_service.id, job_id=item.id) }}">{{ item.original_file_name }}</a>
{% endif %}
{% if item.scheduled %}
<span class="file-list-hint">
<span class="file-list-hint-large">
Sending {{
item.scheduled_for|format_datetime_relative
}}
</span>
{% elif item.upload_type == 'contact_list' %}
<span class="file-list-hint-large">
Uploaded {{
item.created_at|format_datetime_relative
}}
</span>
{% else %}
<span class="file-list-hint">
<span class="file-list-hint-large">
Sent {{
(item.scheduled_for or item.created_at)|format_datetime_relative
}}
@@ -60,8 +68,17 @@
suffix=''
)
) }}
{% elif item.upload_type == 'contact_list' %}
{{ big_number(
item.row_count,
smallest=True,
label="saved {}".format(recipient_count_label(
item.row_count,
item.template_type
))
) }}
{% elif item.pdf_letter %}
<p class="govuk-body govuk-!-margin-bottom-1">
<p class="govuk-body letter-recipient-summary">
{% for line in item.recipient.splitlines() %}
{% if loop.index < 3 %}
{{ line }}<br>
+5 -13
View File
@@ -19,33 +19,25 @@
{% include 'views/dashboard/write-first-messages.html' %}
{% endif %}
{{ ajax_block(partials, updates_url, 'upcoming', interval=5) }}
{{ ajax_block(partials, updates_url, 'upcoming', interval=20) }}
<h2 class="heading-medium">
In the last 7 days
</h2>
{{ ajax_block(partials, updates_url, 'inbox', interval=5) }}
{{ ajax_block(partials, updates_url, 'inbox', interval=20) }}
{{ ajax_block(partials, updates_url, 'totals', interval=5) }}
{{ ajax_block(partials, updates_url, 'totals', interval=20) }}
{{ show_more(
url_for('.monthly', service_id=current_service.id),
'See messages sent per month'
) }}
{{ ajax_block(partials, updates_url, 'template-statistics', interval=5) }}
{% if current_service.immediate_jobs and not current_service.has_permission('upload_letters') %}
{{ ajax_block(partials, updates_url, 'jobs', interval=5) }}
{{ show_more(
url_for('.view_jobs', service_id=current_service.id),
'See all uploaded files'
) }}
{% endif %}
{{ ajax_block(partials, updates_url, 'template-statistics', interval=20) }}
{% if current_user.has_permissions('manage_service') %}
<h2 class='heading-medium'>This year</h2>
{{ ajax_block(partials, updates_url, 'usage') }}
{{ ajax_block(partials, updates_url, 'usage', interval=20) }}
{{ show_more(
url_for(".usage", service_id=current_service['id']),
'See usage'
+3 -15
View File
@@ -15,21 +15,9 @@
<li class="get-started-list__item">
<h2 class="get-started-list__heading">Check if GOV.UK Notify is right for you</h2>
<p>Read about our <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.features') }}">features</a>, <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.pricing') }}">pricing</a> and <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.roadmap') }}">roadmap</a>.</p>
{{ govukDetails({
"summaryText": "Organisations that can use Notify",
"html": '''
<div id="eligible-organisations">
<p>Notify is available to:</p>
<ul class="list list-bullet">
<li>central government departments</li>
<li>local authorities</li>
<li>state-funded schools</li>
<li>the NHS</li>
<li>companies running a service on behalf of a public sector organisation</li>
</ul>
<p>Notify is not currently available to charities.</p>
</div>'''
}) }}
<p class="govuk-body">
Check <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.who_its_for') }}">whether your organisation can use Notify</a>.
</p>
</li>
<li class="get-started-list__item">
@@ -0,0 +1,49 @@
{% extends "content_template.html" %}
{% from "components/page-header.html" import page_header %}
{% block per_page_title %}
Who its for
{% endblock %}
{% block content_column_content %}
{{ page_header(
'Who its for'
) }}
<p class="govuk-body">
GOV.UK Notify is available to:
</p>
<ul class="list list-bullet">
<li>central government departments</li>
<li>emergency services</li>
<li>local authorities</li>
<li>the armed forces</li>
<li>the NHS and GP practices</li>
<li>state-funded schools</li>
</ul>
<p class="govuk-body">
Notify is not currently available to charities.
</p>
<p class="govuk-body">
If you work for one of these organisations but get an error when you try to create an account, <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.support') }}">contact support</a>.
</p>
<h2 class="govuk-heading-m">Suppliers</h2>
<p class="govuk-body">
If youre doing work for a public sector organisation you can use GOV.UK&nbsp;Notify.
</p>
<p class="govuk-body">
Someone from the public sector organisation youre working with needs to set up the account. Then they can invite you as a team member.
</p>
<h2 class="govuk-heading-m">Members of the public</h2>
<p class="govuk-body">
The GOV.UK Notify service is only for people who work in the government
or other public sector organisations.
</p>
<p class="govuk-body">
<a class="govuk-link govuk-link--no-visited-state" href="https://www.gov.uk">Find government services and information on GOV.UK</a>.
</p>
{% endblock %}
+19 -15
View File
@@ -9,34 +9,38 @@
{% block maincolumn_content %}
<h1 class="heading-medium">Uploads</h1>
<div class="dashboard">
{% if show_scheduled_jobs %}
{% with hide_heading = True %}
{% include 'views/jobs/_scheduled.html' %}
{% endwith %}
{% endif %}
{% if jobs %}
{% include 'views/dashboard/_jobs.html' %}
{% endif %}
{% if not jobs and not show_scheduled_jobs %}
{% else %}
<p class="govuk-body">
You have not uploaded any files recently.
</p>
{% if current_service.has_permission('upload_letters') %}
{% if current_user.has_permissions('send_messages') %}
{% if current_service.has_permission('letter') %}
<p class="govuk-body">
Upload a letter and Notify will print, pack and post it for you.
</p>
{% endif %}
<p class="govuk-body">
Upload a letter and Notify will print, pack and post it for you.
To upload a list of contact details, first <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.choose_template', service_id=current_service.id) }}">choose a template</a>.
</p>
{% endif %}
<p class="govuk-body">
To upload a list of contact details, first <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.choose_template', service_id=current_service.id) }}">choose a template</a>.
</p>
{% endif %}
{{ previous_next_navigation(prev_page, next_page) }}
{% if current_service.can_upload_letters and current_user.has_permissions('send_messages') %}
{% if current_user.has_permissions('send_messages') %}
<div class="js-stick-at-bottom-when-scrolling">
{% if current_service.has_permission('letter') %}
{{ govukButton({
"element": "a",
"text": "Upload a letter",
"href": url_for('.upload_letter', service_id=current_service.id),
"classes": "govuk-button--secondary govuk-!-margin-right-3"
}) }}
{% endif %}
{{ govukButton({
"element": "a",
"text": "Upload a letter",
"href": url_for('.upload_letter', service_id=current_service.id),
"text": "Upload an emergency contact list",
"href": url_for('.upload_contact_list', service_id=current_service.id),
"classes": "govuk-button--secondary"
}) }}
</div>
+4 -2
View File
@@ -31,7 +31,8 @@
{{ ajax_block(
partials,
url_for('.get_notifications_as_json', service_id=current_service.id, message_type=message_type, status=status),
'counts'
'counts',
interval=20
) }}
{% call form_wrapper(
@@ -82,7 +83,8 @@
partials,
url_for('.get_notifications_as_json', service_id=current_service.id, message_type=message_type, status=status, page=page),
'notifications',
form='search-form'
form='search-form',
interval=20
) }}
{% endblock %}
@@ -96,10 +96,10 @@
</div>
{% elif template.template_type == 'email' %}
<div class="js-stick-at-bottom-when-scrolling">
{{ ajax_block(partials, updates_url, 'status', finished=finished) }}
{{ ajax_block(partials, updates_url, 'status', interval=2, finished=finished) }}
</div>
{% elif template.template_type == 'sms' %}
{{ ajax_block(partials, updates_url, 'status', finished=finished) }}
{{ ajax_block(partials, updates_url, 'status', interval=2, finished=finished) }}
{% endif %}
{% if current_user.has_permissions('send_messages') and current_user.has_permissions('view_activity') and template.template_type == 'sms' and can_receive_inbound %}
@@ -0,0 +1,17 @@
{% extends "views/platform-admin/_base_template.html" %}
{% block per_page_title %}
Summary
{% endblock %}
{% block platform_admin_content %}
<h1 class="heading-large">
Summary
</h1>
<p class="govuk-body">
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.platform_admin') }}">Load summary</a>
</p>
{% endblock %}
+1 -1
View File
@@ -14,7 +14,7 @@ Create an account
<h1 class="heading-large">Create an account</h1>
{% call form_wrapper(autocomplete=True) %}
{{ textbox(form.name, width='3-4') }}
{{ textbox(form.email_address, hint="Must be from a government organisation", width='3-4', safe_error_message=True, autocomplete='email') }}
{{ textbox(form.email_address, hint="Must be from a public sector organisation", width='3-4', safe_error_message=True, autocomplete='email') }}
<div class="extra-tracking">
{{ textbox(form.mobile_number, width='3-4', hint='Well send you a security code by text message') }}
</div>
@@ -0,0 +1,80 @@
{% extends "withnav_template.html" %}
{% from "components/big-number.html" import big_number -%}
{% from "components/list.html" import list_of_placeholders %}
{% from "components/message-count-label.html" import recipient_count_label %}
{% from "components/page-header.html" import page_header %}
{% from "components/table.html" import list_table, field, right_aligned_field_heading, row_heading %}
{% block service_page_title %}
Choose a saved contact list
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
'Choose a saved contact list',
back_link=url_for('.send_one_off', service_id=current_service.id, template_id=template.id)
) }}
{% if template.placeholders %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-five-sixths">
<p class="govuk-body">
You cannot use a saved contact list with this template because it
is personalised with {{ list_of_placeholders(template.placeholders) }}.
</p>
<p>
Saved contact lists can only store email addresses or phone
numbers.
</p>
</div>
</div>
{% elif contact_lists %}
<div class='dashboard-table ajax-block-container'>
{% call(item, row_number) list_table(
contact_lists,
caption="Existing contact lists",
caption_visible=False,
empty_message=(
'You dont have any contact lists yet'
),
field_headings=[
'File',
'Status'
],
field_headings_visible=False
) %}
{% call row_heading() %}
<div class="file-list">
<a class="file-list-filename-large govuk-link govuk-link--no-visited-state" href="{{ url_for('main.send_from_contact_list', service_id=current_service.id, template_id=template.id, contact_list_id=item.id) }}">{{ item.original_file_name }}</a>
<span class="file-list-hint-large">
Uploaded {{ item.created_at|format_datetime_relative }}
</span>
</div>
{% endcall %}
{% call field() %}
{{ big_number(
item.row_count,
smallest=True,
label=recipient_count_label(
item.row_count,
item.template_type
)
) }}
{% endcall %}
{% endcall %}
</div>
{% else %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-five-sixths">
<p class="govuk-body">
You have not saved any lists of {{ recipient_count_label(99, template.template_type) }} yet.
</p>
<p class="govuk-body">
To upload and save a new contact list, go to the <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.uploads', service_id=current_service.id) }}">uploads</a> page.
</p>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,45 @@
{% extends "withnav_template.html" %}
{% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/message-count-label.html" import recipient_count_label %}
{% from "components/textbox.html" import textbox %}
{% from "components/form.html" import form_wrapper %}
{% block service_page_title %}
{{ page_title }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
page_title,
back_link=back_link
) }}
{% call form_wrapper(
class='send-one-off-form',
module="autofocus",
data_kwargs={'force-focus': True}
) %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
{{ textbox(
form.address,
rows=4,
width='1-1',
autofocus=True,
autosize=True,
) }}
</div>
</div>
<p>
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.send_messages', service_id=current_service.id, template_id=template.id) }}">
Upload a list of {{ recipient_count_label(999, template.template_type) }}
</a>
</p>
{{ page_footer('Continue') }}
{% endcall %}
{{ template|string }}
{% endblock %}
+13 -7
View File
@@ -22,24 +22,30 @@
data_kwargs={'force-focus': True}
) %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
<div class="govuk-grid-column-full {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
{{ textbox(
form.placeholder_value,
hint='Optional' if optional_placeholder else None,
width='1-1',
) }}
</div>
{% if skip_link %}
<div class="govuk-grid-column-one-third">
<a href="{{ skip_link[1] }}" class="govuk-link govuk-link--no-visited-state top-gutter-4-3">{{ skip_link[0] }}</a>
{% if skip_link or link_to_upload %}
<div class="govuk-grid-column-full">
{% if link_to_upload %}
<a class="govuk-link govuk-link--no-visited-state govuk-!-margin-right-3" href="{{ url_for('.send_messages', service_id=current_service.id, template_id=template.id) }}">Upload a list of {{ recipient_count_label(999, template.template_type) }}</a>
{% if current_service.contact_lists %}
<a class="govuk-link govuk-link--no-visited-state govuk-!-margin-right-3" href="{{ url_for('.choose_from_contact_list', service_id=current_service.id, template_id=template.id) }}">Use a saved list</a>
{% endif %}
{% endif %}
{% if skip_link %}
<a href="{{ skip_link[1] }}" class="govuk-link govuk-link--no-visited-state govuk-!-margin-right-3">{{ skip_link[0] }}</a>
{% endif %}
</div>
{% endif %}
</div>
{% if link_to_upload %}
<p>
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.send_messages', service_id=current_service.id, template_id=template.id) }}">
Upload a list of {{ recipient_count_label(999, template.template_type) }}
</a>
</p>
{% endif %}
{{ page_footer('Continue') }}
@@ -1,34 +0,0 @@
{% extends "withoutnav_template.html" %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import sticky_page_footer %}
{% from "components/page-header.html" import page_header %}
{% from "components/form.html" import form_wrapper %}
{% block per_page_title %}
Ask a question or give feedback
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
'Ask a question or give feedback',
back_link=url_for('.support')
) }}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{% call form_wrapper() %}
{{ textbox(form.feedback, width='1-1', hint='', rows=10, autosize=True) }}
{% if not current_user.is_authenticated %}
<h3 class="heading-medium">Do you want a reply?</h3>
<p>Leave your details below if youd like a response.</p>
{{ textbox(form.name, width='1-1') }}
{{ textbox(form.email_address, width='1-1') }}
{% else %}
<p>Well reply to {{ current_user.email_address }}</p>
{% endif %}
{{ sticky_page_footer('Send') }}
{% endcall %}
</div>
</div>
{% endblock %}
+5 -6
View File
@@ -1,6 +1,7 @@
{% extends "withoutnav_template.html" %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/page-header.html" import page_header %}
{% block per_page_title %}
Out of hours emergencies
@@ -8,9 +9,10 @@
{% block maincolumn_content %}
<h1 class="heading-large">
Out of hours emergencies
</h1>
{{ page_header(
'Out of hours emergencies',
url_for('.support')
)}}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<p>
@@ -36,9 +38,6 @@
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('main.feedback', ticket_type='report-problem', severe='no') }}">Fill in this form</a>
and well get back to you by the next working day.
</p>
<p>
<a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.support') }}">Back to support</a>
</p>
</div>
</div>
@@ -6,23 +6,25 @@
{% from "components/form.html" import form_wrapper %}
{% block per_page_title %}
Report a problem
{{ page_title }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
'Report a problem',
back_link=url_for('.support')
page_title,
back_link=back_link
) }}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<div class="panel panel-border-wide">
<p>
Check our <a class="govuk-link govuk-link--no-visited-state" href="https://status.notifications.service.gov.uk">system status</a>
page to see if there are any known issues with GOV.UK Notify.
</p>
</div>
{% if show_status_page_banner %}
<div class="panel panel-border-wide">
<p>
Check our <a class="govuk-link govuk-link--no-visited-state" href="https://status.notifications.service.gov.uk">system status</a>
page to see if there are any known issues with GOV.UK Notify.
</p>
</div>
{% endif %}
{% call form_wrapper() %}
{{ textbox(form.feedback, width='1-1', hint='', rows=10, autosize=True) }}
{% if not current_user.is_authenticated %}
+17 -11
View File
@@ -9,19 +9,25 @@
{% block maincolumn_content %}
<h1 class="heading-large">Support</h1>
<p>We provide 24-hour online support for teams with a live service on GOV.UK&nbsp;Notify.</p>
{% call form_wrapper() %}
{% if current_user.is_authenticated %}
{{ radios(form.support_type) }}
{% else %}
<p class="govuk-body">
What do you need help with?
</p>
{{ radios(form.who, hide_legend=True) }}
{% endif %}
{{ page_footer('Continue') }}
{% endcall %}
<p>You can also <a class="govuk-link govuk-link--no-visited-state" href="https://ukgovernmentdigital.slack.com/messages/C0E1ADVPC">contact us on Slack</a>.</p>
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="heading-large">Support</h1>
<p>We provide 24-hour online support for teams with a live service on GOV.UK Notify.</p>
{% call form_wrapper(class="bottom-gutter-2") %}
{{ radios(form.support_type) }}
{{ page_footer('Continue') }}
{% endcall %}
<p>You can also <a class="govuk-link govuk-link--no-visited-state" href="https://ukgovernmentdigital.slack.com/messages/C0E1ADVPC">contact us on Slack</a>.</p>
<h2 class="heading-medium">Office hours</h2>
<p>Our office hours are 9:30am to 5:30pm, Monday to Friday.</p>
<p>When you report a problem in office hours, well aim to read it within 30 minutes and reply within one working day.</p>
+48
View File
@@ -0,0 +1,48 @@
{% extends "withoutnav_template.html" %}
{% from "components/page-header.html" import page_header %}
{% block per_page_title %}
The GOV.UK Notify team cant give advice to members of the public
{% endblock %}
{% block maincolumn_content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{{ page_header(
'The GOV.UK Notify service is for people who work in the government',
back_link=url_for('.support')
) }}
<p class="govuk-body">
We cant give advice to the public. We dont have access to information about you held by government departments.
</p>
<p class="govuk-body">
There are other pages on GOV.UK where you can get help:
</p>
<h2 class="govuk-heading-m govuk-!-margin-bottom-1">
<a class="govuk-link govuk-link--no-visited-state" href="https://www.gov.uk/coronavirus">Coronavirus (COVID-19)</a>
</h2>
<p class="govuk-body">
What you need to do
</p>
<h2 class="govuk-heading-m govuk-!-margin-bottom-1">
<a class="govuk-link govuk-link--no-visited-state" href="https://www.gov.uk/contact">Contact the government</a>
</h2>
<p class="govuk-body">
Ask about benefits, driving, transport, tax, and more
</p>
<h2 class="govuk-heading-m govuk-!-margin-bottom-1">
<a class="govuk-link govuk-link--no-visited-state" href="https://www.gov.uk/report-suspicious-emails-websites-phishing">Avoid and report internet scams and phishing</a>
</h2>
<p class="govuk-body">
Advice on suspicious emails and text messages
</p>
</div>
</div>
{% endblock %}
+2 -2
View File
@@ -5,7 +5,7 @@
{% from "components/form.html" import form_wrapper %}
{% block per_page_title %}
Feedback
{{ page_title }}
{% endblock %}
{% block maincolumn_content %}
@@ -13,7 +13,7 @@
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{{ page_header(
'Report a problem',
page_title,
back_link=url_for('.support')
) }}
{% call form_wrapper() %}
+5 -5
View File
@@ -16,11 +16,11 @@
<div class="bottom-gutter-2-3">
<div class="govuk-grid-row">
{% if template.template_type == 'letter' %}
{% if letter_too_long %}
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/letter-too-long.html" %}
{% endcall %}
{% endif %}
{% if letter_too_long %}
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/letter-too-long.html" %}
{% endcall %}
{% endif %}
{% if current_user.has_permissions('send_messages', restrict_admin_usage=True) and not letter_too_long %}
<div class="govuk-grid-column-one-half">
<a href="{{ url_for(".set_sender", service_id=current_service.id, template_id=template.id) }}" class="govuk-link govuk-link--no-visited-state pill-separate-item">
@@ -0,0 +1,91 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/radios.html" import radio_select %}
{% from "components/table.html" import list_table, field, text_field, index_field, hidden_field_heading %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/back-link/macro.njk" import govukBackLink %}
{% from "components/message-count-label.html" import message_count_label, recipient_count_label %}
{% set file_contents_header_id = 'file-preview' %}
{% macro skip_to_file_contents() %}
<p class="govuk-visually-hidden">
<a class="govuk-link govuk-link--no-visited-state" href="#{{ file_contents_header_id }}">Skip to file contents</a>
</p>
{% endmacro %}
{% block service_page_title %}
Error
{% endblock %}
{% block maincolumn_content %}
{{ govukBackLink({ "href": url_for('main.upload_contact_list', service_id=current_service.id) }) }}
<div class="bottom-gutter-1-2">
{% call banner_wrapper(type='dangerous') %}
{% if recipients.too_many_rows %}
<h1 class='banner-title' data-module="track-error" data-error-type="Too many rows" data-error-label="{{ upload_id }}">
Your file has too many rows
</h1>
<p>
Notify can store files up to
{{ "{:,}".format(recipients.max_rows) }} rows in size. Your
file has {{ "{:,}".format(recipients|length) }} rows.
</p>
{% elif not recipients.allowed_to_send_to %}
<h1 class='banner-title' data-module="track-error" data-error-type="Trial mode: bad recipients" data-error-label="{{ upload_id }}">
You cannot save
{{ 'this' if recipients|length == 1 else 'these' }}
{{ recipient_count_label(recipients|length, recipients.template_type) }}
</h1>
<p>
In <a class="govuk-link govuk-link--no-visited-state" href="{{ url_for('.trial_mode_new') }}">trial mode</a> you can only
send to yourself and members of your team
</p>
{% endif %}
{{ skip_to_file_contents() }}
{% endcall %}
</div>
<div class="js-stick-at-top-when-scrolling">
<div class="form-group">
{{ file_upload(
form.file,
action=url_for('.upload_contact_list', service_id=current_service.id),
button_text='Upload your file again'
) }}
</div>
<a href="#content" class="govuk-link govuk-link--no-visited-state back-to-top-link">Back to top</a>
</div>
<h2 class="heading-medium" id="{{ file_contents_header_id }}">{{ original_file_name }}</h2>
{% call(item, row_number) list_table(
recipients.displayed_rows,
caption=original_file_name,
caption_visible=False,
field_headings=[
'<span class="govuk-visually-hidden">Row in file</span> <span aria-hidden="true">1</span>'|safe
] + recipients.column_headers
) %}
{{ index_field(item.index + 2) }}
{% for column in recipients.column_headers %}
{{ text_field(item[column].data or '') }}
{% endfor %}
{% endcall %}
{% if recipients.displayed_rows|list|length < recipients|length %}
<p class="table-show-more-link">
Only showing the first {{ recipients.displayed_rows|list|length }} rows
</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/radios.html" import radio_select %}
{% from "components/table.html" import list_table, field, text_field, index_field, hidden_field_heading %}
{% from "components/page-header.html" import page_header %}
{% from "components/message-count-label.html" import message_count_label, recipient_count_label %}
{% from "components/button/macro.njk" import govukButton %}
{% block service_page_title %}
{{ contact_list.original_file_name }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
contact_list.original_file_name,
back_link=url_for('main.uploads', service_id=current_service.id)
) }}
<p class="govuk-body">
Uploaded by {{ contact_list.created_by }} {{ contact_list.created_at|format_datetime_human }}
</p>
<p class="govuk-body">
<a class="govuk-link govuk-link--no-visited-state heading-small" download href="{{ url_for('main.download_contact_list', service_id=current_service.id, contact_list_id=contact_list.id) }}">Download this list</a>&emsp;
{{ contact_list.recipients|length|format_thousands }}
{{ recipient_count_label(contact_list.recipients|length, contact_list.recipients.template_type) }}
</p>
{% set recipient_column = contact_list.recipients.column_headers[0] %}
{% call(item, row_number) list_table(
contact_list.recipients.displayed_rows,
caption=recipient_count_label(contact_list.recipients|length, contact_list.template_type)|capitalize,
caption_visible=False,
field_headings=['1', recipient_column],
) %}
{{ index_field(row_number) }}
{{ text_field(item[recipient_column].data) }}
{% endcall %}
{% if contact_list.recipients.displayed_rows|list|length < contact_list.recipients|length %}
<p class="table-show-more-link">
Only showing the first {{ contact_list.recipients.displayed_rows|list|length }} rows
</p>
{% endif %}
{% if not confirm_delete_banner %}
<div class="js-stick-at-bottom-when-scrolling">
<div class="page-footer">
<span class="page-footer-delete-link page-footer-delete-link-without-button">
<a class="govuk-link govuk-link--destructive" href="{{ url_for('main.delete_contact_list', service_id=current_service.id, contact_list_id=contact_list.id) }}">Delete this contact list</a>
</span>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,70 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/radios.html" import radio_select %}
{% from "components/table.html" import list_table, field, text_field, index_field, hidden_field_heading %}
{% from "components/page-header.html" import page_header %}
{% from "components/message-count-label.html" import message_count_label, recipient_count_label %}
{% from "components/button/macro.njk" import govukButton %}
{% set file_contents_header_id = 'file-preview' %}
{% macro skip_to_file_contents() %}
<p class="govuk-visually-hidden">
<a class="govuk-link govuk-link--no-visited-state" href="#{{ file_contents_header_id }}">Skip to file contents</a>
</p>
{% endmacro %}
{% block service_page_title %}
{{ original_file_name }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
original_file_name,
back_link=url_for('main.upload_contact_list', service_id=current_service.id)
) }}
<p class="govuk-body">
{{ recipients|length|format_thousands }} {{ recipient_count_label(recipients|length, recipients.template_type) }} found
</p>
<div class="bottom-gutter-3-2">
<form method="post" enctype="multipart/form-data" action="{{ url_for('main.save_contact_list', service_id=current_service.id, upload_id=upload_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{{ govukButton({ "text": "Save contact list" }) }}
</form>
</div>
<h2 class="govuk-heading-m">
File preview
</h2>
{% call(item, row_number) list_table(
recipients.displayed_rows,
caption=original_file_name,
caption_visible=False,
field_headings=[
'<span class="govuk-visually-hidden">Row in file</span> <span aria-hidden="true">1</span>'|safe
] + recipients.column_headers
) %}
{{ index_field(item.index + 2) }}
{% for column in recipients.column_headers %}
{% if item[column].ignore %}
{{ text_field(item[column].data or '', status='default') }}
{% else %}
{{ text_field(item[column].data or '') }}
{% endif %}
{% endfor %}
{% if item[None].data %}
{% for column in item[None].data %}
{{ text_field(column, status='default') }}
{% endfor %}
{% endif %}
{% endcall %}
{% if recipients.displayed_rows|list|length < recipients|length %}
<p class="table-show-more-link">
Only showing the first {{ recipients.displayed_rows|list|length }} rows
</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,108 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/radios.html" import radio_select %}
{% from "components/table.html" import list_table, field, text_field, index_field, hidden_field_heading %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/back-link/macro.njk" import govukBackLink %}
{% from "components/message-count-label.html" import message_count_label %}
{% set file_contents_header_id = 'file-preview' %}
{% macro skip_to_file_contents() %}
<p class="govuk-visually-hidden">
<a class="govuk-link govuk-link--no-visited-state" href="#{{ file_contents_header_id }}">Skip to file contents</a>
</p>
{% endmacro %}
{% block service_page_title %}
Error
{% endblock %}
{% block maincolumn_content %}
{{ govukBackLink({ "href": url_for('main.upload_contact_list', service_id=current_service.id) }) }}
<div class="bottom-gutter-1-2">
{% call banner_wrapper(type='dangerous') %}
{% if row_errors|length == 1 %}
<h1 class='banner-title' data-module="track-error" data-error-type="Bad rows" data-error-label="{{ upload_id }}">
Theres a problem with {{ original_file_name }}
</h1>
<p>
You need to {{ row_errors[0] }}.
</p>
{% else %}
<h1 class='banner-title' data-module="track-error" data-error-type="Bad rows" data-error-label="{{ upload_id }}">
There are some problems with {{ original_file_name }}
</h1>
<p>
You need to:
</p>
<ul class="list-bullet">
{% for error in row_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{{ skip_to_file_contents() }}
{% endcall %}
</div>
<div class="js-stick-at-top-when-scrolling">
<div class="form-group">
{{ file_upload(
form.file,
action=url_for('.upload_contact_list', service_id=current_service.id),
button_text='Upload your file again'
) }}
</div>
<a href="#content" class="govuk-link govuk-link--no-visited-state back-to-top-link">Back to top</a>
</div>
{% call(item, row_number) list_table(
recipients.displayed_rows,
caption=original_file_name,
caption_visible=False,
field_headings=[
'<span class="govuk-visually-hidden">Row in file</span> <span aria-hidden="true" class="table-field-invisible-error">1</span>'|safe
] + recipients.column_headers
) %}
{% call index_field() %}
<span class="{% if item.has_errors %}table-field-error{% endif %}">
{{ item.index + 2 }}
</span>
{% endcall %}
{% for column in recipients.column_headers %}
{% if item[column].error and not recipients.missing_column_headers %}
{% call field() %}
<span>
<span class="table-field-error-label">{{ item[column].error }}</span>
{{ item[column].data if item[column].data != None }}
</span>
{% endcall %}
{% elif item[column].ignore %}
{{ text_field(item[column].data or '', status='default') }}
{% else %}
{{ text_field(item[column].data or '') }}
{% endif %}
{% endfor %}
{% if item[None].data %}
{% for column in item[None].data %}
{{ text_field(column, status='default') }}
{% endfor %}
{% endif %}
{% endcall %}
{% if recipients.displayed_rows|list|length < recipients|length %}
{% if recipients.displayed_rows|list|length < recipients.rows_with_errors|list|length %}
<p class="table-show-more-link">
Only showing the first {{ recipients.displayed_rows|list|length }} rows with errors
</p>
{% else %}
<p class="table-show-more-link">
Only showing rows with errors
</p>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/radios.html" import radio_select %}
{% from "components/table.html" import list_table, field, text_field, index_field, hidden_field_heading %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/back-link/macro.njk" import govukBackLink %}
{% from "components/message-count-label.html" import message_count_label %}
{% set file_contents_header_id = 'file-preview' %}
{% macro skip_to_file_contents() %}
<p class="govuk-visually-hidden">
<a class="govuk-link govuk-link--no-visited-state" href="#{{ file_contents_header_id }}">Skip to file contents</a>
</p>
{% endmacro %}
{% block service_page_title %}
Error
{% endblock %}
{% block maincolumn_content %}
{{ govukBackLink({ "href": url_for('main.upload_contact_list', service_id=current_service.id) }) }}
<div class="bottom-gutter-1-2">
{% call banner_wrapper(type='dangerous') %}
{% if not recipients|length %}
<h1 class='banner-title' data-module="track-error" data-error-type="No rows" data-error-label="{{ upload_id }}">
Your file is missing some rows
</h1>
<p>
It needs at least one row of data
{%- if template_type %}.{% else %}, in a column called email address or phone number.{% endif %}
</p>
{% elif recipients.column_headers|length == 1 %}
<h1 class='banner-title' data-module="track-error" data-error-type="Too many rows" data-error-label="{{ upload_id }}">
Your file needs a column called email address or phone number.
</h1>
<p>
Right now it has 1 column called {{ recipients._raw_column_headers[0] }}.
</p>
{% else %}
<h1 class='banner-title' data-module="track-error" data-error-type="Too many rows" data-error-label="{{ upload_id }}">
Your file has too many columns
</h1>
<p>
It needs to have 1 column, called email address or phone number.
</p>
<p>
Right now it has {{ recipients._raw_column_headers|length }} columns called {{ recipients._raw_column_headers | formatted_list }}.
</p>
{% endif %}
{{ skip_to_file_contents() }}
{% endcall %}
</div>
<div class="js-stick-at-top-when-scrolling">
<div class="form-group">
{{ file_upload(
form.file,
action=url_for('.upload_contact_list', service_id=current_service.id),
button_text='Upload your file again'
) }}
</div>
<a href="#content" class="govuk-link govuk-link--no-visited-state back-to-top-link">Back to top</a>
</div>
{% set column_headers = recipients._raw_column_headers if recipients.duplicate_recipient_column_headers else recipients.column_headers %}
<h2 class="heading-medium" id="{{ file_contents_header_id }}">{{ original_file_name }}</h2>
<div class="fullscreen-content" data-module="fullscreen-table">
{% call(item, row_number) list_table(
recipients.displayed_rows,
caption=original_file_name,
caption_visible=False,
field_headings=[
'<span class="govuk-visually-hidden">Row in file</span> <span aria-hidden="true">1</span>'|safe
] + recipients._raw_column_headers
) %}
{{ index_field(item.index + 2) }}
{% for column in column_headers %}
{% if item[column].ignore %}
{{ text_field(item[column].data or '', status='default') }}
{% else %}
{{ text_field(item[column].data or '') }}
{% endif %}
{% endfor %}
{% if item[None].data %}
{% for column in item[None].data %}
{{ text_field(column, status='default') }}
{% endfor %}
{% endif %}
{% endcall %}
</div>
{% if recipients.displayed_rows|list|length < recipients|length %}
<p class="table-show-more-link">
Only showing the first {{ recipients.displayed_rows|list|length }} rows
</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,91 @@
{% extends "withnav_template.html" %}
{% from "components/banner.html" import banner_wrapper %}
{% from "components/file-upload.html" import file_upload %}
{% from "components/page-header.html" import page_header %}
{% from "components/table.html" import list_table, text_field, index_field, index_field_heading %}
{% block service_page_title %}
Upload an emergency contact list
{% endblock %}
{% block maincolumn_content %}
{% if error %}
{% call banner_wrapper(type='dangerous') %}
<h1 class="banner-title">{{ error.title }}</h1>
{% if error.detail %}
<p>{{ error.detail | safe }}</p>
{% endif %}
{% endcall %}
{% else %}
{{ page_header(
'Upload an emergency contact list',
back_link=url_for('main.uploads', service_id=current_service.id)
) }}
<p class="govuk-body">
Save a list of staff email addresses or phone numbers in Notify.
</p>
<p class="govuk-body">
In an emergency, you can send a message to everyone on the list.
</p>
<p class="govuk-body">
Do not include contact details for members of the public.
</p>
{% endif %}
<div class="bottom-gutter">
{{ file_upload(
form.file,
button_text='Upload your file again' if error else 'Choose file',
show_errors=False
)}}
</div>
<h2 class="heading-medium">Your file needs to look like one of these examples</h2>
<p class="hint">
Save your file as a
<acronym title="Comma Separated Values">CSV</acronym>,
<acronym title="Tab Separated Values">TSV</acronym>,
<acronym title="Open Document Spreadsheet">ODS</acronym>,
or Microsoft Excel spreadsheet
</p>
<div class="govuk-grid-row">
<div class="govuk-grid-column-one-half">
<div class="spreadsheet">
{% call(item, row_number) list_table(
[
['email address'],
['test@example.gov.uk'],
],
caption="Example",
caption_visible=False,
field_headings=['', 'A']
) %}
{{ index_field(row_number - 1) }}
{% for column in item %}
{{ text_field(column) }}
{% endfor %}
{% endcall %}
</div>
</div>
<div class="govuk-grid-column-one-half">
<div class="spreadsheet">
{% call(item, row_number) list_table(
[
['phone number'],
['07700 900123'],
],
caption="Example",
caption_visible=False,
field_headings=['', 'A']
) %}
{{ index_field(row_number - 1) }}
{% for column in item %}
{{ text_field(column) }}
{% endfor %}
{% endcall %}
</div>
</div>
</div>
{% endblock %}
+11
View File
@@ -1,11 +1,22 @@
from werkzeug.routing import BaseConverter
from app.models.feedback import (
GENERAL_TICKET_TYPE,
PROBLEM_TICKET_TYPE,
QUESTION_TICKET_TYPE,
)
class TemplateTypeConverter(BaseConverter):
regex = '(?:email|sms|letter)'
class TicketTypeConverter(BaseConverter):
regex = f'(?:{PROBLEM_TICKET_TYPE}|{QUESTION_TICKET_TYPE}|{GENERAL_TICKET_TYPE})'
class LetterFileExtensionConverter(BaseConverter):
regex = '(?:pdf|png)'
+7
View File
@@ -323,6 +323,13 @@ class Spreadsheet():
pyexcel.free_resources()
return instance
@classmethod
def from_file_form(cls, form):
return cls.from_file(
form.file.data,
filename=form.file.data.filename,
)
@property
def as_rows(self):
if not self._rows: