diff --git a/app/__init__.py b/app/__init__.py index 94a1c1d86..8db5ca6d1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -19,7 +19,7 @@ from app.notify_client.status_api_client import StatusApiClient from app.notify_client.invite_api_client import InviteApiClient from app.its_dangerous_session import ItsdangerousSessionInterface from app.asset_fingerprinter import AssetFingerprinter -from app.utils import validate_phone_number, InvalidPhoneError +from utils.recipients import validate_phone_number, InvalidPhoneError import app.proxy_fix from config import configs from utils import logging diff --git a/app/assets/stylesheets/components/banner.scss b/app/assets/stylesheets/components/banner.scss index 77b706779..884523bdd 100644 --- a/app/assets/stylesheets/components/banner.scss +++ b/app/assets/stylesheets/components/banner.scss @@ -12,6 +12,18 @@ position: relative; clear: both; + &-title { + @include bold-24; + } + + p { + margin: 10px 0 5px 0; + } + + .list-bullet { + @include copy-19; + } + } .banner-with-tick, diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss index 36c137bcc..8a37c677e 100644 --- a/app/assets/stylesheets/components/table.scss +++ b/app/assets/stylesheets/components/table.scss @@ -16,10 +16,26 @@ %table-field, .table-field { + vertical-align: top; + &:last-child { padding-right: 0; } + &-error { + + border-left: 5px solid $error-colour; + padding-left: 7px; + display: block; + + &-label { + display: block; + color: $error-colour; + font-weight: bold; + } + + } + &-status { &-default { @@ -55,13 +71,6 @@ background-image: file-url('tick.png'); } - &-missing { - color: $error-colour; - font-weight: bold; - border-left: 5px solid $error-colour; - padding-left: 7px; - } - } } @@ -92,9 +101,15 @@ } .table-show-more-link { - @include bold-16; + @include core-16; + color: $secondary-text-colour; margin-top: -20px; border-bottom: 1px solid $border-colour; padding-bottom: 10px; text-align: center; } + +a.table-show-more-link { + @include bold-16; + color: $link-colour; +} diff --git a/app/main/dao/users_dao.py b/app/main/dao/users_dao.py index 411a22490..932a98cd3 100644 --- a/app/main/dao/users_dao.py +++ b/app/main/dao/users_dao.py @@ -64,11 +64,6 @@ def is_email_unique(email_address): raise ex -def request_password_reset(user): - user.state = 'request_password_reset' - user_api_client.update_user(user) - - def send_verify_code(user_id, code_type, to): return user_api_client.send_verify_code(user_id, code_type, to) diff --git a/app/main/forms.py b/app/main/forms.py index 3cbfb7390..dc2b18e33 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -16,7 +16,7 @@ from wtforms.validators import DataRequired, Email, Length, Regexp from app.main.validators import Blacklist, CsvFileValidator -from app.utils import ( +from utils.recipients import ( validate_phone_number, format_phone_number, InvalidPhoneError @@ -37,19 +37,10 @@ class UKMobileNumber(TelField): def pre_validate(self, form): try: - self.data = validate_phone_number(self.data) + validate_phone_number(self.data) except InvalidPhoneError as e: raise ValidationError(e.message) - def post_validate(self, form, validation_stopped): - - if len(self.data) != 9: - return - # TODO implement in the render field method. - # API's require no spaces in the number - # self.data = '+44 7{} {} {}'.format(*re.findall('...', self.data)) - self.data = format_phone_number(self.data) - def mobile_number(): return UKMobileNumber('Mobile phone number', @@ -104,7 +95,7 @@ class RegisterUserFromInviteForm(Form): mobile_number = mobile_number() password = password() service = HiddenField('service') - email_address = email_address() + email_address = HiddenField('email_address') class InviteUserForm(Form): diff --git a/app/main/uploader.py b/app/main/uploader.py index bfecb75b8..3dfd4d87b 100644 --- a/app/main/uploader.py +++ b/app/main/uploader.py @@ -8,7 +8,7 @@ BUCKET_NAME = 'service-{}-notify' def s3upload(upload_id, service_id, filedata, region): s3 = resource('s3') bucket_name = BUCKET_NAME.format(service_id) - contents = '\n'.join(filedata['data']) + contents = filedata['data'] exists = True try: diff --git a/app/main/views/forgot_password.py b/app/main/views/forgot_password.py index 3eb30369b..16818bf06 100644 --- a/app/main/views/forgot_password.py +++ b/app/main/views/forgot_password.py @@ -1,25 +1,22 @@ from flask import ( render_template, - flash ) +from notifications_python_client.errors import HTTPError from app.main import main -from app.main.dao import users_dao from app.main.forms import ForgotPasswordForm -from app.notify_client.sender import send_change_password_email +from app import user_api_client @main.route('/forgot-password', methods=['GET', 'POST']) def forgot_password(): - form = ForgotPasswordForm() if form.validate_on_submit(): - if not users_dao.is_email_unique(form.email_address.data): - user = users_dao.get_user_by_email(form.email_address.data) - users_dao.request_password_reset(user) - send_change_password_email(form.email_address.data) - return render_template('views/password-reset-sent.html') - else: - return render_template('views/password-reset-sent.html') + try: + user_api_client.send_reset_password_url(form.email_address.data) + except HTTPError as e: + if e.status_code != 404: + raise e + return render_template('views/password-reset-sent.html') return render_template('views/forgot-password.html', form=form) diff --git a/app/main/views/invites.py b/app/main/views/invites.py index 662d66cec..44d00296c 100644 --- a/app/main/views/invites.py +++ b/app/main/views/invites.py @@ -33,6 +33,7 @@ def accept_invite(token): session['invited_user'] = invited_user.serialize() if existing_user: + user_api_client.add_user_to_service(invited_user.service, existing_user.id, invited_user.permissions) diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py index 4b28a8504..088915aaf 100644 --- a/app/main/views/new_password.py +++ b/app/main/views/new_password.py @@ -1,22 +1,33 @@ -from flask import (render_template, url_for, redirect, flash, session) +import json + +from flask import (render_template, url_for, redirect, flash, session, current_app, abort) +from itsdangerous import SignatureExpired from app.main import main from app.main.dao import users_dao from app.main.forms import NewPasswordForm -from app.notify_client.sender import check_token +from datetime import datetime @main.route('/new-password/', methods=['GET', 'POST']) def new_password(token): - email_address = check_token(token) - if not email_address: + from utils.url_safe_token import check_token + try: + token_data = check_token(token, current_app.config['SECRET_KEY'], current_app.config['DANGEROUS_SALT'], + current_app.config['TOKEN_MAX_AGE_SECONDS']) + except SignatureExpired: flash('The link in the email we sent you has expired. Enter your email address to resend.') return redirect(url_for('.forgot_password')) + email_address = json.loads(token_data)['email'] user = users_dao.get_user_by_email(email_address=email_address) - if user and user.state != 'request_password_reset': - flash('The link in the email we sent you has already been used.') - return redirect(url_for('.index')) + # TODO: what should this be?? + if not user: + abort(404, 'user not found') + if user.password_changed_at and datetime.strptime(user.password_changed_at, '%Y-%m-%d %H:%M:%S.%f') > \ + datetime.strptime(json.loads(token_data)['created_at'], '%Y-%m-%d %H:%M:%S.%f'): + flash('The link in the email has already been used') + return redirect(url_for('main.index')) form = NewPasswordForm() @@ -26,7 +37,6 @@ def new_password(token): 'id': user.id, 'email': user.email_address, 'password': form.new_password.data} - users_dao.activate_user(user) return redirect(url_for('main.two_factor')) else: return render_template('views/new-password.html', token=token, form=form, user=user) diff --git a/app/main/views/register.py b/app/main/views/register.py index 7400f113d..eaeeaf150 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -51,7 +51,7 @@ def register_from_invite(): form.service.data = invited_user['service'] form.email_address.data = invited_user['email_address'] - return render_template('views/register-from-invite.html', form=form) + return render_template('views/register-from-invite.html', email_address=invited_user['email_address'], form=form) def _do_registration(form, service=None): diff --git a/app/main/views/send.py b/app/main/views/send.py index 3784ba7c2..27d1ffe4d 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -1,6 +1,7 @@ import csv import io import uuid +from contextlib import suppress from flask import ( request, @@ -15,7 +16,8 @@ from flask import ( from flask_login import login_required, current_user from notifications_python_client.errors import HTTPError -from utils.template import Template, NeededByTemplateError, NoPlaceholderForDataError +from utils.template import Template +from utils.recipients import RecipientCSV, first_column_heading from app.main import main from app.main.forms import CsvUploadForm @@ -26,10 +28,7 @@ from app.main.uploader import ( from app.main.dao import templates_dao from app.main.dao import services_dao from app import job_api_client -from app.utils import ( - validate_recipient, validate_header_row, InvalidPhoneError, InvalidEmailError, user_has_permissions, - InvalidHeaderError) -from utils.process_csv import first_column_heading +from app.utils import user_has_permissions, get_errors_for_csv send_messages_page_headings = { @@ -100,16 +99,25 @@ def send_messages(service_id, template_id): form = CsvUploadForm() if form.validate_on_submit(): try: - csv_file = form.file - filedata = _get_filedata(csv_file) upload_id = str(uuid.uuid4()) - s3upload(upload_id, service_id, filedata, current_app.config['AWS_REGION']) - session['upload_data'] = {"template_id": template_id, "original_file_name": filedata['file_name']} + s3upload( + upload_id, + service_id, + { + 'file_name': form.file.data.filename, + 'data': form.file.data.getvalue().decode('utf-8') + }, + current_app.config['AWS_REGION'] + ) + session['upload_data'] = { + "template_id": template_id, + "original_file_name": form.file.data.filename + } return redirect(url_for('.check_messages', service_id=service_id, upload_id=upload_id)) except ValueError as e: - flash('There was a problem uploading: {}'.format(csv_file.data.filename)) + flash('There was a problem uploading: {}'.format(form.file.data.filename)) flash(str(e)) return redirect(url_for('.send_messages', service_id=service_id, template_id=template_id)) @@ -118,7 +126,6 @@ def send_messages(service_id, template_id): templates_dao.get_service_template_or_404(service_id, template_id)['data'], prefix=service['name'] ) - recipient_column = first_column_heading[template.template_type] return render_template( 'views/send.html', @@ -174,7 +181,7 @@ def send_message_to_self(service_id, template_id): filedata = { 'file_name': 'Test run', - 'data': output.getvalue().splitlines() + 'data': output.getvalue() } upload_id = str(uuid.uuid4()) s3upload(upload_id, service_id, filedata, current_app.config['AWS_REGION']) @@ -185,107 +192,75 @@ def send_message_to_self(service_id, template_id): upload_id=upload_id)) -@main.route("/services//check/", - methods=['GET', 'POST']) +@main.route("/services//check/", methods=['GET']) @login_required @user_has_permissions('send_texts', 'send_emails', 'send_letters') def check_messages(service_id, upload_id): - upload_data = session['upload_data'] - template_id = upload_data.get('template_id') service = services_dao.get_service_by_id_or_404(service_id) - if request.method == 'GET': - contents = s3download(service_id, upload_id) - if not contents: - flash('There was a problem reading your upload file') - raw_template = templates_dao.get_service_template_or_404(service_id, template_id)['data'] - upload_result = _get_rows(contents, raw_template) - session['upload_data']['notification_count'] = len(upload_result['rows']) - template = Template( - raw_template, - values=upload_result['rows'][0] if upload_result['valid'] else {}, - drop_values={first_column_heading[raw_template['template_type']]}, - prefix=service['name'] - ) - return render_template( - 'views/check.html', - upload_result=upload_result, - template=template, - page_heading=get_page_headings(template.template_type), - column_headers=[first_column_heading[template.template_type]] + list(template.placeholders_as_markup), - original_file_name=upload_data.get('original_file_name'), - service_id=service_id, - service=service, - form=CsvUploadForm() - ) - elif request.method == 'POST': - if request.files: - # The csv was invalid, validate the csv again - return send_messages(service_id, template_id) + contents = s3download(service_id, upload_id) + if not contents: + flash('There was a problem reading your upload file') - original_file_name = upload_data.get('original_file_name') - notification_count = upload_data.get('notification_count') - session.pop('upload_data') - try: - job_api_client.create_job(upload_id, service_id, template_id, original_file_name, notification_count) - except HTTPError as e: - if e.status_code == 404: - abort(404) - else: - raise e - - return redirect( - url_for('main.view_job', service_id=service_id, job_id=upload_id) - ) - - -def _get_filedata(file): - import itertools - reader = csv.reader( - file.data.getvalue().decode('utf-8').splitlines(), - quoting=csv.QUOTE_NONE, - skipinitialspace=True + template = Template( + templates_dao.get_service_template_or_404( + service_id, + session['upload_data'].get('template_id') + )['data'], + prefix=service['name'] ) - lines = [] - for row in reader: - non_empties = itertools.dropwhile(lambda x: x.strip() == '', row) - has_content = [] - for item in non_empties: - has_content.append(item) - if has_content: - lines.append(row) - if len(lines) < 2: # must be header row and at least one data row - message = 'The file {} contained no data'.format(file.data.filename) - raise ValueError(message) - - content_lines = [] - for row in lines: - content_lines.append(','.join(row).rstrip(',')) - return {'file_name': file.data.filename, 'data': content_lines} - - -def _get_rows(contents, raw_template): - reader = csv.DictReader( - contents.split('\n'), - quoting=csv.QUOTE_NONE, - skipinitialspace=True + recipients = RecipientCSV( + contents, + template_type=template.template_type, + placeholders=template.placeholders, + max_initial_rows_shown=5 + ) + + with suppress(StopIteration): + template.values = next(recipients.rows) + + session['upload_data']['notification_count'] = len(list(recipients.rows)) + session['upload_data']['valid'] = not recipients.has_errors + + return render_template( + 'views/check.html', + recipients=recipients, + template=template, + page_heading=get_page_headings(template.template_type), + errors=get_errors_for_csv(recipients, template.template_type), + count_of_recipients=session['upload_data']['notification_count'], + count_of_displayed_recipients=len(list(recipients.rows_annotated_and_truncated)), + original_file_name=session['upload_data'].get('original_file_name'), + service_id=service_id, + service=service, + form=CsvUploadForm() + ) + + +@main.route("/services//check/", methods=['POST']) +@login_required +@user_has_permissions('send_texts', 'send_emails', 'send_letters') +def start_job(service_id, upload_id): + + upload_data = session['upload_data'] + services_dao.get_service_by_id_or_404(service_id) + + if request.files or not upload_data.get('valid'): + # The csv was invalid, validate the csv again + return send_messages(service_id, upload_data.get('template_id')) + + session.pop('upload_data') + + job_api_client.create_job( + upload_id, + service_id, + upload_data.get('template_id'), + upload_data.get('original_file_name'), + upload_data.get('notification_count') + ) + + return redirect( + url_for('main.view_job', service_id=service_id, job_id=upload_id) ) - valid = True - rows = [] - for row in reader: - rows.append(row) - try: - validate_recipient( - row, template_type=raw_template['template_type'] - ) - Template( - raw_template, - values=row, - drop_values={first_column_heading[raw_template['template_type']]} - ).replaced - except (InvalidEmailError, InvalidPhoneError, NeededByTemplateError, - NoPlaceholderForDataError, InvalidHeaderError): - valid = False - return {"valid": valid, "rows": rows} diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index c2931b1cd..5b598e4ae 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -45,6 +45,11 @@ def sign_in(): # Vague error message for login in case of user not known, locked, inactive or password not verified flash('Username or password is incorrect') + invited_user = session.get('invited_user') + if invited_user: + message = 'You already have an account with GOV.UK Notify. Sign in to your account to accept this invitation.' + flash(message, 'default') + return render_template('views/signin.html', form=form) diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 6ed2a6089..37618ed0d 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -1,3 +1,4 @@ + from flask import ( render_template, redirect, @@ -29,6 +30,7 @@ def two_factor(): try: user = users_dao.get_user_by_id(user_id) services = services_dao.get_services(user_id).get('data', []) + # Check if coming from new password page if 'password' in session['user_details']: user.set_password(session['user_details']['password']) users_dao.update_user(user) diff --git a/app/notify_client/models.py b/app/notify_client/models.py index 19a7d1246..cd4bd607b 100644 --- a/app/notify_client/models.py +++ b/app/notify_client/models.py @@ -92,7 +92,7 @@ class User(UserMixin): if service_id in self._permissions: if or_: return any([x in self._permissions[service_id] for x in permissions]) - print(set(self._permissions[service_id]) >= set(permissions)) + return set(self._permissions[service_id]) >= set(permissions) return False diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 74c1f9af9..ba15b90ba 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -102,4 +102,9 @@ class UserApiClient(BaseAPIClient): def set_user_permissions(self, user_id, service_id, permissions): data = [{'permission': x} for x in permissions] endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id) - resp = self.post(endpoint, data=data) + self.post(endpoint, data=data) + + def send_reset_password_url(self, email_address): + endpoint = '/user/reset-password' + data = {'email': email_address} + self.post(endpoint, data=data) diff --git a/app/templates/components/banner.html b/app/templates/components/banner.html index 74402779e..0c0ef7abc 100644 --- a/app/templates/components/banner.html +++ b/app/templates/components/banner.html @@ -22,3 +22,7 @@ {% endif %} {% endmacro %} + +{% macro banner_wrapper(type=None, with_tick=False, delete_button=None, subhead=None) %} + {{ banner(caller()|safe, type=type, with_tick=with_tick, delete_button=delete_button, subhead=subhead) }} +{% endmacro %} diff --git a/app/templates/components/file-upload.html b/app/templates/components/file-upload.html index 0d9e65fb9..6dcb22a2e 100644 --- a/app/templates/components/file-upload.html +++ b/app/templates/components/file-upload.html @@ -1,5 +1,5 @@ {% macro file_upload(field, button_text="Choose file") %} -
+