Merge branch 'master' into error-handling

Conflicts:
	app/main/views/invites.py
This commit is contained in:
Rebecca Law
2016-03-10 15:03:21 +00:00
16 changed files with 219 additions and 80 deletions

View File

@@ -33,7 +33,7 @@
}; };
this.update = () => this.$backgroundMaskForeground.html( this.update = () => this.$backgroundMaskForeground.html(
this.$textbox.val().replace( $('<div/>').text(this.$textbox.val()).html().replace(
tagPattern, match => `<span class='tag'>${match}</span>` tagPattern, match => `<span class='tag'>${match}</span>`
) )
); );

View File

@@ -1,7 +1,6 @@
from flask import url_for, abort from flask import url_for
from app import notifications_api_client from app import notifications_api_client
from app.utils import BrowsableItem from app.utils import BrowsableItem
from notifications_python_client.errors import HTTPError
def insert_service_template(name, type_, content, service_id, subject=None): def insert_service_template(name, type_, content, service_id, subject=None):

View File

@@ -98,13 +98,26 @@ class RegisterUserFromInviteForm(Form):
email_address = HiddenField('email_address') email_address = HiddenField('email_address')
# WTF forms does not give a handy way to customise error messages for
# radio button fields so just overriding the default here for use
# in permissions form.
class CustomRadioField(RadioField):
def pre_validate(self, form):
for v, _ in self.choices:
if self.data == v:
break
else:
raise ValueError(self.gettext('Choose yes or no'))
class PermisisonsForm(Form): class PermisisonsForm(Form):
# TODO fix this Radio field so we are not having to test for yes or no rather # TODO fix this Radio field so we are not having to test for yes or no rather
# use operator equality. # use operator equality.
send_messages = RadioField("Send messages", choices=[('yes', 'yes'), ('no', 'no')]) send_messages = CustomRadioField("Send messages", choices=[('yes', 'yes'), ('no', 'no')])
manage_service = RadioField("Manage service", choices=[('yes', 'yes'), ('no', 'no')]) manage_service = CustomRadioField("Manage service", choices=[('yes', 'yes'), ('no', 'no')])
manage_api_keys = RadioField("Manage API keys", choices=[('yes', 'yes'), ('no', 'no')]) manage_api_keys = CustomRadioField("Manage API keys", choices=[('yes', 'yes'), ('no', 'no')])
class InviteUserForm(PermisisonsForm): class InviteUserForm(PermisisonsForm):
@@ -192,7 +205,24 @@ class AddServiceForm(Form):
class ServiceNameForm(Form): class ServiceNameForm(Form):
name = StringField(u'New name') def __init__(self, names_func, *args, **kwargs):
"""
Keyword arguments:
names_func -- Returns a list of unique service_names already registered
on the system.
"""
self._names_func = names_func
super(ServiceNameForm, self).__init__(*args, **kwargs)
name = StringField(
u'New name',
validators=[
DataRequired(message='Service name cant be empty')
])
def validate_name(self, a):
if a.data in self._names_func():
raise ValidationError('This service name is already in use')
class ConfirmPasswordForm(Form): class ConfirmPasswordForm(Form):

View File

@@ -2,8 +2,8 @@ from flask import (
redirect, redirect,
url_for, url_for,
session, session,
render_template flash,
) render_template)
from app.main import main from app.main import main
@@ -17,6 +17,7 @@ from app import (
@main.route("/invitation/<token>") @main.route("/invitation/<token>")
def accept_invite(token): def accept_invite(token):
invited_user = invite_api_client.check_token(token) invited_user = invite_api_client.check_token(token)
if invited_user.status == 'cancelled': if invited_user.status == 'cancelled':
from_user = user_api_client.get_user(invited_user.from_user) from_user = user_api_client.get_user(invited_user.from_user)
service = get_service_by_id_or_404(invited_user.service) service = get_service_by_id_or_404(invited_user.service)
@@ -24,6 +25,11 @@ def accept_invite(token):
from_user=from_user.name, from_user=from_user.name,
service_name=service['name']) service_name=service['name'])
if invited_user.status == 'accepted':
session.pop('invited_user', None)
flash('You have already accepted this invitation', 'default')
return redirect(url_for('main.service_dashboard', service_id=invited_user.service))
existing_user = user_api_client.get_user_by_email(invited_user.email_address) existing_user = user_api_client.get_user_by_email(invited_user.email_address)
session['invited_user'] = invited_user.serialize() session['invited_user'] = invited_user.serialize()

