diff --git a/Makefile b/Makefile index 0fccc7932..3e5415b44 100644 --- a/Makefile +++ b/Makefile @@ -183,7 +183,7 @@ cf-deploy: ## Deploys the app to Cloud Foundry cf v3-cancel-zdt-push ${CF_APP} || true cf v3-apply-manifest ${CF_APP} -f <(make -s generate-manifest) - cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work + CF_STARTUP_TIMEOUT=10 cf v3-zdt-push ${CF_APP} --wait-for-deploy-complete # fails after 5 mins if deploy doesn't work .PHONY: cf-deploy-prototype cf-deploy-prototype: cf-target ## Deploys the first prototype to Cloud Foundry diff --git a/app/__init__.py b/app/__init__.py index 43d9e2019..51cb3a268 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/assets/stylesheets/components/letter.scss b/app/assets/stylesheets/components/letter.scss index 49f25bc3c..84503da16 100644 --- a/app/assets/stylesheets/components/letter.scss +++ b/app/assets/stylesheets/components/letter.scss @@ -89,3 +89,8 @@ $iso-paper-ratio: 141.42135624%; } } + +.letter-recipient-summary { + line-height: 28px; + margin-bottom: 0; +} diff --git a/app/assets/stylesheets/views/dashboard.scss b/app/assets/stylesheets/views/dashboard.scss index b0035347b..f694415d6 100644 --- a/app/assets/stylesheets/views/dashboard.scss +++ b/app/assets/stylesheets/views/dashboard.scss @@ -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 { diff --git a/app/config.py b/app/config.py index 7b8984287..9d470974e 100644 --- a/app/config.py +++ b/app/config.py @@ -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' diff --git a/app/main/forms.py b/app/main/forms.py index 84017130a..a665db984 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -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', 'I’m 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?', diff --git a/app/main/validators.py b/app/main/validators.py index 39bf2fb0b..f35d6208a 100644 --- a/app/main/validators.py +++ b/app/main/validators.py @@ -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' - ' contact us' - ).format(url_for('main.support')) + message = ''' + Enter a public sector email address or + find out who can use Notify + '''.format(url_for('main.who_its_for')) if not is_gov_user(field.data.lower()): raise ValidationError(message) diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index b66eedf8f..9af826f07 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -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), diff --git a/app/main/views/feedback.py b/app/main/views/feedback.py index 9cf0cd6a5..00896c1ce 100644 --- a/app/main/views/feedback.py +++ b/app/main/views/feedback.py @@ -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 there’s 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/', 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/', methods=['GET', 'POST']) +@main.route('/support/', 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(), diff --git a/app/main/views/index.py b/app/main/views/index.py index 13effe361..062230eb2 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -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(): diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index 375bc3523..25bf8a884 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -42,26 +42,10 @@ from app.utils import ( @main.route("/services//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//jobs/") diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index 25b727630..e2f9b27cd 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -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', diff --git a/app/main/views/send.py b/app/main/views/send.py index dbfb3421b..8db4d931e 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -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//send//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//send//test/step-", 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//send/' + '/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//send/' + '/from-contact-list/' +) +@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) diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 614f861d9..78e371363 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -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 diff --git a/app/main/views/sub_navigation_dictionaries.py b/app/main/views/sub_navigation_dictionaries.py index b3acbb468..3acd67136 100644 --- a/app/main/views/sub_navigation_dictionaries.py +++ b/app/main/views/sub_navigation_dictionaries.py @@ -52,6 +52,10 @@ def using_notify_nav(): "name": "Get started", "link": "main.get_started", }, + { + "name": "Who it’s for", + "link": "main.who_its_for", + }, { "name": "Trial mode", "link": "main.trial_mode_new", diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 0cd8b8444..00e2a3b53 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -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')) diff --git a/app/main/views/uploads.py b/app/main/views/uploads.py index a6b32f28d..20cc47f64 100644 --- a/app/main/views/uploads.py +++ b/app/main/views/uploads.py @@ -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//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//check-contact-list/", + 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//save-contact-list/", 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//contact-list/", 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//contact-list//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//contact-list/.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, + ) diff --git a/app/models/contact_list.py b/app/models/contact_list.py new file mode 100644 index 000000000..95cfdfdf2 --- /dev/null +++ b/app/models/contact_list.py @@ -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(), + ) diff --git a/app/models/feedback.py b/app/models/feedback.py new file mode 100644 index 000000000..345be5e6a --- /dev/null +++ b/app/models/feedback.py @@ -0,0 +1,3 @@ +QUESTION_TICKET_TYPE = 'ask-question-give-feedback' +PROBLEM_TICKET_TYPE = 'report-problem' +GENERAL_TICKET_TYPE = 'general' diff --git a/app/models/service.py b/app/models/service.py index 132ac157c..c053438cc 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -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) diff --git a/app/navigation.py b/app/navigation.py index aae89b581..7343559d6 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -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', } diff --git a/app/notify_client/contact_list_api_client.py b/app/notify_client/contact_list_api_client.py new file mode 100644 index 000000000..9f7c04dc7 --- /dev/null +++ b/app/notify_client/contact_list_api_client.py @@ -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() diff --git a/app/notify_client/job_api_client.py b/app/notify_client/job_api_client.py index 803bbe26e..f143da94b 100644 --- a/app/notify_client/job_api_client.py +++ b/app/notify_client/job_api_client.py @@ -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) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index b89e5c196..b0ad13904 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -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)) diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index f9d0b5b9f..8ddfb1a57 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -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 diff --git a/app/templates/admin_template.html b/app/templates/admin_template.html index 91eec3123..804f65809 100644 --- a/app/templates/admin_template.html +++ b/app/templates/admin_template.html @@ -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 it’s for", + }, { "href": url_for("main.trial_mode_new"), "text": "Trial mode" diff --git a/app/templates/components/ajax-block.html b/app/templates/components/ajax-block.html index 4d40a25b3..c0aa403d8 100644 --- a/app/templates/components/ajax-block.html +++ b/app/templates/components/ajax-block.html @@ -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 %}
Dashboard {% endif %}
  • Templates
  • - {% if current_user.has_permissions('view_activity') %} -
  • Uploads
  • - {% else %} + {% if not current_user.has_permissions('view_activity') %}
  • Sent messages
  • - {% if current_service.has_jobs or current_service.can_upload_letters %} -
  • Uploads
  • - {% endif %} {% endif %} +
  • Uploads
  • Team members
  • {% if current_user.has_permissions('manage_service', allow_org_user=True) %}
  • Usage
  • diff --git a/app/templates/views/check/ok.html b/app/templates/views/check/ok.html index 3ec9d2ac9..7554f18c0 100644 --- a/app/templates/views/check/ok.html +++ b/app/templates/views/check/ok.html @@ -37,6 +37,7 @@