Merge pull request #3527 from alphagov/broadcast-end-time

Let users choose when to end a broadcast
This commit is contained in:
Chris Hill-Scott
2020-07-17 10:43:10 +01:00
committed by GitHub
9 changed files with 241 additions and 74 deletions

View File

@@ -7,12 +7,14 @@
let states = { let states = {
'initial': Hogan.compile(` 'initial': Hogan.compile(`
<div class="radio-select__column"> {{#showNowAsDefault}}
<div class="multiple-choice js-multiple-choice"> <div class="radio-select__column">
<input checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value=""> <div class="multiple-choice js-multiple-choice">
<label class="block-label js-block-label" for="{{name}}-0">Now</label> <input checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value="">
<label class="block-label js-block-label" for="{{name}}-0">Now</label>
</div>
</div> </div>
</div> {{/showNowAsDefault}}
<div class="radio-select__column"> <div class="radio-select__column">
{{#categories}} {{#categories}}
<input type='button' class='govuk-button govuk-button--secondary radio-select__button--category' value='{{.}}' /> <input type='button' class='govuk-button govuk-button--secondary radio-select__button--category' value='{{.}}' />
@@ -20,12 +22,14 @@
</div> </div>
`), `),
'choose': Hogan.compile(` 'choose': Hogan.compile(`
<div class="radio-select__column"> {{#showNowAsDefault}}
<div class="multiple-choice js-multiple-choice js-initial-option"> <div class="radio-select__column">
<input checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value=""> <div class="multiple-choice js-multiple-choice js-initial-option">
<label for="{{name}}-0">Now</label> <input checked="checked" id="{{name}}-0" name="{{name}}" type="radio" value="">
<label for="{{name}}-0">Now</label>
</div>
</div> </div>
</div> {{/showNowAsDefault}}
<div class="radio-select__column"> <div class="radio-select__column">
{{#choices}} {{#choices}}
<div class="multiple-choice js-multiple-choice js-option"> <div class="multiple-choice js-multiple-choice js-option">
@@ -37,12 +41,14 @@
</div> </div>
`), `),
'chosen': Hogan.compile(` 'chosen': Hogan.compile(`
<div class="radio-select__column"> {{#showNowAsDefault}}
<div class="multiple-choice js-multiple-choice js-initial-option"> <div class="radio-select__column">
<input id="{{name}}-0" name="{{name}}" type="radio" value=""> <div class="multiple-choice js-multiple-choice js-initial-option">
<label for="{{name}}-0">Now</label> <input id="{{name}}-0" name="{{name}}" type="radio" value="">
<label for="{{name}}-0">Now</label>
</div>
</div> </div>
</div> {{/showNowAsDefault}}
<div class="radio-select__column"> <div class="radio-select__column">
{{#choices}} {{#choices}}
<div class="multiple-choice js-multiple-choice"> <div class="multiple-choice js-multiple-choice">
@@ -80,10 +86,15 @@
let categories = $component.data('categories').split(','); let categories = $component.data('categories').split(',');
let name = $component.find('input').eq(0).attr('name'); let name = $component.find('input').eq(0).attr('name');
let mousedownOption = null; let mousedownOption = null;
let showNowAsDefault = (
$component.data('show-now-as-default').toString() === 'true' ?
{'name': name} : false
);
const reset = () => { const reset = () => {
render('initial', { render('initial', {
'categories': categories, 'categories': categories,
'name': name 'name': name,
'showNowAsDefault': showNowAsDefault
}); });
}; };
const selectOption = (value) => { const selectOption = (value) => {
@@ -91,7 +102,8 @@
'choices': choices.filter( 'choices': choices.filter(
element => element.value == value element => element.value == value
), ),
'name': name 'name': name,
'showNowAsDefault': showNowAsDefault
}); });
focusSelected(component); focusSelected(component);
}; };
@@ -119,7 +131,8 @@
'choices': choices.filter( 'choices': choices.filter(
element => element.label.toLowerCase().indexOf(day) > -1 element => element.label.toLowerCase().indexOf(day) > -1
), ),
'name': name 'name': name,
'showNowAsDefault': showNowAsDefault
}); });
focusSelected(component); focusSelected(component);
@@ -153,7 +166,8 @@
'choices': choices.filter( 'choices': choices.filter(
element => element.value == $selection.eq(0).attr('value') element => element.value == $selection.eq(0).attr('value')
), ),
'name': name 'name': name,
'showNowAsDefault': showNowAsDefault
}); });
} else { } else {
@@ -174,7 +188,8 @@
render('initial', { render('initial', {
'categories': categories, 'categories': categories,
'name': name 'name': name,
'showNowAsDefault': showNowAsDefault
}); });
$component.css({'height': 'auto'}); $component.css({'height': 'auto'});

View File

@@ -100,7 +100,7 @@ def get_next_hours_until(until):
now = datetime.utcnow() now = datetime.utcnow()
hours = int((until - now).total_seconds() / (60 * 60)) hours = int((until - now).total_seconds() / (60 * 60))
return [ return [
(now + timedelta(hours=i)).replace(minute=0, second=0).replace(tzinfo=pytz.utc) (now + timedelta(hours=i)).replace(minute=0, second=0, microsecond=0).replace(tzinfo=pytz.utc)
for i in range(1, hours + 1) for i in range(1, hours + 1)
] ]
@@ -979,6 +979,24 @@ class ChooseTimeForm(StripWhitespaceForm):
) )
class ChooseBroadcastDurationForm(StripWhitespaceForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.finishes_at.choices = [
get_time_value_and_label(hour) for hour in get_next_hours_until(
get_furthest_possible_scheduled_time()
)
]
self.finishes_at.categories = get_next_days_until(
get_furthest_possible_scheduled_time()
)
finishes_at = RadioField(
'When should this broadcast end?',
)
class CreateKeyForm(StripWhitespaceForm): class CreateKeyForm(StripWhitespaceForm):
def __init__(self, existing_keys, *args, **kwargs): def __init__(self, existing_keys, *args, **kwargs):
self.existing_key_names = [ self.existing_key_names = [

View File

@@ -1,8 +1,12 @@
from flask import abort, jsonify, redirect, render_template, request, url_for from flask import abort, jsonify, redirect, render_template, url_for
from app import current_service from app import current_service
from app.main import main from app.main import main
from app.main.forms import BroadcastAreaForm, SearchByNameForm from app.main.forms import (
BroadcastAreaForm,
ChooseBroadcastDurationForm,
SearchByNameForm,
)
from app.models.broadcast_message import BroadcastMessage, BroadcastMessages from app.models.broadcast_message import BroadcastMessage, BroadcastMessages
from app.utils import service_has_permission, user_has_permissions from app.utils import service_has_permission, user_has_permissions
@@ -141,15 +145,19 @@ def preview_broadcast_message(service_id, broadcast_message_id):
broadcast_message_id, broadcast_message_id,
service_id=current_service.id, service_id=current_service.id,
) )
if request.method == 'POST': form = ChooseBroadcastDurationForm()
broadcast_message.request_approval()
if form.validate_on_submit():
broadcast_message.request_approval(until=form.finishes_at.data)
return redirect(url_for( return redirect(url_for(
'.broadcast_dashboard', '.broadcast_dashboard',
service_id=current_service.id, service_id=current_service.id,
)) ))
return render_template( return render_template(
'views/broadcast/preview-message.html', 'views/broadcast/preview-message.html',
broadcast_message=broadcast_message, broadcast_message=broadcast_message,
form=form,
) )

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import datetime
from notifications_utils.broadcast_areas import broadcast_area_libraries from notifications_utils.broadcast_areas import broadcast_area_libraries
from notifications_utils.template import BroadcastPreviewTemplate from notifications_utils.template import BroadcastPreviewTemplate
@@ -33,7 +33,6 @@ class BroadcastMessage(JSONModel):
'approved_by_id', 'approved_by_id',
'cancelled_by_id', 'cancelled_by_id',
} }
DEFAULT_TTL = timedelta(hours=72)
libraries = broadcast_area_libraries libraries = broadcast_area_libraries
@@ -132,9 +131,9 @@ class BroadcastMessage(JSONModel):
data=kwargs, data=kwargs,
) )
def request_approval(self): def request_approval(self, until):
self._update( self._update(
finishes_at=(datetime.utcnow() + self.DEFAULT_TTL).isoformat(), finishes_at=until,
) )
self._set_status_to('pending-approval') self._set_status_to('pending-approval')