View File

@@ -100,28 +100,6 @@ def edit_user_permissions(service_id, user_id):
) )
@main.route("/services/<service_id>/users/<user_id>/delete", methods=['GET', 'POST'])
@login_required
@user_has_permissions('manage_users', 'manage_templates', 'manage_settings')
def delete_user(service_id, user_id):
user = user_api_client.get_user(user_id)
service = get_service_by_id(service_id)
if request.method == 'POST':
return redirect(url_for('.manage_users', service_id=service_id))
flash(
'Are you sure you want to delete {}s account?'.format(user.get('name') or user['email_localpart']),
'delete'
)
return render_template(
'views/invite-user.html',
user=user,
service_id=service_id
)
@main.route("/services/<service_id>/cancel-invited-user/<invited_user_id>", methods=['GET']) @main.route("/services/<service_id>/cancel-invited-user/<invited_user_id>", methods=['GET'])
@user_has_permissions('manage_users', 'manage_templates', 'manage_settings') @user_has_permissions('manage_users', 'manage_templates', 'manage_settings')
def cancel_invited_user(service_id, invited_user_id): def cancel_invited_user(service_id, invited_user_id):

View File

@@ -28,9 +28,14 @@ def register():
form = RegisterUserForm() form = RegisterUserForm()
if form.validate_on_submit(): if form.validate_on_submit():
return _do_registration(form) registered = _do_registration(form)
else: if registered:
return render_template('views/register.html', form=form) return redirect(url_for('main.verify'))
else:
flash('There was an error registering your account')
return render_template('views/register.html', form=form), 400
return render_template('views/register.html', form=form)
@main.route('/register-from-invite', methods=['GET', 'POST']) @main.route('/register-from-invite', methods=['GET', 'POST'])
@@ -44,7 +49,11 @@ def register_from_invite():
if form.validate_on_submit(): if form.validate_on_submit():
if form.service.data != invited_user['service'] or form.email_address.data != invited_user['email_address']: if form.service.data != invited_user['service'] or form.email_address.data != invited_user['email_address']:
abort(400) abort(400)
return _do_registration(form) registered = _do_registration(form)
if registered:
return redirect(url_for('main.verify'))
else:
flash('There was an error registering your account')
form.service.data = invited_user['service'] form.service.data = invited_user['service']
form.email_address.data = invited_user['email_address'] form.email_address.data = invited_user['email_address']
@@ -68,6 +77,6 @@ def _do_registration(form, service=None):
users_dao.send_verify_code(user.id, 'email', user.email_address) users_dao.send_verify_code(user.id, 'email', user.email_address)
session['expiry_date'] = str(datetime.now() + timedelta(hours=1)) session['expiry_date'] = str(datetime.now() + timedelta(hours=1))
session['user_details'] = {"email": user.email_address, "id": user.id} session['user_details'] = {"email": user.email_address, "id": user.id}
return redirect(url_for('main.verify')) return True
else: else:
flash('There was an error registering your account') return False

View File

