diff --git a/app/assets/stylesheets/components/radios.scss b/app/assets/stylesheets/components/radios.scss index e7f9110bf..eb632b62f 100644 --- a/app/assets/stylesheets/components/radios.scss +++ b/app/assets/stylesheets/components/radios.scss @@ -110,3 +110,9 @@ } } + +.conditional-radio-panel { + border-left: 4px solid $border-colour; + margin: -20px 0 0 17px; + padding: 10px 0 0 28px; +} diff --git a/app/main/forms.py b/app/main/forms.py index 2da1e2de2..d359e6b8a 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1462,3 +1462,41 @@ class GoLiveNotesForm(StripWhitespaceForm): 'Go live notes', filters=[lambda x: x or None], ) + + +class AcceptAgreementForm(StripWhitespaceForm): + + version = StringField( + 'Which version of the agreement are you accepting?' + ) + + who = RadioField( + 'Who is accepting the agreement?', + choices=( + ( + 'me', + 'I’m accepting the agreement', + ), + ( + 'someone-else', + 'I’m accepting the agreement on behalf of someone else', + ), + ), + validators=[DataRequired()], + ) + + on_behalf_of_name = StringField( + 'Who are you accepting the agreement on behalf of?' + ) + + on_behalf_of_email = email_address( + 'What’s their email address?', + required=False, + gov_user=False, + ) + + def validate_version(self, field): + try: + float(field.data) + except (TypeError, ValueError): + raise ValidationError("Must be a number") diff --git a/app/main/views/agreement.py b/app/main/views/agreement.py index 3e48ba2a9..5c339648b 100644 --- a/app/main/views/agreement.py +++ b/app/main/views/agreement.py @@ -1,8 +1,11 @@ -from flask import abort, render_template, request, send_file, url_for +from datetime import datetime + +from flask import abort, redirect, render_template, request, send_file, url_for from flask_login import current_user, login_required from app import current_service from app.main import main +from app.main.forms import AcceptAgreementForm from app.main.views.sub_navigation_dictionaries import features_nav from app.s3_client.s3_mou_client import get_mou @@ -26,6 +29,60 @@ def service_agreement(service_id): ) +@main.route('/services//agreement/accept', methods=['GET', 'POST']) +@login_required +def service_accept_agreement(service_id): + + org = current_service.organisation + + if not org: + abort(404) + + form = AcceptAgreementForm( + version=org.agreement_signed_version, + who='someone-else' if ( + org.agreement_signed_on_behalf_of_name + and org.agreement_signed_on_behalf_of_email_address + ) else 'me', + on_behalf_of_name=org.agreement_signed_on_behalf_of_name, + on_behalf_of_email=org.agreement_signed_on_behalf_of_email_address, + ) + + if form.validate_on_submit(): + current_service.organisation.update( + agreement_signed_version=float(form.version.data), + agreement_signed_on_behalf_of_name=form.on_behalf_of_name.data, + agreement_signed_on_behalf_of_email_address=form.on_behalf_of_email.data, + ) + return redirect(url_for('main.service_confirm_agreement', service_id=current_service.id)) + + return render_template( + 'views/agreement/agreement-accept.html', + form=form, + ) + + +@main.route('/services//agreement/confirm', methods=['GET', 'POST']) +@login_required +def service_confirm_agreement(service_id): + + if ( + not current_service.organisation + or current_service.organisation.agreement_signed_version is None + ): + abort(403) + + if request.method == 'POST': + current_service.organisation.update( + agreement_signed=True, + agreement_signed_at=str(datetime.utcnow()), + agreement_signed_by_id=current_user.id, + ) + return redirect(url_for('main.request_to_go_live', service_id=current_service.id)) + + return render_template('views/agreement/agreement-confirm.html') + + @main.route('/agreement.pdf') @login_required def download_agreement(): diff --git a/app/models/organisation.py b/app/models/organisation.py index b0c10f863..d8aa19822 100644 --- a/app/models/organisation.py +++ b/app/models/organisation.py @@ -19,6 +19,8 @@ class Organisation(JSONModel): 'agreement_signed_at', 'agreement_signed_by_id', 'agreement_signed_version', + 'agreement_signed_on_behalf_of_name', + 'agreement_signed_on_behalf_of_email_address', 'domains', 'request_to_go_live_notes', 'count_of_live_services', @@ -156,6 +158,10 @@ class Organisation(JSONModel): key=lambda user: user.email_address.lower(), ) + def update(self, **kwargs): + response = organisations_client.update_organisation(self.id, **kwargs) + self.__init__(response) + class Organisations(ModelList): client = organisations_client.get_organisations diff --git a/app/navigation.py b/app/navigation.py index 63b6e2fa4..ad3be0723 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -244,6 +244,8 @@ class HeaderNavigation(Navigation): 'service_add_letter_contact', 'service_add_sms_sender', 'service_agreement', + 'service_accept_agreement', + 'service_confirm_agreement', 'service_confirm_delete_email_reply_to', 'service_confirm_delete_sms_sender', 'service_dashboard', @@ -377,6 +379,8 @@ class MainNavigation(Navigation): 'service_add_letter_contact', 'service_add_sms_sender', 'service_agreement', + 'service_accept_agreement', + 'service_confirm_agreement', 'service_confirm_delete_email_reply_to', 'service_confirm_delete_sms_sender', 'service_edit_email_reply_to', @@ -765,6 +769,8 @@ class CaseworkNavigation(Navigation): 'service_add_letter_contact', 'service_add_sms_sender', 'service_agreement', + 'service_accept_agreement', + 'service_confirm_agreement', 'service_confirm_delete_email_reply_to', 'service_confirm_delete_sms_sender', 'service_dashboard', @@ -1034,6 +1040,8 @@ class OrgNavigation(Navigation): 'service_add_letter_contact', 'service_add_sms_sender', 'service_agreement', + 'service_accept_agreement', + 'service_confirm_agreement', 'service_confirm_delete_email_reply_to', 'service_confirm_delete_sms_sender', 'service_dashboard', diff --git a/app/templates/views/agreement/_agreement.html b/app/templates/views/agreement/_agreement.html index 2e462192a..c9d1dfa9b 100644 --- a/app/templates/views/agreement/_agreement.html +++ b/app/templates/views/agreement/_agreement.html @@ -1,17 +1,17 @@