View File

@@ -23,7 +23,8 @@
{% macro radio_select( {% macro radio_select(
field, field,
hint=None, hint=None,
wrapping_class='form-group' wrapping_class='form-group',
show_now_as_default=True
) %} ) %}
<div class="{{ wrapping_class }} {% if field.errors %} form-group-error{% endif %}"> <div class="{{ wrapping_class }} {% if field.errors %} form-group-error{% endif %}">
<fieldset> <fieldset>
@@ -35,7 +36,7 @@
</span> </span>
{% endif %} {% endif %}
</legend> </legend>
<div class="radio-select" data-module="radio-select" data-categories="{{ field.categories|join(',') }}"> <div class="radio-select" data-module="radio-select" data-categories="{{ field.categories|join(',') }}" data-show-now-as-default="{{ show_now_as_default|string|lower }}">
<div class="radio-select-column"> <div class="radio-select-column">
{% for option in field %} {% for option in field %}
<div class="multiple-choice"> <div class="multiple-choice">

View File

@@ -1,7 +1,8 @@
{% from "components/button/macro.njk" import govukButton %} {% from "components/button/macro.njk" import govukButton %}
{% from "components/form.html" import form_wrapper %} {% from "components/form.html" import form_wrapper %}
{% from "components/page-header.html" import page_header %} {% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import sticky_page_footer %} {% from "components/page-footer.html" import page_footer %}
{% from "components/radios.html" import radio_select %}
{% extends "withnav_template.html" %} {% extends "withnav_template.html" %}
@@ -28,7 +29,11 @@
{{ broadcast_message.template|string }} {{ broadcast_message.template|string }}
{% call form_wrapper() %} {% call form_wrapper() %}
{{ sticky_page_footer('Submit for approval') }} {{ radio_select(
form.finishes_at,
show_now_as_default=False
) }}
{{ page_footer('Submit for approval') }}
{% endcall %} {% endcall %}
{% endblock %} {% endblock %}

