From 3e969b3640c73e88f3d6c71cdcf22195d64b4dd7 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Mon, 7 Mar 2016 18:18:52 +0000 Subject: [PATCH 01/10] Re-implement forgot password --- app/main/dao/users_dao.py | 5 -- app/main/views/forgot_password.py | 19 +++--- app/main/views/new_password.py | 26 +++++--- app/notify_client/user_api_client.py | 7 ++- config.py | 4 +- tests/app/main/views/test_forgot_password.py | 32 ++-------- tests/app/main/views/test_new_password.py | 64 ++++++++++---------- tests/conftest.py | 44 ++++++++++---- 8 files changed, 105 insertions(+), 96 deletions(-) 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/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/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/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index ce9d83bce..542035639 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/config.py b/config.py index af641a7b7..7f40c889f 100644 --- a/config.py +++ b/config.py @@ -39,9 +39,9 @@ class Config(object): ADMIN_CLIENT_SECRET = os.getenv('ADMIN_CLIENT_SECRET') WTF_CSRF_ENABLED = True - SECRET_KEY = 'secret-key' + SECRET_KEY = 'dev-notify-secret-key' HTTP_PROTOCOL = 'http' - DANGEROUS_SALT = 'itsdangeroussalt' + DANGEROUS_SALT = 'dev-notify-salt' TOKEN_MAX_AGE_SECONDS = 3600 MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10mb diff --git a/tests/app/main/views/test_forgot_password.py b/tests/app/main/views/test_forgot_password.py index 7789b4197..8ca8e3a59 100644 --- a/tests/app/main/views/test_forgot_password.py +++ b/tests/app/main/views/test_forgot_password.py @@ -9,13 +9,10 @@ def test_should_render_forgot_password(app_): in response.get_data(as_text=True) -def test_should_redirect_to_password_reset_sent_and_state_updated( - app_, - api_user_active, - mock_get_user_by_email, - mock_update_user, - mock_send_email -): +def test_should_redirect_to_password_reset_sent_for_valid_email( + app_, + api_user_active, + mock_reset_user_password): with app_.test_request_context(): response = app_.test_client().post( url_for('.forgot_password'), @@ -24,23 +21,4 @@ def test_should_redirect_to_password_reset_sent_and_state_updated( assert ( 'You have been sent an email containing a link' ' to reset your password.') in response.get_data(as_text=True) - assert mock_send_email.call_count == 1 - - -def test_should_redirect_to_password_reset_sent_for_non_existant_email_address( - app_, - api_user_active, - mock_dont_get_user_by_email, - mock_update_user, - mock_send_email -): - with app_.test_request_context(): - response = app_.test_client().post( - url_for('.forgot_password'), - data={'email_address': 'nope@example.gov.uk'}) - assert response.status_code == 200 - assert ( - 'You have been sent an email containing a link' - ' to reset your password.') in response.get_data(as_text=True) - mock_dont_get_user_by_email.assert_called_once_with('nope@example.gov.uk') - assert not mock_send_email.called + mock_reset_user_password.assert_called_once_with(api_user_active.email_address) diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py index a3a17061a..84106354e 100644 --- a/tests/app/main/views/test_new_password.py +++ b/tests/app/main/views/test_new_password.py @@ -1,9 +1,8 @@ +import json +from datetime import datetime + from flask import url_for - -from app.main.dao import users_dao -from app.main.encryption import check_hash -from app.notify_client.sender import generate_token - +from utils.url_safe_token import generate_token import pytest @@ -14,59 +13,62 @@ def test_should_render_new_password_template(app_, mock_get_user_by_email_request_password_reset): with app_.test_request_context(): with app_.test_client() as client: - token = generate_token(api_user_active.email_address) + data = json.dumps({'email': api_user_active.email_address, 'created_at': str(datetime.now())}) + token = generate_token(data, app_.config['SECRET_KEY'], + app_.config['DANGEROUS_SALT']) response = client.get(url_for('.new_password', token=token)) assert response.status_code == 200 assert 'You can now create a new password for your account.' in response.get_data(as_text=True) -@pytest.mark.skipif(True, reason='Password reset no implemented') -def test_should_render_new_password_template_with_message_of_bad_token(app_, - mock_get_user_by_email): +def test_should_return_404_when_email_address_does_not_exist(app_, mock_get_user_by_email_not_found): with app_.test_request_context(): with app_.test_client() as client: - token = generate_token('no_user@d.gov.uk') + data = json.dumps({'email': 'no_user@d.gov.uk', 'created_at': str(datetime.now())}) + token = generate_token(data, app_.config['SECRET_KEY'], app_.config['DANGEROUS_SALT']) response = client.get(url_for('.new_password', token=token)) - assert response.status_code == 200 - assert 'Message about email address does not exist. Some one needs to figure out the words here.' in \ - response.get_data(as_text=True) + assert response.status_code == 404 -@pytest.mark.skipif(True, reason='Password reset no implemented') def test_should_redirect_to_two_factor_when_password_reset_is_successful(app_, mock_get_user_by_email_request_password_reset, - mock_login): + mock_login, + mock_send_verify_code): with app_.test_request_context(): with app_.test_client() as client: user = mock_get_user_by_email_request_password_reset.return_value - token = generate_token(user.email_address) + data = json.dumps({'email': user.email_address, 'created_at': str(datetime.now())}) + token = generate_token(data, app_.config['SECRET_KEY'], app_.config['DANGEROUS_SALT']) response = client.post(url_for('.new_password', token=token), data={'new_password': 'a-new_password'}) assert response.status_code == 302 assert response.location == url_for('.two_factor', _external=True) + mock_get_user_by_email_request_password_reset.assert_called_once_with(user.email_address) + + +def test_should_redirect_index_if_user_has_already_changed_password(app_, + mock_get_user_by_email_user_changed_password, + mock_login, + mock_send_verify_code): + with app_.test_request_context(): + with app_.test_client() as client: + user = mock_get_user_by_email_user_changed_password.return_value + data = json.dumps({'email': user.email_address, 'created_at': str(datetime.now())}) + token = generate_token(data, app_.config['SECRET_KEY'], app_.config['DANGEROUS_SALT']) + response = client.post(url_for('.new_password', token=token), data={'new_password': 'a-new_password'}) + assert response.status_code == 302 + assert response.location == url_for('.index', _external=True) + mock_get_user_by_email_user_changed_password.assert_called_once_with(user.email_address) def test_should_redirect_to_forgot_password_with_flash_message_when_token_is_expired( - app_, mock_get_user_by_email_request_password_reset, mock_login + app_, mock_get_user_by_email_request_password_reset, mock_login ): with app_.test_request_context(): with app_.test_client() as client: app_.config['TOKEN_MAX_AGE_SECONDS'] = -1000 user = mock_get_user_by_email_request_password_reset.return_value - token = generate_token(user.email_address) + token = generate_token(user.email_address, app_.config['SECRET_KEY'], app_.config['DANGEROUS_SALT']) response = client.post(url_for('.new_password', token=token), data={'new_password': 'a-new_password'}) assert response.status_code == 302 assert response.location == url_for('.forgot_password', _external=True) app_.config['TOKEN_MAX_AGE_SECONDS'] = 3600 - - -@pytest.mark.skipif(True, reason='Password reset no implemented') -def test_should_redirect_to_forgot_pass_when_user_active_should_be_request_passw_reset( - app_, mock_get_user_by_email_request_password_reset, mock_login -): - with app_.test_request_context(): - with app_.test_client() as client: - user = mock_get_user_by_email_request_password_reset.return_value - token = generate_token(user.email_address) - response = client.post(url_for('.new_password', token=token), data={'new_password': 'a-new_password'}) - assert response.status_code == 302 - assert response.location == url_for('.index', _external=True) diff --git a/tests/conftest.py b/tests/conftest.py index 0e55f6a78..206d8ae3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import uuid -from datetime import date +from datetime import date, datetime, timedelta import pytest from app import create_app @@ -268,9 +268,27 @@ def api_user_request_password_reset(): 'password': 'somepassword', 'email_address': 'test@user.gov.uk', 'mobile_number': '+4412341234', - 'state': 'request_password_reset', + 'state': 'active', 'failed_login_count': 5, - 'permissions': {} + 'permissions': {}, + 'password_changed_at': None + } + user = User(user_data) + return user + + +@pytest.fixture(scope='function') +def api_user_changed_password(): + from app.notify_client.user_api_client import User + user_data = {'id': 555, + 'name': 'Test User', + 'password': 'somepassword', + 'email_address': 'test@user.gov.uk', + 'mobile_number': '+4412341234', + 'state': 'active', + 'failed_login_count': 5, + 'permissions': {}, + 'password_changed_at': str(datetime.now() + timedelta(minutes=1)) } user = User(user_data) return user @@ -345,6 +363,13 @@ def mock_get_user_by_email_request_password_reset(mocker, api_user_request_passw return_value=api_user_request_password_reset) +@pytest.fixture(scope='function') +def mock_get_user_by_email_user_changed_password(mocker, api_user_changed_password): + return mocker.patch( + 'app.user_api_client.get_user_by_email', + return_value=api_user_changed_password) + + @pytest.fixture(scope='function') def mock_get_user_by_email_locked(mocker, api_user_locked): return mocker.patch( @@ -382,14 +407,6 @@ def mock_verify_password(mocker): side_effect=_verify_password) -@pytest.fixture(scope='function') -def mock_password_reset(mocker, api_user_active): - - def _reset(email): - api_user_active.state = 'request_password_reset' - return mocker.patch('app.main.dao.users_dao.request_password_reset', side_effect=_reset) - - @pytest.fixture(scope='function') def mock_update_user(mocker): @@ -645,3 +662,8 @@ def mock_add_user_to_service(mocker, service_one, api_user_active): @pytest.fixture(scope='function') def mock_set_user_permissions(mocker): return mocker.patch('app.user_api_client.set_user_permissions', return_value=None) + + +@pytest.fixture(scope='function') +def mock_reset_user_password(mocker): + return mocker.patch('app.user_api_client.send_reset_password_url', return_value=None) From e735d772fe4dae73c1d28b4647ef7f66f33f6eb0 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Tue, 8 Mar 2016 14:58:29 +0000 Subject: [PATCH 02/10] Added a test to check that the password is updated when the password exists in the session object on the two-factor page. --- app/main/views/two_factor.py | 4 ++-- tests/app/main/views/test_new_password.py | 1 - tests/app/main/views/test_two_factor.py | 29 +++++++++++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 325225ae2..1450a9d8d 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -1,6 +1,6 @@ from flask import ( - render_template, redirect, jsonify, session, url_for) + render_template, redirect, session, url_for) from flask_login import login_user @@ -33,7 +33,7 @@ def two_factor(): login_user(user, remember=form.remember_me.data if form.remember_me.data else False) finally: del session['user_details'] - if (len(services) == 1): + if len(services) == 1: return redirect(url_for('main.service_dashboard', service_id=services[0]['id'])) else: return redirect(url_for('main.choose_service')) diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py index 84106354e..6ad5ef075 100644 --- a/tests/app/main/views/test_new_password.py +++ b/tests/app/main/views/test_new_password.py @@ -3,7 +3,6 @@ from datetime import datetime from flask import url_for from utils.url_safe_token import generate_token -import pytest def test_should_render_new_password_template(app_, diff --git a/tests/app/main/views/test_two_factor.py b/tests/app/main/views/test_two_factor.py index a89d8b03c..e2ce5fcc0 100644 --- a/tests/app/main/views/test_two_factor.py +++ b/tests/app/main/views/test_two_factor.py @@ -1,7 +1,5 @@ from flask import url_for -from tests import create_test_user - def test_should_render_two_factor_page(app_, api_user_active, @@ -109,3 +107,30 @@ def test_remember_me_set(app_, response = client.post(url_for('main.two_factor'), data={'sms_code': '23456', 'remember_me': True}) assert response.status_code == 302 + + +def test_two_factor_should_set_password_when_new_password_exists_in_session(app_, + api_user_active, + mock_get_user, + mock_check_verify_code, + mock_get_services_with_one_service, + mock_update_user): + with app_.test_request_context(): + with app_.test_client() as client: + with client.session_transaction() as session: + session['user_details'] = { + 'id': api_user_active.id, + 'email': api_user_active.email_address, + 'password': 'changedpassword'} + + response = client.post(url_for('main.two_factor'), + data={'sms_code': '12345'}) + + assert response.status_code == 302 + assert response.location == url_for( + 'main.service_dashboard', + service_id="596364a0-858e-42c8-9062-a8fe822260eb", + _external=True + ) + api_user_active.password = 'changedpassword' + mock_update_user.assert_called_once_with(api_user_active) From 4678a12d334d74656eb3db90d8ee397e95ad23cf Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Tue, 8 Mar 2016 16:29:05 +0000 Subject: [PATCH 03/10] Revert the disabled email field on the register-invited-user page, the email address is not being submitted on the form when registering --- app/main/forms.py | 2 +- app/templates/views/register-from-invite.html | 2 +- tests/app/main/views/test_accept_invite.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/main/forms.py b/app/main/forms.py index 3cbfb7390..ad9036e1c 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -104,7 +104,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/templates/views/register-from-invite.html b/app/templates/views/register-from-invite.html index b08e8d24f..7e7e81aaa 100644 --- a/app/templates/views/register-from-invite.html +++ b/app/templates/views/register-from-invite.html @@ -12,12 +12,12 @@ Create an account – GOV.UK Notify

Create an account

- {{ textbox(form.email_address, width='3-4', disabled=True ) }} {{ textbox(form.name, width='3-4') }} {{ textbox(form.mobile_number, width='3-4') }} {{ textbox(form.password, hint="Your password must have at least 10 characters", width='3-4') }} {{ page_footer("Continue") }} {{form.service}} + {{form.email_address}}
diff --git a/tests/app/main/views/test_accept_invite.py b/tests/app/main/views/test_accept_invite.py index cfd1abb18..a302def3f 100644 --- a/tests/app/main/views/test_accept_invite.py +++ b/tests/app/main/views/test_accept_invite.py @@ -101,13 +101,13 @@ def test_new_user_accept_invite_calls_api_and_views_registration_page(app_, assert page.h1.string.strip() == 'Create an account' form = page.find('form') - email = form.find('input', id='email_address') name = form.find('input', id='name') password = form.find('input', id='password') service = form.find('input', type='hidden', id='service') + email = form.find('input', type='hidden', id='email_address') assert email - assert email.attrs['disabled'] + assert email.attrs['value'] == 'invited_user@test.gov.uk' assert name assert password assert service From a1c4600b29068dd0791443b92ceb04a79b8eb51c Mon Sep 17 00:00:00 2001 From: Nicholas Staples Date: Tue, 8 Mar 2016 16:54:07 +0000 Subject: [PATCH 04/10] Exact permissions added. --- app/notify_client/models.py | 2 +- tests/app/main/test_utils.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/notify_client/models.py b/app/notify_client/models.py index eacd0decb..9c8093ff4 100644 --- a/app/notify_client/models.py +++ b/app/notify_client/models.py @@ -88,7 +88,7 @@ class User(UserMixin): if service_id in self._permissions: if or_: return any([x in self._permissions[service_id] for x in permissions]) - return set(self._permissions[service_id]) > set(permissions) + return set(self._permissions[service_id]) >= set(permissions) return False @property diff --git a/tests/app/main/test_utils.py b/tests/app/main/test_utils.py index 6ab9b555f..3356ad7ef 100644 --- a/tests/app/main/test_utils.py +++ b/tests/app/main/test_utils.py @@ -58,6 +58,18 @@ def test_user_has_permissions_multiple(app_, response = decorated_index() +def test_exact_permissions(app_, + api_user_active, + mock_login, + mock_get_user_with_permissions): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(api_user_active) + decorator = user_has_permissions('manage_users', 'manage_templates', 'manage_settings') + decorated_index = decorator(index) + response = decorated_index() + + def test_validate_header_row(): row = {'bad': '+44 7700 900981'} try: From 0a5bf0bc442ea5216fd3dd1c397440868211b74a Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Mon, 7 Mar 2016 09:54:53 +0000 Subject: [PATCH 05/10] Update to utils 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and remove the code from this app that has moved into utils. --- app/utils.py | 78 ------------------------------------------------ requirements.txt | 2 +- 2 files changed, 1 insertion(+), 79 deletions(-) diff --git a/app/utils.py b/app/utils.py index 96bf0a8fd..bdaa9d5f0 100644 --- a/app/utils.py +++ b/app/utils.py @@ -32,84 +32,6 @@ class BrowsableItem(object): pass -class InvalidEmailError(Exception): - def __init__(self, message): - self.message = message - - -class InvalidPhoneError(Exception): - def __init__(self, message): - self.message = message - - -class InvalidHeaderError(Exception): - def __init__(self, message): - self.message = message - - -def validate_phone_number(number): - sanitised_number = number.replace('(', '') - sanitised_number = sanitised_number.replace(')', '') - sanitised_number = sanitised_number.replace(' ', '') - sanitised_number = sanitised_number.replace('-', '') - - if sanitised_number.startswith('+'): - sanitised_number = sanitised_number[1:] - - valid_prefixes = ['07', '447', '4407', '00447'] - if not sum(sanitised_number.startswith(prefix) for prefix in valid_prefixes): - raise InvalidPhoneError('Must be a UK mobile number (eg 07700 900460)') - - for digit in sanitised_number: - try: - int(digit) - except(ValueError): - raise InvalidPhoneError('Must not contain letters or symbols') - - # Split number on first 7 - sanitised_number = sanitised_number.split('7', 1)[1] - - if len(sanitised_number) > 9: - raise InvalidPhoneError('Too many digits') - - if len(sanitised_number) < 9: - raise InvalidPhoneError('Not enough digits') - - return sanitised_number - - -def format_phone_number(number): - import re - if len(number) > 9: - raise InvalidPhoneError('Too many digits') - - if len(number) < 9: - raise InvalidPhoneError('Not enough digits') - return '+447{}{}{}'.format(*re.findall('...', number)) - - -def validate_email_address(email_address): - if re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", email_address): - return - raise InvalidEmailError('Not a valid email address') - - -def validate_recipient(row, template_type): - validate_header_row(row, template_type) - return { - 'email': validate_email_address, - 'sms': validate_phone_number - }[template_type](get_recipient_from_row(row, template_type)) - - -def validate_header_row(row, template_type): - try: - column_heading = first_column_heading[template_type] - row[column_heading] - except KeyError as e: - raise InvalidHeaderError('Invalid header name, should be {}'.format(column_heading)) - - def user_has_permissions(*permissions, or_=False): def wrap(func): @wraps(func) diff --git a/requirements.txt b/requirements.txt index 7a0048d08..85ed0e61d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,4 @@ Pygments==2.0.2 git+https://github.com/alphagov/notifications-python-client.git@0.3.1#egg=notifications-python-client==0.3.1 -git+https://github.com/alphagov/notifications-utils.git@1.0.0#egg=notifications-utils==1.0.0 +git+https://github.com/alphagov/notifications-utils.git@2.0.0#egg=notifications-utils==2.0.0 From eb3734f1d18d478a2d235a41d4fbf8c10381ddd0 Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Mon, 7 Mar 2016 18:47:05 +0000 Subject: [PATCH 06/10] Give the user better error messages for CSV files Makes uses of the additions to utils in https://github.com/alphagov/notifications-utils/pull/9 This commit strips out a lot of the complex stuff that the views and templates in this app were doing. There is now a cleaner separation of concerns: - utils returns the number and type of errors in the csv - `get_errors_for_csv` helper in this app maps the number and type of errors onto human-friendly error messages - the view and template just doing the glueing-together of all the pieces This is (hopefully) easier to understand, definitely makes the component parts easier to test in isolation, and makes it easier to give more specific error messages. --- app/__init__.py | 2 +- app/assets/stylesheets/components/banner.scss | 12 ++ app/assets/stylesheets/components/table.scss | 31 ++- app/main/forms.py | 2 +- app/main/uploader.py | 2 +- app/main/views/send.py | 193 ++++++++---------- app/templates/components/banner.html | 4 + app/templates/components/file-upload.html | 2 +- app/templates/views/check.html | 104 ++++++---- app/utils.py | 43 +++- tests/app/main/test_errors_for_csv.py | 79 +++++++ .../{test_utils.py => test_permissions.py} | 10 +- tests/app/main/views/test_register.py | 2 +- tests/app/main/views/test_send.py | 109 +++------- 14 files changed, 340 insertions(+), 255 deletions(-) create mode 100644 tests/app/main/test_errors_for_csv.py rename tests/app/main/{test_utils.py => test_permissions.py} (89%) 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/forms.py b/app/main/forms.py index ad9036e1c..33e4d1c0c 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 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/send.py b/app/main/views/send.py index 3784ba7c2..8125502db 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,81 @@ 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 session['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') + + try: + 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') + ) + 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) ) - 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/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") %} -
+