- Before you can go live on GOV.UK Notify, your organisation needs to agree to our data sharing and financial agreement. + To use GOV.UK Notify your organisation (Cabinet Office) must accept the GOV.UK Notify data sharing and financial agreement. +

+

+ This agreement only needs to be accepted once and will then cover all Notify services from + {{ current_service.organisation.name }}. +

+

+ It needs to be accepted by, or on behalf of someone who can sign contracts for your organisation. +

+

+ Download a copy of the agreement.

-
    -
  1. - Download the agreement. -
  2. -
  3. - Get it signed by someone who has the authority to do so on behalf of {{ owner }}. -
  4. -
  5. - Return the signed copy to notify-support@digital.cabinet-office.gov.uk. -
  6. -

The agreement contains commercially sensitive information, so don’t share it more widely than you need to.

+Continue diff --git a/app/templates/views/agreement/agreement-accept.html b/app/templates/views/agreement/agreement-accept.html new file mode 100644 index 000000000..c2972bf6a --- /dev/null +++ b/app/templates/views/agreement/agreement-accept.html @@ -0,0 +1,37 @@ +{% extends "withnav_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/radios.html" import radios %} +{% from "components/form.html" import form_wrapper %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/page-header.html" import page_header %} + +{% block per_page_title %} + Accept data sharing and financial agreement +{% endblock %} + +{% block maincolumn_content %} + +
+
+ + {{ page_header( + 'Accept data sharing and financial agreement', + back_link=url_for('main.service_agreement', service_id=current_service.id) + ) }} + + {% call form_wrapper(class='top-gutter') %} + + {{ textbox(form.version, width='1-3', hint='The version number is on the front page, for example ‘3.6’') }} + {{ radios(form.who) }} +
+ {{ textbox(form.on_behalf_of_name, width='1-1') }} + {{ textbox(form.on_behalf_of_email, width='1-1') }} +
+ {{ page_footer('Continue') }} + + {% endcall %} + +
+
+ +{% endblock %} diff --git a/app/templates/views/agreement/agreement-confirm.html b/app/templates/views/agreement/agreement-confirm.html new file mode 100644 index 000000000..80e0bbad8 --- /dev/null +++ b/app/templates/views/agreement/agreement-confirm.html @@ -0,0 +1,39 @@ +{% extends "withnav_template.html" %} +{% from "components/form.html" import form_wrapper %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/page-header.html" import page_header %} + +{% block per_page_title %} + GOV.UK Notify data sharing and financial agreement +{% endblock %} + +{% block maincolumn_content %} + +
+
+ + {{ page_header( + 'GOV.UK Notify data sharing and financial agreement', + back_link=url_for('main.service_accept_agreement', service_id=current_service.id) + ) }} + + {% call form_wrapper(class='top-gutter') %} + + {% if current_service.organisation.agreement_signed_on_behalf_of_name and current_service.organisation.agreement_signed_on_behalf_of_email_address %} +

+ I confirm that I have the legal authority to accept the GOV.UK Notify data sharing and financial agreement (version {{ current_service.organisation.agreement_signed_version }}) on behalf of {{ current_service.organisation.agreement_signed_on_behalf_of_name }} ({{ current_service.organisation.agreement_signed_on_behalf_of_email_address }}) and that {{ current_service.organisation.name }} will be bound by it. +

+ {% else %} +

+ I confirm that I have the legal authority to accept the GOV.UK Notify data sharing and financial agreement (version {{ current_service.organisation.agreement_signed_version }}) and that {{ current_service.organisation.name }} will be bound by it. +

+ {% endif %} + + {{ page_footer('Accept this agreement') }} + + {% endcall %} + +
+
+ +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py index 777ccb7a6..d1ef1ddbc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -190,6 +190,9 @@ def organisation_json( domains=None, crown=True, agreement_signed=False, + agreement_signed_version=None, + agreement_signed_on_behalf_of_name=None, + agreement_signed_on_behalf_of_email_address=None, organisation_type='', request_to_go_live_notes=None, ): @@ -210,6 +213,9 @@ def organisation_json( 'agreement_signed': agreement_signed, 'agreement_signed_at': None, 'agreement_signed_by': None, + 'agreement_signed_version': agreement_signed_version, + 'agreement_signed_on_behalf_of_name': agreement_signed_on_behalf_of_name, + 'agreement_signed_on_behalf_of_email_address': agreement_signed_on_behalf_of_email_address, 'domains': domains or [], 'request_to_go_live_notes': request_to_go_live_notes, 'count_of_live_services': len(services), diff --git a/tests/app/main/views/test_agreement.py b/tests/app/main/views/test_agreement.py index c4934623d..9d2d23562 100644 --- a/tests/app/main/views/test_agreement.py +++ b/tests/app/main/views/test_agreement.py @@ -3,11 +3,14 @@ from io import BytesIO import pytest from flask import url_for +from freezegun import freeze_time +from tests import organisation_json from tests.conftest import ( SERVICE_ONE_ID, mock_get_organisation_by_domain, mock_get_service_organisation, + normalize_spaces, ) @@ -49,7 +52,14 @@ class _MockS3Object(): False, False, [ partial(url_for, 'main.download_agreement'), - lambda: 'mailto:notify-support@digital.cabinet-office.gov.uk', + partial(url_for, 'main.service_accept_agreement', service_id=SERVICE_ONE_ID), + ] + ), + ( + False, True, + [ + partial(url_for, 'main.download_agreement'), + partial(url_for, 'main.service_accept_agreement', service_id=SERVICE_ONE_ID), ] ), ( @@ -67,6 +77,7 @@ def test_show_agreement_page( mocker, fake_uuid, mock_has_jobs, + mock_get_service_organisation, agreement_signed, crown, expected_links, @@ -89,6 +100,203 @@ def test_show_agreement_page( assert link['href'] == expected_links[index]() +def test_show_accept_agreement_page( + client_request, + mocker, + mock_get_service_organisation, +): + page = client_request.get('main.service_accept_agreement', service_id=SERVICE_ONE_ID) + + assert [ + (input['type'], input['name'], input.get('id')) for input in page.select('input') + ] == [ + ('text', 'version', 'version'), + ('radio', 'who', 'who-0'), + ('radio', 'who', 'who-1'), + ('text', 'on_behalf_of_name', 'on_behalf_of_name'), + ('email', 'on_behalf_of_email', 'on_behalf_of_email'), + ('hidden', 'csrf_token', None), + ] + + assert normalize_spaces(page.select_one('label[for=version]').text) == ( + 'Which version of the agreement are you accepting? ' + 'The version number is on the front page, for example ‘3.6’' + ) + assert page.select_one('input[name=version]')['value'] == '' + + assert normalize_spaces(page.select_one('#who legend').text) == ( + 'Who is accepting the agreement?' + ) + assert normalize_spaces(page.select_one('label[for=who-0]').text) == ( + 'I’m accepting the agreement' + ) + assert page.select('input[name=who]')[0]['value'] == 'me' + assert normalize_spaces(page.select_one('label[for=who-1]').text) == ( + 'I’m accepting the agreement on behalf of someone else' + ) + assert page.select('input[name=who]')[1]['value'] == 'someone-else' + + assert normalize_spaces(page.select_one('label[for=on_behalf_of_name]').text) == ( + 'Who are you accepting the agreement on behalf of?' + ) + assert page.select_one('input[name=on_behalf_of_name]')['value'] == '' + + assert normalize_spaces(page.select_one('label[for=on_behalf_of_email]').text) == ( + 'What’s their email address?' + ) + assert page.select_one('input[name=on_behalf_of_email]')['value'] == '' + + +def test_accept_agreement_page_populates( + client_request, + mocker, + mock_get_service_organisation, +): + mocker.patch( + 'app.models.organisation.organisations_client.get_service_organisation', + return_value=organisation_json( + agreement_signed_version='1.2', + agreement_signed_on_behalf_of_name='Firstname Lastname', + agreement_signed_on_behalf_of_email_address='test@example.com', + ) + ) + + page = client_request.get('main.service_accept_agreement', service_id=SERVICE_ONE_ID) + + assert [ + (field['name'], field['value']) for field in page.select('input[type=text], input[type=email]') + ] == [ + ('version', '1.2'), + ('on_behalf_of_name', 'Firstname Lastname'), + ('on_behalf_of_email', 'test@example.com'), + ] + + +def test_accept_agreement_page_validates( + client_request, + mock_get_service_organisation, +): + page = client_request.post( + 'main.service_accept_agreement', + service_id=SERVICE_ONE_ID, + _data={ + 'version': '', + 'who': '', + 'on_behalf_of_name': '', + 'on_behalf_of_email': '', + }, + _expected_status=200, + ) + assert [ + error.text.strip() for error in page.select('.error-message') + ] == [ + 'Must be a number', + 'This field is required.', + ] + + +def test_accept_agreement_page_persists( + client_request, + mock_get_service_organisation, + mock_update_organisation, +): + client_request.post( + 'main.service_accept_agreement', + service_id=SERVICE_ONE_ID, + _data={ + 'version': '1.2', + 'who': 'someone-else', + 'on_behalf_of_name': 'Firstname Lastname', + 'on_behalf_of_email': 'test@example.com', + }, + _expected_status=302, + _expected_redirect=url_for( + 'main.service_confirm_agreement', + service_id=SERVICE_ONE_ID, + _external=True, + ), + ) + mock_update_organisation.assert_called_once_with( + '7aa5d4e9-4385-4488-a489-07812ba13383', + agreement_signed_version=1.2, + agreement_signed_on_behalf_of_name='Firstname Lastname', + agreement_signed_on_behalf_of_email_address='test@example.com', + ) + + +@pytest.mark.parametrize('name, email, expected_paragraph', ( + (None, None, ( + 'I confirm that I have the legal authority to accept the ' + 'GOV.UK Notify data sharing and financial agreement (version ' + '1.2) and that Test Organisation will be bound by it.' + )), + ('Firstname Lastname', 'test@example.com', ( + 'I confirm that I have the legal authority to accept the ' + 'GOV.UK Notify data sharing and financial agreement (version ' + '1.2) on behalf of Firstname Lastname (test@example.com) and ' + 'that Test Organisation will be bound by it.' + )), +)) +def test_show_confirm_agreement_page( + client_request, + mocker, + name, + email, + expected_paragraph, +): + mocker.patch( + 'app.models.organisation.organisations_client.get_service_organisation', + return_value=organisation_json( + agreement_signed_version='1.2', + agreement_signed_on_behalf_of_name=name, + agreement_signed_on_behalf_of_email_address=email, + ) + ) + page = client_request.get('main.service_confirm_agreement', service_id=SERVICE_ONE_ID) + assert normalize_spaces(page.select_one('main p').text) == expected_paragraph + + +@pytest.mark.parametrize('http_method', ('get', 'post')) +def test_confirm_agreement_page_403s_if_previous_step_not_taken( + client_request, + mock_get_service_organisation, + http_method, +): + getattr(client_request, http_method)( + 'main.service_confirm_agreement', + service_id=SERVICE_ONE_ID, + _expected_status=403, + ) + + +@freeze_time("2012-01-01 01:01") +def test_confirm_agreement_page_persists( + client_request, + mocker, + mock_update_organisation, + fake_uuid, +): + mocker.patch( + 'app.models.organisation.organisations_client.get_service_organisation', + return_value=organisation_json(agreement_signed_version='1.2') + ) + client_request.post( + 'main.service_confirm_agreement', + service_id=SERVICE_ONE_ID, + _expected_redirect=url_for( + 'main.request_to_go_live', + service_id=SERVICE_ONE_ID, + _external=True, + ), + ) + mock_update_organisation.assert_called_once_with( + '1234', + agreement_signed=True, + agreement_signed_at='2012-01-01 01:01:00', + agreement_signed_by_id=fake_uuid, + ) + + @pytest.mark.parametrize('crown, expected_file_fetched, expected_file_served', [ ( True,