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={}
) %}