@@ -12,11 +12,13 @@ from flask_login import (
login_required, login_required,
current_user current_user
) )
from notifications_python_client import HTTPError
from app.main.dao.services_dao import ( from app.main.dao.services_dao import (
get_service_by_id, get_service_by_id,
delete_service, delete_service,
update_service update_service,
find_all_service_names
) )
from app.main import main from app.main import main
@@ -44,7 +46,7 @@ def service_settings(service_id):
def service_name_change(service_id): def service_name_change(service_id):
service = get_service_by_id(service_id)['data'] service = get_service_by_id(service_id)['data']
form = ServiceNameForm() form = ServiceNameForm(find_all_service_names)
if form.validate_on_submit(): if form.validate_on_submit():
session['service_name_change'] = form.name.data session['service_name_change'] = form.name.data
@@ -70,10 +72,20 @@ def service_name_change_confirm(service_id):
if form.validate_on_submit(): if form.validate_on_submit():
service['name'] = session['service_name_change'] service['name'] = session['service_name_change']
update_service(service) try:
session['service_name'] = service['name'] update_service(service)
session.pop('service_name_change') except HTTPError as e:
return redirect(url_for('.service_settings', service_id=service_id)) error_msg = "Duplicate service name '{}'".format(session['service_name_change'])
if e.status_code == 400 and error_msg in e.message['name']:
# Redirect the user back to the change service name screen
flash('This service name is already in use', 'error')
return redirect(url_for('main.service_name_change', service_id=service_id))
else:
raise e
else:
session['service_name'] = service['name']
session.pop('service_name_change')
return redirect(url_for('.service_settings', service_id=service_id))
return render_template( return render_template(
'views/service-settings/confirm.html', 'views/service-settings/confirm.html',
heading='Change your service name', heading='Change your service name',

View File

@@ -1,6 +1,7 @@
from flask import render_template, current_app, abort from flask import render_template, current_app, abort
from flask_wtf import Form from flask_wtf import Form
from wtforms import StringField, PasswordField, TextAreaField, FileField, validators from wtforms import StringField, PasswordField, TextAreaField, FileField, validators
from app.main.forms import CustomRadioField
from utils.template import Template from utils.template import Template
from app.main import main from app.main import main
@@ -17,11 +18,14 @@ def styleguide():
code = StringField('Enter code') code = StringField('Enter code')
message = TextAreaField(u'Message') message = TextAreaField(u'Message')
file_upload = FileField('Upload a CSV file to add your recipients details') file_upload = FileField('Upload a CSV file to add your recipients details')
manage_service = CustomRadioField('Manage service', choices=[('yes', 'yes'), ('no', 'no')])
manage_templates = CustomRadioField('Manage templates', choices=[('yes', 'yes'), ('no', 'no')])
sms = "Your vehicle tax for ((registration number)) is due on ((date)). Renew online at www.gov.uk/vehicle-tax" sms = "Your vehicle tax for ((registration number)) is due on ((date)). Renew online at www.gov.uk/vehicle-tax"
form = FormExamples() form = FormExamples()
form.message.data = sms form.message.data = sms
form.manage_service.data = 'yes'
form.validate() form.validate()
template = Template({'content': sms}) template = Template({'content': sms})

View File

@@ -1,15 +1,20 @@
{% macro yes_no(name, label, current_value=None) %} {% macro yes_no(field, current_value=None) %}
<fieldset class='yes-no'> <fieldset class='yes-no {% if field.errors %} error{% endif %}'>
<legend class='yes-no-label'> <legend class='yes-no-label'>
{{ label }} {{ field.label }}
{% if field.errors %}
<span class="error-message">
{{ field.errors[0] }}
</span>
{% endif %}
</legend> </legend>
<div class='yes-no-fields inline'> <div class='yes-no-fields inline'>
<label class='block-label'> <label class='block-label'>
<input type='radio' name='{{ name }}' value='yes' {% if current_value == 'yes' %}checked{% endif %} /> <input type='radio' name='{{ field.name }}' value='yes' {% if current_value == 'yes' %}checked{% endif %} />
Yes Yes
</label> </label>
<label class='block-label'> <label class='block-label'>
<input type='radio' name='{{ name }}' value='no' {% if current_value == 'no' %}checked{% endif %} /> <input type='radio' name='{{ field.name }}' value='no' {% if current_value == 'no' %}checked{% endif %} />
No No
</label> </label>
</div> </div>

View File

@@ -21,22 +21,16 @@ Manage users GOV.UK Notify
Permissions Permissions
</legend> </legend>
<span class="form-hint">All team members can see message history</span> <span class="form-hint">All team members can see message history</span>
{{ yes_no(form.send_messages.name, form.send_messages.label, form.send_messages.data) }} {{ yes_no(form.send_messages, form.send_messages.data) }}
{{ yes_no(form.manage_service.name, form.manage_service.label, form.manage_service.data) }} {{ yes_no(form.manage_service, form.manage_service.data) }}
{{ yes_no(form.manage_api_keys.name, form.manage_api_keys.label, form.manage_api_keys.data) }} {{ yes_no(form.manage_api_keys, form.manage_api_keys.data) }}
</fieldset> </fieldset>
{% if user %} {{ page_footer(
{{ page_footer( 'Save',
'Save', back_link=url_for('.manage_users', service_id=service_id),
delete_link=url_for('.delete_user', service_id=service_id, user_id=user_id), back_link_text='Cancel'
delete_link_text='Delete this account', ) }}
back_link=url_for('.manage_users', service_id=service_id),
back_link_text='Cancel'
) }}
{% else %}
{{ page_footer('Send invitation email') }}
{% endif %}
</form> </form>
</div> </div>

View File

@@ -23,22 +23,12 @@ Manage users GOV.UK Notify
Permissions Permissions
</legend> </legend>
<span class="form-hint">All team members can see message history</span> <span class="form-hint">All team members can see message history</span>
{{ yes_no(form.send_messages.name, form.send_messages.label, form.send_messages.data) }} {{ yes_no(form.send_messages, form.send_messages.data) }}
{{ yes_no(form.manage_service.name, form.manage_service.label, form.manage_service.data) }} {{ yes_no(form.manage_service, form.manage_service.data) }}
{{ yes_no(form.manage_api_keys.name, form.manage_api_keys.label, form.manage_api_keys.data) }} {{ yes_no(form.manage_api_keys, form.manage_api_keys.data) }}
</fieldset> </fieldset>
{% if user %} {{ page_footer('Send invitation email') }}
{{ page_footer(
'Save',
delete_link=url_for('.delete_user', service_id=service_id, user_id=user_id),
delete_link_text='Delete this account',
back_link=url_for('.manage_users', service_id=service_id),
back_link_text='Cancel'
) }}
{% else %}
{{ page_footer('Send invitation email') }}
{% endif %}
</form> </form>
</div> </div>

View File

@@ -247,8 +247,8 @@
<div class="grid-row"> <div class="grid-row">
<div class='column-half'> <div class='column-half'>
<fieldset class='yes-no-wrapper'> <fieldset class='yes-no-wrapper'>
{{ yes_no('manage_service', 'Manage service', True) }} {{ yes_no(form.manage_service, form.manage_service.data) }}
{{ yes_no('templates', 'Create templates', True) }} {{ yes_no(form.manage_templates, form.manage_templates.data) }}
</fieldset> </fieldset>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,8 @@ from flask import url_for
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import app import app
from app.notify_client.models import InvitedUser
from tests.conftest import sample_invite as create_sample_invite from tests.conftest import sample_invite as create_sample_invite
from tests.conftest import mock_check_invite_token as mock_check_token_invite from tests.conftest import mock_check_invite_token as mock_check_token_invite
@@ -34,6 +36,25 @@ def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard(app_,
assert response.location == expected_redirect_location assert response.location == expected_redirect_location
def test_existing_user_cant_accept_twice(app_,
mocker,
sample_invite):
sample_invite['status'] = 'accepted'
invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=invite)
with app_.test_request_context():
with app_.test_client() as client:
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True)
assert response.status_code == 200
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
assert page.h1.string.strip() == 'Sign in'
flash_banners = page.find_all('div', class_='banner-default')
assert len(flash_banners) == 2
assert flash_banners[0].text.strip() == 'You have already accepted this invitation'
def test_existing_signed_out_user_accept_invite_redirects_to_sign_in(app_, def test_existing_signed_out_user_accept_invite_redirects_to_sign_in(app_,
service_one, service_one,
api_user_active, api_user_active,

View File

@@ -1,4 +1,5 @@
from flask import url_for from flask import url_for
from bs4 import BeautifulSoup
def test_render_register_returns_template_with_form(app_): def test_render_register_returns_template_with_form(app_):
@@ -106,3 +107,24 @@ def test_should_return_400_if_password_is_blacklisted(app_,
response.status_code == 200 response.status_code == 200
assert 'That password is blacklisted, too common' in response.get_data(as_text=True) assert 'That password is blacklisted, too common' in response.get_data(as_text=True)
def test_register_with_existing_email_returns_error(app_,
api_user_active,
mock_get_user_by_email):
user_data = {
'name': 'Already Hasaccount',
'email_address': api_user_active.email_address,
'mobile_number': '+4407700900460',
'password': 'validPassword!'
}
with app_.test_request_context():
response = app_.test_client().post(url_for('main.register'),
data=user_data)
assert response.status_code == 400
page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
element = page.find('h1')
assert element.text == 'Create an account'
flash_banner = page.find('div', class_='banner-dangerous').string.strip()
assert flash_banner == 'There was an error registering your account'

View File

@@ -54,8 +54,9 @@ def test_should_redirect_after_change_service_name(app_,
with app_.test_client() as client: with app_.test_client() as client:
client.login(api_user_active) client.login(api_user_active)
service_id = 123 service_id = 123
response = client.post(url_for( response = client.post(
'main.service_name_change', service_id=service_id)) url_for('main.service_name_change', service_id=service_id),
data={'name': "new name"})
assert response.status_code == 302 assert response.status_code == 302
settings_url = url_for( settings_url = url_for(
@@ -64,6 +65,27 @@ def test_should_redirect_after_change_service_name(app_,
assert mock_get_service.called assert mock_get_service.called
def test_should_not_allow_duplicate_names(app_,
api_user_active,
mock_get_service,
mock_get_user,
mock_get_user_by_email,
mock_login,
mock_has_permissions,
mock_get_services):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
service_id = 123
response = client.post(
url_for('main.service_name_change', service_id=service_id),
data={'name': "service_one"})
assert response.status_code == 200
resp_data = response.get_data(as_text=True)
assert 'This service name is already in use' in resp_data
def test_should_show_service_name_confirmation(app_, def test_should_show_service_name_confirmation(app_,
api_user_active, api_user_active,
mock_get_service, mock_get_service,
@@ -112,6 +134,32 @@ def test_should_redirect_after_service_name_confirmation(app_,
assert mock_update_service.called assert mock_update_service.called
def test_should_raise_duplicate_name_handled(app_,
api_user_active,
mock_get_service,
mock_update_service_raise_httperror_duplicate_name,
mock_get_user,
mock_get_user_by_email,
mock_login,
mock_verify_password,
mock_has_permissions):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
service_id = 123
service_new_name = 'New Name'
with client.session_transaction() as session:
session['service_name_change'] = service_new_name
response = client.post(url_for(
'main.service_name_change_confirm', service_id=service_id))
assert response.status_code == 302
name_change_url = url_for(
'main.service_name_change', service_id=service_id, _external=True)
resp_data = response.get_data(as_text=True)
assert name_change_url == response.location
def test_should_show_request_to_go_live(app_, def test_should_show_request_to_go_live(app_,
api_user_active, api_user_active,
mock_get_service, mock_get_service,

View File

@@ -1,5 +1,6 @@
import uuid import uuid
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from unittest.mock import Mock
import pytest import pytest
from app import create_app from app import create_app
@@ -18,6 +19,8 @@ from app.notify_client.models import (
InvitedUser InvitedUser
) )
from notifications_python_client.errors import HTTPError
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def app_(request): def app_(request):
@@ -90,6 +93,24 @@ def mock_update_service(mocker):
'app.notifications_api_client.update_service', side_effect=_update) 'app.notifications_api_client.update_service', side_effect=_update)
@pytest.fixture(scope='function')
def mock_update_service_raise_httperror_duplicate_name(mocker):
def _update(service_id,
service_name,
active,
limit,
restricted,
users):
json_mock = Mock(return_value={'message': {'name': ["Duplicate service name '{}'".format(service_name)]}})
resp_mock = Mock(status_code=400, json=json_mock)
http_error = HTTPError(response=resp_mock, message="Default message")
raise http_error
return mocker.patch(
'app.notifications_api_client.update_service', side_effect=_update)
SERVICE_ONE_ID = "596364a0-858e-42c8-9062-a8fe822260eb" SERVICE_ONE_ID = "596364a0-858e-42c8-9062-a8fe822260eb"
SERVICE_TWO_ID = "147ad62a-2951-4fa1-9ca0-093cd1a52c52" SERVICE_TWO_ID = "147ad62a-2951-4fa1-9ca0-093cd1a52c52"