diff --git a/Makefile b/Makefile index f0c51c5e4..9c0769e3a 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ generate-version-file: ## Generates the app version file .PHONY: build build: dependencies generate-version-file ## Build project + ./venv/bin/pip-accel wheel --wheel-dir=wheelhouse -r requirements.txt npm run build .PHONY: build-codedeploy-artifact diff --git a/app/__init__.py b/app/__init__.py index 0fd40481c..88e2e8d25 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -117,6 +117,7 @@ def create_app(): application.add_template_filter(format_date) application.add_template_filter(format_date_normal) application.add_template_filter(format_date_short) + application.add_template_filter(format_datetime_relative) application.add_template_filter(format_delta) application.add_template_filter(format_notification_status) application.add_template_filter(format_notification_status_as_time) @@ -232,6 +233,23 @@ def format_datetime_short(date): ) +def format_datetime_relative(date): + return '{} at {}'.format( + get_human_day(date), + format_time(date) + ) + + +def get_human_day(time): + # Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’ + time = (gmt_timezones(time) - timedelta(hours=1)).strftime('%A') + if time == datetime.utcnow().strftime('%A'): + return 'today' + if time == (datetime.utcnow() + timedelta(days=1)).strftime('%A'): + return 'tomorrow' + return time + + def format_time(date): return { '12:00AM': 'Midnight', diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js index 8923376ef..8d359cc7d 100644 --- a/app/assets/javascripts/radioSelect.js +++ b/app/assets/javascripts/radioSelect.js @@ -2,88 +2,144 @@ "use strict"; - var render = ($options, $button) => ( - filterOptionVisibility($options) && setButtonState($options, $button) - ); + let states = { + 'initial': Hogan.compile(` +
+ +
+
+ {{#categories}} + + {{/categories}} +
+ `), + 'choose': Hogan.compile(` +
+ +
+
+ {{#choices}} + + {{/choices}} +
+ `), + 'chosen': Hogan.compile(` +
+ +
+
+ {{#choices}} + + {{/choices}} +
+
+ +
+ `) + }; - var filterOptionVisibility = $options => $options - .removeClass('js-visible') - .filter( - (index, element) => (index === 0 || $(element).has(':checked').length) - ) - .addClass('js-visible'); - - var setButtonState = ($options, $button) => $button - .addClass('js-visible') - .prop( - 'value', - $options.has(':checked').find('input').attr('id') === $options.eq(0).find('input').attr('id') ? - 'Later' : 'Choose a different time' + let focusSelected = function() { + setTimeout( + () => $('[type=radio]:checked').parent('label').blur().trigger('focus').addClass('selected'), + 10 ); - - // Workaround because GOV.UK SelectionButtons doesn’t deselect in this case - var deselectUnchecked = $options => $options - .filter( - (index, element) => $(element).not(':has(:checked)') - ).removeClass('selected'); - - var refocus = $element => setTimeout( - () => $element.blur().trigger('focus'), - 10 - ); - - var renderIfComponentLosesFocus = ($options, $button, $focused) => () => - ($focused.attr('type') !== 'radio') && - render($options, $button) && - refocus($focused); // Make sure that window scrolls to focused element + }; Modules.RadioSelect = function() { this.start = function(component) { let $component = $(component); - let $options = $('label', $component); + let render = (state, data) => $component.html(states[state].render(data)); + let choices = $('label', $component).toArray().map(function(element) { + let $element = $(element); + return { + 'id': $element.attr('for'), + 'label': $.trim($element.text()), + 'value': $element.find('input').attr('value') + }; + }); + let categories = $component.data('categories').split(','); + let name = $component.find('input').eq(0).attr('name'); - $component.append( - $button = $('') - ); + $component + .on('click', '.js-category-button', function(event) { - $button.on('click', () => - $options.addClass('js-visible').has(':checked').focus() && - $button.removeClass('js-visible') - ); + event.preventDefault(); + let wordsInDay = $(this).attr('value').split(' '); + let day = wordsInDay[wordsInDay.length - 1].toLowerCase(); + render('choose', { + 'choices': choices.filter( + element => element.label.toLowerCase().indexOf(day) > -1 + ), + 'name': name + }); + $('.js-option').eq(0).parent('label').trigger('focus'); - $component.on('keydown', 'input[type=radio]', function() { + }) + .on('click', '.js-option', function(event) { - // intercept keypresses which aren’t enter or space - if (event.which !== 13 && event.which !== 32) { - setTimeout( - renderIfComponentLosesFocus($options, $button, $(document.activeElement)), - 200 - ); - return true; - } + // stop click being triggered by keyboard events + if (!event.pageX) return true; - event.preventDefault(); + event.preventDefault(); + let value = $(this).attr('value'); + render('chosen', { + 'choices': choices.filter( + element => element.value == value + ), + 'name': name + }); + focusSelected(); - render($options, $button); - refocus($(this)); + }) + .on('keydown', 'input[type=radio]', function(event) { + // intercept keypresses which aren’t enter or space + if (event.which !== 13 && event.which !== 32) { + return true; + } + + event.preventDefault(); + let value = $(this).attr('value'); + render('chosen', { + 'choices': choices.filter( + element => element.value == value + ), + 'name': name + }); + focusSelected(); + + }) + .on('click', '.js-reset-button', function(event) { + + event.preventDefault(); + render('initial', { + 'categories': categories, + 'name': name + }); + focusSelected(); + + }); + + render('initial', { + 'categories': categories, + 'name': name }); - $component.on('click', 'input[type=radio]', function(event) { - - deselectUnchecked($options); - - // stop click being triggered by keyboard events - if (!event.pageX) return true; - - render($options, $button); - refocus($(this)); - - }); - - render($options, $button); + $component.css({'height': 'auto'}); }; diff --git a/app/assets/stylesheets/_grids.scss b/app/assets/stylesheets/_grids.scss index 00f8809e6..cdd68defe 100644 --- a/app/assets/stylesheets/_grids.scss +++ b/app/assets/stylesheets/_grids.scss @@ -35,6 +35,10 @@ margin-bottom: $gutter-half; } +.bottom-gutter-3-2 { + margin-bottom: $gutter * 3/2; +} + .bottom-gutter-2 { margin-bottom: $gutter * 2; } diff --git a/app/assets/stylesheets/app.scss b/app/assets/stylesheets/app.scss index 8144e50ba..878d94497 100644 --- a/app/assets/stylesheets/app.scss +++ b/app/assets/stylesheets/app.scss @@ -214,3 +214,12 @@ summary::-webkit-details-marker { details .arrow { font-size: 16px; } + +.block-label-hint { + @include core-16; + margin-top: 5px; +} + +.block-label input[disabled] { + opacity: 0.5; +} diff --git a/app/assets/stylesheets/components/radio-select.scss b/app/assets/stylesheets/components/radio-select.scss index 7462eed1e..1440d0ad6 100644 --- a/app/assets/stylesheets/components/radio-select.scss +++ b/app/assets/stylesheets/components/radio-select.scss @@ -6,7 +6,9 @@ vertical-align: top; .block-label { - margin-right: 10px; + margin-right: 5px; + padding-right: $gutter - 10px; + padding-left: 54px - 10px; } } @@ -15,31 +17,21 @@ display: inline-block; vertical-align: top; width: auto; - padding: 20px 30px 15px 30px; + padding: 20px $gutter-half 15px $gutter-half; + margin-right: 5px; } .js-enabled & { + height: 60px; + overflow: visible; .block-label { - &:last-child { - margin-bottom: 10px; - } - } - - .block-label, - .tertiary-button { display: none; } - .js-visible { - - display: block; - - &.tertiary-button { - display: inline-block; - } - + .js-block-label { + display: inline-block; } } diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss index c748bdbcc..8f908ddde 100644 --- a/app/assets/stylesheets/components/table.scss +++ b/app/assets/stylesheets/components/table.scss @@ -118,6 +118,10 @@ } + &-noborder { + border: 0px; + } + &-index { width: 15px; } @@ -159,6 +163,12 @@ text-align: right; } + +.table-row-group { + border-top: 1px solid $border-colour; + border-bottom: 1px solid $border-colour; +} + .table-empty-message { @include core-16; color: $secondary-text-colour; diff --git a/app/main/forms.py b/app/main/forms.py index 650f72891..95afc47c6 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -27,27 +27,58 @@ from app.main.validators import (Blacklist, CsvFileValidator, ValidGovEmail, NoC def get_time_value_and_label(future_time): return ( future_time.replace(tzinfo=None).isoformat(), - get_human_time(future_time.astimezone(pytz.timezone('Europe/London'))) + '{} at {}'.format( + get_human_day(future_time.astimezone(pytz.timezone('Europe/London'))), + get_human_time(future_time.astimezone(pytz.timezone('Europe/London'))) + ) ) def get_human_time(time): return { - '0': 'Midnight', - '12': 'Midday' + '0': 'midnight', + '12': 'midday' }.get( time.strftime('%-H'), time.strftime('%-I%p').lower() ) -def get_next_hours_from(now, hours=23): +def get_human_day(time, prefix_today_with='T'): + # Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’ + time = (time - timedelta(hours=1)).strftime('%A') + if time == datetime.utcnow().strftime('%A'): + return '{}oday'.format(prefix_today_with) + if time == (datetime.utcnow() + timedelta(days=1)).strftime('%A'): + return 'Tomorrow' + return time + + +def get_furthest_possible_scheduled_time(): + return (datetime.utcnow() + timedelta(days=4)).replace(hour=0) + + +def get_next_hours_until(until): + now = datetime.utcnow() + hours = int((until - now).total_seconds() / (60 * 60)) return [ (now + timedelta(hours=i)).replace(minute=0, second=0).replace(tzinfo=pytz.utc) for i in range(1, hours + 1) ] +def get_next_days_until(until): + now = datetime.utcnow() + days = int((until - now).total_seconds() / (60 * 60 * 24)) + return [ + get_human_day( + (now + timedelta(days=i)).replace(tzinfo=pytz.utc), + prefix_today_with='Later t' + ) + for i in range(0, days + 1) + ] + + def email_address(label='Email address', gov_user=True): validators = [ Length(min=5, max=255), @@ -310,8 +341,11 @@ class ChooseTimeForm(Form): def __init__(self, *args, **kwargs): super(ChooseTimeForm, self).__init__(*args, **kwargs) self.scheduled_for.choices = [('', 'Now')] + [ - get_time_value_and_label(hour) for hour in get_next_hours_from(datetime.utcnow()) + get_time_value_and_label(hour) for hour in get_next_hours_until( + get_furthest_possible_scheduled_time() + ) ] + self.scheduled_for.categories = get_next_days_until(get_furthest_possible_scheduled_time()) scheduled_for = RadioField( 'When should Notify send these messages?', @@ -350,26 +384,47 @@ class Feedback(Form): class RequestToGoLiveForm(Form): - channel = StringField( - 'Are you sending emails or text messages or both?', - validators=[DataRequired(message='Can’t be empty')] + mou = RadioField( + ( + 'Has your organisation accepted the GOV.UK Notify data sharing and financial ' + 'agreement (Memorandum of Understanding)?' + ), + choices=[ + ('yes', 'Yes'), + ('no', 'No'), + ('don’t know', 'I don’t know') + ], + validators=[DataRequired()] + ) + channel = RadioField( + 'What kind of messages will you be sending?', + choices=[ + ('emails', 'Emails'), + ('text messages', 'Text messages'), + ('emails and text messages', 'Both') + ], + validators=[DataRequired()] ) start_date = StringField( 'When will you be ready to start sending messages?', validators=[DataRequired(message='Can’t be empty')] ) start_volume = StringField( - 'How many messages do you expect to send per month to start with? Give an estimate in numbers.', + 'How many messages do you expect to send to start with?', validators=[DataRequired(message='Can’t be empty')] ) peak_volume = StringField( - 'Will the number of messages a month increase and when will that start? Give an estimate.', + 'Will the number of messages increase and when will that start?', validators=[DataRequired(message='Can’t be empty')] ) - upload_or_api = StringField( - 'Are you uploading a list of contacts that you’re sending your message to, ' + - 'or are you integrating your system with ours?', - validators=[DataRequired(message='Can’t be empty')] + upload_or_api = RadioField( + 'How are you going to send messages?', + choices=[ + ('File upload', 'Upload a spreadsheet of recipients'), + ('API', 'Integrate with the GOV.UK Notify API'), + ('API and file upload', 'Both') + ], + validators=[DataRequired()] ) diff --git a/app/main/views/api_keys.py b/app/main/views/api_keys.py index de7c65181..fd3352a54 100644 --- a/app/main/views/api_keys.py +++ b/app/main/views/api_keys.py @@ -1,4 +1,4 @@ -from flask import request, render_template, redirect, url_for, flash +from flask import request, render_template, redirect, url_for, flash, Markup, abort from flask_login import login_required from app.main import main from app.main.forms import CreateKeyForm, Whitelist @@ -68,13 +68,22 @@ def create_api_key(service_id): key['name'] for key in api_key_api_client.get_api_keys(service_id=service_id)['apiKeys'] ] form = CreateKeyForm(key_names) - form.key_type.choices = filter(None, [ - (KEY_TYPE_NORMAL, 'Send messages to anyone') - if not current_service['restricted'] else None, - (KEY_TYPE_TEST, 'Simulate sending messages to anyone'), - (KEY_TYPE_TEAM, 'Only send messages to your team or whitelist') - ]) + form.key_type.choices = [ + (KEY_TYPE_NORMAL, 'Send messages to anyone'), + (KEY_TYPE_TEAM, 'Send messages to anyone on my whitelist'), + (KEY_TYPE_TEST, 'Pretend to send messages to anyone'), + ] + if current_service['restricted']: + disabled_options = [KEY_TYPE_NORMAL] + option_hints = {KEY_TYPE_NORMAL: Markup( + 'This option is not available because your service is in ' + 'trial mode'.format(url_for(".trial_mode")) + )} + else: + disabled_options, option_hints = [], {} if form.validate_on_submit(): + if form.key_type.data in disabled_options: + abort(400) secret = api_key_api_client.create_api_key( service_id=service_id, key_name=form.key_name.data, @@ -88,7 +97,9 @@ def create_api_key(service_id): ) return render_template( 'views/api/keys/create.html', - form=form + form=form, + disabled_options=disabled_options, + option_hints=option_hints ) diff --git a/app/main/views/letters.py b/app/main/views/letters.py index 4c8e1bd12..bf268a12e 100644 --- a/app/main/views/letters.py +++ b/app/main/views/letters.py @@ -8,7 +8,6 @@ from app.utils import user_has_permissions @main.route("/services//letters") @login_required -@user_has_permissions('manage_templates', 'send_letters', admin_override=True, any_=True) def letters(service_id): if not current_service['can_send_letters']: abort(403) diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index 74c9ef3be..f81aed894 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -1,7 +1,5 @@ import itertools -from datetime import datetime -import pytz from flask import render_template from flask_login import login_required @@ -63,13 +61,10 @@ def create_global_stats(services): def format_stats_by_service(services): for service in services: - stats = service['statistics'].values() yield { 'id': service['id'], 'name': service['name'], - 'sending': sum((stat['requested'] - stat['delivered'] - stat['failed']) for stat in stats), - 'delivered': sum(stat['delivered'] for stat in stats), - 'failed': sum(stat['failed'] for stat in stats), + 'stats': service['statistics'], 'restricted': service['restricted'], 'research_mode': service['research_mode'], 'created_at': service['created_at'], diff --git a/app/main/views/send.py b/app/main/views/send.py index 9ed33784d..90f423d13 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -21,7 +21,7 @@ from notifications_utils.template import Template from notifications_utils.recipients import RecipientCSV, first_column_heading, validate_and_format_phone_number from app.main import main -from app.main.forms import CsvUploadForm, ChooseTimeForm +from app.main.forms import CsvUploadForm, ChooseTimeForm, get_next_days_until, get_furthest_possible_scheduled_time from app.main.uploader import ( s3upload, s3download diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 99c2c70c9..97b708291 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -112,11 +112,13 @@ def service_request_to_go_live(service_id): 'subject': 'Request to go live', 'message': ( 'On behalf of {} ({})\n\nExpected usage\n---' + '\nMOU in place: {}' '\nChannel: {}\nStart date: {}\nStart volume: {}' '\nPeak volume: {}\nUpload or API: {}' ).format( current_service['name'], url_for('main.service_dashboard', service_id=current_service['id'], _external=True), + form.mou.data, form.channel.data, form.start_date.data, form.start_volume.data, diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 982832e9a..d835fd5af 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -42,7 +42,8 @@ def view_template(service_id, template_id): 'views/templates/template.html', template=Template( service_api_client.get_service_template(service_id, template_id)['data'], - prefix=current_service['name'] + prefix=current_service['name'], + sms_sender=current_service['sms_sender'] ) ) @@ -63,7 +64,8 @@ def view_template_version(service_id, template_id, version): 'views/templates/template_history.html', template=Template( service_api_client.get_service_template(service_id, template_id, version)['data'], - prefix=current_service['name'] + prefix=current_service['name'], + sms_sender=current_service['sms_sender'] ) ) @@ -231,7 +233,8 @@ def view_template_versions(service_id, template_id): versions=[ Template( template, - prefix=current_service['name'] + prefix=current_service['name'], + sms_sender=current_service['sms_sender'] ) for template in service_api_client.get_service_template_versions(service_id, template_id)['data'] ] ) diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index cc71fc758..4107e53cd 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -47,8 +47,8 @@ def user_profile_name(): form = ChangeNameForm(new_name=current_user.name) if form.validate_on_submit(): - current_user.name = form.new_name.data - user_api_client.update_user(current_user) + user_api_client.update_user_attribute(current_user.id, + name=form.new_name.data) return redirect(url_for('.user_profile')) return render_template( @@ -107,7 +107,6 @@ def user_profile_email_authenticate(): @main.route("/user-profile/email/confirm/", methods=['GET']) @login_required def user_profile_email_confirm(token): - token_data = check_token(token, current_app.config['SECRET_KEY'], current_app.config['DANGEROUS_SALT'], @@ -115,9 +114,8 @@ def user_profile_email_confirm(token): token_data = json.loads(token_data) user_id = token_data['user_id'] new_email = token_data['email'] - user = user_api_client.get_user(user_id) - user.email_address = new_email - user_api_client.update_user(user) + user_api_client.update_user_attribute(user_id, + email_address=new_email) session.pop(NEW_EMAIL, None) return redirect(url_for('.user_profile')) @@ -179,10 +177,11 @@ def user_profile_mobile_number_confirm(): form = ConfirmMobileNumberForm(_check_code) if form.validate_on_submit(): - current_user.mobile_number = session[NEW_MOBILE] + mobile_number = session[NEW_MOBILE] del session[NEW_MOBILE] del session[NEW_MOBILE_PASSWORD_CONFIRMED] - user_api_client.update_user(current_user) + user_api_client.update_user_attribute(current_user.id, + mobile_number=mobile_number) return redirect(url_for('.user_profile')) return render_template( diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index d3c077333..c68a2e1e7 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -3,6 +3,12 @@ from notifications_python_client.errors import HTTPError from app.notify_client.models import User +ALLOWED_ATTRIBUTES = { + 'name', + 'email_address', + 'mobile_number' +} + class UserApiClient(BaseAPIClient): def __init__(self): @@ -53,6 +59,19 @@ class UserApiClient(BaseAPIClient): user_data = self.put(url, data=data) return User(user_data['data'], max_failed_login_count=self.max_failed_login_count) + def update_user_attribute(self, user_id, **kwargs): + data = dict(kwargs) + disallowed_attributes = set(data.keys()) - ALLOWED_ATTRIBUTES + if disallowed_attributes: + raise TypeError('Not allowed to update user attributes: {}'.format( + ", ".join(disallowed_attributes) + )) + + data = dict(**kwargs) + url = "/user/{}".format(user_id) + user_data = self.post(url, data=data) + return User(user_data['data'], max_failed_login_count=self.max_failed_login_count) + def verify_password(self, user_id, password): try: url = "/user/{}/verify/password".format(user_id) diff --git a/app/templates/components/radio-select.html b/app/templates/components/radio-select.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/templates/components/radios.html b/app/templates/components/radios.html index 3a0f19a7c..a24c49d1c 100644 --- a/app/templates/components/radios.html +++ b/app/templates/components/radios.html @@ -1,6 +1,8 @@ {% macro radios( field, - hint=None + hint=None, + disable=[], + option_hints={} ) %}
@@ -14,8 +16,21 @@ {% for option in field %} {% endfor %}
@@ -38,7 +53,7 @@ {% endif %} -
+
{% for option in field %}