View File

@@ -11,19 +11,19 @@ def test_form_contains_next_24h(app_):
# Friday # Friday
assert choices[0] == ('', 'Now') assert choices[0] == ('', 'Now')
assert choices[1] == ('2016-01-01T12:00:00.061258', 'Today at midday') assert choices[1] == ('2016-01-01T12:00:00', 'Today at midday')
assert choices[13] == ('2016-01-02T00:00:00.061258', 'Today at midnight') assert choices[13] == ('2016-01-02T00:00:00', 'Today at midnight')
# Saturday # Saturday
assert choices[14] == ('2016-01-02T01:00:00.061258', 'Tomorrow at 1am') assert choices[14] == ('2016-01-02T01:00:00', 'Tomorrow at 1am')
assert choices[37] == ('2016-01-03T00:00:00.061258', 'Tomorrow at midnight') assert choices[37] == ('2016-01-03T00:00:00', 'Tomorrow at midnight')
# Sunday # Sunday
assert choices[38] == ('2016-01-03T01:00:00.061258', 'Sunday at 1am') assert choices[38] == ('2016-01-03T01:00:00', 'Sunday at 1am')
# Monday # Monday
assert choices[84] == ('2016-01-04T23:00:00.061258', 'Monday at 11pm') assert choices[84] == ('2016-01-04T23:00:00', 'Monday at 11pm')
assert choices[85] == ('2016-01-05T00:00:00.061258', 'Monday at midnight') assert choices[85] == ('2016-01-05T00:00:00', 'Monday at midnight')
with pytest.raises(IndexError): with pytest.raises(IndexError):
assert choices[ assert choices[

View File

@@ -278,22 +278,79 @@ def test_remove_broadcast_area_page(
) )
@pytest.mark.parametrize('end_time', (
# Before now
pytest.param('2020-02-02T02:00:00', marks=pytest.mark.xfail),
# End of the current hour
pytest.param('2020-02-02T03:00:00'),
# Midnight 3 days ahead
pytest.param('2020-02-06T00:00:00'),
# 1am 4 days ahead
pytest.param('2020-02-06T01:00:00', marks=pytest.mark.xfail),
))
@freeze_time('2020-02-02 02:02:02')
def test_preview_broadcast_message_page( def test_preview_broadcast_message_page(
client_request, client_request,
service_one, service_one,
mock_get_draft_broadcast_message, mock_get_draft_broadcast_message,
mock_get_broadcast_template, mock_get_broadcast_template,
fake_uuid, fake_uuid,
end_time,
): ):
service_one['permissions'] += ['broadcast'] service_one['permissions'] += ['broadcast']
client_request.get(
page = client_request.get(
'.preview_broadcast_message', '.preview_broadcast_message',
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid, broadcast_message_id=fake_uuid,
), )
assert [
normalize_spaces(area.text)
for area in page.select('.area-list-item.area-list-item--unremoveable')
] == [
'England',
'Scotland',
]
assert normalize_spaces(
page.select_one('.broadcast-message-wrapper').text
) == (
'This is a test'
)
form = page.select_one('form')
assert form['method'] == 'post'
assert 'action' not in form
radio_choices = [
choice['value'] for choice in form.select('input[type=radio][name=finishes_at]')
]
assert len(radio_choices) == 94
assert end_time in radio_choices
@freeze_time('2020-02-02 02:02:02.222222') @pytest.mark.parametrize('end_time', (
# Before now
pytest.param('2020-02-02T02:00:00', marks=pytest.mark.xfail),
# End of the current hour
pytest.param('2020-02-02T03:00:00'),
# Midnight 3 days ahead
pytest.param('2020-02-06T00:00:00'),
# 1am 4 days ahead
pytest.param('2020-02-06T01:00:00', marks=pytest.mark.xfail),
))
@freeze_time('2020-02-02 02:02:02')
def test_start_broadcasting( def test_start_broadcasting(
client_request, client_request,
service_one, service_one,
@@ -302,24 +359,54 @@ def test_start_broadcasting(
mock_update_broadcast_message, mock_update_broadcast_message,
mock_update_broadcast_message_status, mock_update_broadcast_message_status,
fake_uuid, fake_uuid,
end_time,
):
service_one['permissions'] += ['broadcast']
client_request.post(
'.preview_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_data={
'finishes_at': end_time,
}
),
mock_update_broadcast_message.assert_called_once_with(
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
data={
'finishes_at': end_time,
},
)
mock_update_broadcast_message_status.assert_called_once_with(
'pending-approval',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
)
def test_start_broadcasting_shows_validation_error(
client_request,
service_one,
mock_get_draft_broadcast_message,
mock_get_broadcast_template,
mock_update_broadcast_message,
mock_update_broadcast_message_status,
fake_uuid,
): ):
service_one['permissions'] += ['broadcast'] service_one['permissions'] += ['broadcast']
client_request.post( page = client_request.post(
'.preview_broadcast_message', '.preview_broadcast_message',
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid, broadcast_message_id=fake_uuid,
), _data={},
mock_update_broadcast_message.assert_called_once_with( _expected_status=200,
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
data={
'finishes_at': '2020-02-05T02:02:02.222222',
},
) )
mock_update_broadcast_message_status.assert_called_once_with( assert mock_update_broadcast_message.called is False
'pending-approval', assert mock_update_broadcast_message_status.called is False
service_id=SERVICE_ONE_ID, assert normalize_spaces(
broadcast_message_id=fake_uuid, page.select_one('form fieldset legend .error-message').text
) == (
'Select an option'
) )

View File

@@ -68,11 +68,12 @@ describe('RadioSelect', () => {
hours.forEach((hour, idx) => { hours.forEach((hour, idx) => {
const hourLabel = getHourLabel(hour); const hourLabel = getHourLabel(hour);
const num = idx + 1;
result += result +=
`<div class="multiple-choice"> `<div class="multiple-choice">
<input id="scheduled_for-${idx}" name="scheduled_for" type="radio" value="2019-05-${dayAsNumber}T${hour}:00:00.459156"> <input id="scheduled_for-${num}" name="scheduled_for" type="radio" value="2019-05-${dayAsNumber}T${hour}:00:00.459156">
<label for="scheduled_for-${idx}"> <label for="scheduled_for-${num}">
${day} at ${hourLabel} ${day} at ${hourLabel}
</label> </label>
</div>`; </div>`;
@@ -99,7 +100,7 @@ describe('RadioSelect', () => {
<legend class="form-label"> <legend class="form-label">
When should Notify send these messages? When should Notify send these messages?
</legend> </legend>
<div class="radio-select" data-module="radio-select" data-categories="${CATEGORIES.join(',')}"> <div class="radio-select" data-module="radio-select" data-categories="${CATEGORIES.join(',')}" data-show-now-as-default="true">
<div class="radio-select__column"> <div class="radio-select__column">
<div class="multiple-choice"> <div class="multiple-choice">
<input checked="" id="scheduled_for-0" name="scheduled_for" type="radio" value=""> <input checked="" id="scheduled_for-0" name="scheduled_for" type="radio" value="">
@@ -114,7 +115,7 @@ describe('RadioSelect', () => {
</div> </div>
</fieldset>`; </fieldset>`;
originalOptionsForAllCategories = Array.from(document.querySelector('.radio-select__column:nth-child(2) .multiple-choice')) originalOptionsForAllCategories = Array.from(document.querySelectorAll('.radio-select__column:nth-child(2) .multiple-choice'))
.map(option => getDataFromOption(option)); .map(option => getDataFromOption(option));
}); });
@@ -122,30 +123,60 @@ describe('RadioSelect', () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
}); });
describe("when the page has loaded it should have a button for each category", () => { describe("when the page has loaded", () => {
let categoryButtons; describe("if the 'data-show-now-as-default' attribute", () => {
beforeEach(() => { test("is set to true the module should have a 'Now' option", () => {
// start module // default is for it to be set to true
window.GOVUK.modules.start();
categoryButtons = document.querySelectorAll('.radio-select__column:nth-child(2) .radio-select__button--category'); // start module
window.GOVUK.modules.start();
expect(document.querySelectorAll('.radio-select__column').length).toEqual(2);
});
test("is set to false the module should not have a 'Now' option", () => {
document.querySelector('.radio-select').setAttribute('data-show-now-as-default', 'false');
// start module
window.GOVUK.modules.start();
expect(document.querySelectorAll('.radio-select__column').length).toEqual(1);
});
}); });
test("the number of buttons should match the categories", () => { describe("it should have a button for each category", () => {
expect(categoryButtons.length).toBe(CATEGORIES.length); let categoryButtons;
}); beforeEach(() => {
test("each button's text should match their category", () => { // start module
window.GOVUK.modules.start();
categoryButtons = document.querySelectorAll('.radio-select__column:nth-child(2) .radio-select__button--category');
});
test("the number of buttons should match the categories", () => {
expect(categoryButtons.length).toBe(CATEGORIES.length);
});
test("each button's text should match their category", () => {
// check the buttons have the right text
CATEGORIES.forEach((category, idx) => {
expect(categoryButtons[idx].getAttribute('value')).toEqual(category);
});
// check the buttons have the right text
CATEGORIES.forEach((category, idx) => {
expect(categoryButtons[idx].getAttribute('value')).toEqual(category);
}); });
}); });
@@ -158,10 +189,13 @@ describe('RadioSelect', () => {
describe(`clicking the button for ${category} should`, () => { describe(`clicking the button for ${category} should`, () => {
const categoryRegExp = new RegExp('^' + category);
let originalOptionsForcategory;
beforeEach(() => { beforeEach(() => {
// get all the options in the original page for this category // get all the options in the original page for this category
originalOptionsForCategory = originalOptionsForAllCategories.filter(option => option.label === category); originalOptionsForCategory = originalOptionsForAllCategories.filter(option => categoryRegExp.test(option.label));
// start module // start module
window.GOVUK.modules.start(); window.GOVUK.modules.start();
@@ -173,7 +207,7 @@ describe('RadioSelect', () => {
test("show the options for it, with the right label and value", () => { test("show the options for it, with the right label and value", () => {
// check options this reveals against those originally in the page for this category // check options this reveals against those originally in the page for this category
const options = document.querySelector('.radio-select__column:nth-child(2) .multiple-choice'); const options = document.querySelectorAll('.radio-select__column:nth-child(2) .multiple-choice');
const optionsThatMatchOriginals = Array.from(options).filter((option, idx) => { const optionsThatMatchOriginals = Array.from(options).filter((option, idx) => {
const optionData = getDataFromOption(option); const optionData = getDataFromOption(option);