Merge pull request #3522 from alphagov/broadcast-approval

Add an approval loop for broadcasts
This commit is contained in:
Chris Hill-Scott
2020-07-17 08:18:34 +01:00
committed by GitHub
11 changed files with 375 additions and 57 deletions
@@ -24,6 +24,10 @@
@include copy-19; @include copy-19;
} }
.page-footer {
margin-bottom: govuk-spacing(1);
}
} }
%banner-with-tick, %banner-with-tick,
@@ -7,6 +7,7 @@
line-height: 40px; line-height: 40px;
padding: 1px 0 0 15px; padding: 1px 0 0 15px;
font-weight: normal;
} }
+58 -2
View File
@@ -13,7 +13,7 @@ from app.utils import service_has_permission, user_has_permissions
def broadcast_dashboard(service_id): def broadcast_dashboard(service_id):
return render_template( return render_template(
'views/broadcast/dashboard.html', 'views/broadcast/dashboard.html',
partials=get_broadcast_dashboard_partials(current_service.id) partials=get_broadcast_dashboard_partials(current_service.id),
) )
@@ -27,6 +27,11 @@ def broadcast_dashboard_updates(service_id):
def get_broadcast_dashboard_partials(service_id): def get_broadcast_dashboard_partials(service_id):
broadcast_messages = BroadcastMessages(service_id) broadcast_messages = BroadcastMessages(service_id)
return dict( return dict(
pending_approval_broadcasts=render_template(
'views/broadcast/partials/dashboard-table.html',
broadcasts=broadcast_messages.with_status('pending-approval'),
empty_message='You do not have any broadcasts waiting for approval',
),
live_broadcasts=render_template( live_broadcasts=render_template(
'views/broadcast/partials/dashboard-table.html', 'views/broadcast/partials/dashboard-table.html',
broadcasts=broadcast_messages.with_status('broadcasting'), broadcasts=broadcast_messages.with_status('broadcasting'),
@@ -137,7 +142,7 @@ def preview_broadcast_message(service_id, broadcast_message_id):
service_id=current_service.id, service_id=current_service.id,
) )
if request.method == 'POST': if request.method == 'POST':
broadcast_message.start_broadcast() broadcast_message.request_approval()
return redirect(url_for( return redirect(url_for(
'.broadcast_dashboard', '.broadcast_dashboard',
service_id=current_service.id, service_id=current_service.id,
@@ -164,6 +169,57 @@ def view_broadcast_message(service_id, broadcast_message_id):
) )
@main.route('/services/<uuid:service_id>/broadcast/<uuid:broadcast_message_id>', methods=['POST'])
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def approve_broadcast_message(service_id, broadcast_message_id):
broadcast_message = BroadcastMessage.from_id(
broadcast_message_id,
service_id=current_service.id,
)
if broadcast_message.status != 'pending-approval':
return redirect(url_for(
'.view_broadcast_message',
service_id=current_service.id,
broadcast_message_id=broadcast_message.id,
))
broadcast_message.approve_broadcast()
return redirect(url_for(
'.view_broadcast_message',
service_id=current_service.id,
broadcast_message_id=broadcast_message.id,
))
@main.route('/services/<uuid:service_id>/broadcast/<uuid:broadcast_message_id>/reject')
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def reject_broadcast_message(service_id, broadcast_message_id):
broadcast_message = BroadcastMessage.from_id(
broadcast_message_id,
service_id=current_service.id,
)
if broadcast_message.status != 'pending-approval':
return redirect(url_for(
'.view_broadcast_message',
service_id=current_service.id,
broadcast_message_id=broadcast_message.id,
))
broadcast_message.reject_broadcast()
return redirect(url_for(
'.broadcast_dashboard',
service_id=current_service.id,
))
@main.route('/services/<uuid:service_id>/broadcast/<uuid:broadcast_message_id>/cancel') @main.route('/services/<uuid:service_id>/broadcast/<uuid:broadcast_message_id>/cancel')
@user_has_permissions('send_messages') @user_has_permissions('send_messages')
@service_has_permission('broadcast') @service_has_permission('broadcast')
+28 -30
View File
@@ -108,49 +108,47 @@ class BroadcastMessage(JSONModel):
return User.from_id(self.cancelled_by_id) return User.from_id(self.cancelled_by_id)
def add_areas(self, *new_areas): def add_areas(self, *new_areas):
broadcast_message_api_client.update_broadcast_message( self._update(areas=list(OrderedSet(
broadcast_message_id=self.id, self._dict['areas'] + list(new_areas)
service_id=self.service_id, )))
data={
'areas': list(OrderedSet(
self._dict['areas'] + list(new_areas)
))
},
)
def remove_area(self, area_to_remove): def remove_area(self, area_to_remove):
broadcast_message_api_client.update_broadcast_message( self._update(areas=[
area for area in self._dict['areas']
if area != area_to_remove
])
def _set_status_to(self, status):
broadcast_message_api_client.update_broadcast_message_status(
status,
broadcast_message_id=self.id, broadcast_message_id=self.id,
service_id=self.service_id, service_id=self.service_id,
data={
'areas': [
area for area in self._dict['areas']
if area != area_to_remove
]
},
) )
def start_broadcast(self): def _update(self, **kwargs):
broadcast_message_api_client.update_broadcast_message( broadcast_message_api_client.update_broadcast_message(
broadcast_message_id=self.id, broadcast_message_id=self.id,
service_id=self.service_id, service_id=self.service_id,
data={ data=kwargs,
'starts_at': datetime.utcnow().isoformat(),
'finishes_at': (datetime.utcnow() + self.DEFAULT_TTL).isoformat(),
},
) )
broadcast_message_api_client.update_broadcast_message_status(
'broadcasting', def request_approval(self):
broadcast_message_id=self.id, self._update(
service_id=self.service_id, finishes_at=(datetime.utcnow() + self.DEFAULT_TTL).isoformat(),
) )
self._set_status_to('pending-approval')
def approve_broadcast(self):
self._update(
starts_at=datetime.utcnow().isoformat(),
)
self._set_status_to('broadcasting')
def reject_broadcast(self):
self._set_status_to('rejected')
def cancel_broadcast(self): def cancel_broadcast(self):
broadcast_message_api_client.update_broadcast_message_status( self._set_status_to('cancelled')
'cancelled',
broadcast_message_id=self.id,
service_id=self.service_id,
)
class BroadcastMessages(ModelList): class BroadcastMessages(ModelList):
+8
View File
@@ -361,6 +361,8 @@ class HeaderNavigation(Navigation):
'remove_broadcast_area', 'remove_broadcast_area',
'preview_broadcast_message', 'preview_broadcast_message',
'view_broadcast_message', 'view_broadcast_message',
'approve_broadcast_message',
'reject_broadcast_message',
'cancel_broadcast_message', 'cancel_broadcast_message',
} }
@@ -419,6 +421,8 @@ class MainNavigation(Navigation):
'remove_broadcast_area', 'remove_broadcast_area',
'preview_broadcast_message', 'preview_broadcast_message',
'view_broadcast_message', 'view_broadcast_message',
'approve_broadcast_message',
'reject_broadcast_message',
'cancel_broadcast_message', 'cancel_broadcast_message',
}, },
'uploads': { 'uploads': {
@@ -1012,6 +1016,8 @@ class CaseworkNavigation(Navigation):
'remove_broadcast_area', 'remove_broadcast_area',
'preview_broadcast_message', 'preview_broadcast_message',
'view_broadcast_message', 'view_broadcast_message',
'approve_broadcast_message',
'reject_broadcast_message',
'cancel_broadcast_message', 'cancel_broadcast_message',
} }
@@ -1333,5 +1339,7 @@ class OrgNavigation(Navigation):
'remove_broadcast_area', 'remove_broadcast_area',
'preview_broadcast_message', 'preview_broadcast_message',
'view_broadcast_message', 'view_broadcast_message',
'approve_broadcast_message',
'reject_broadcast_message',
'cancel_broadcast_message', 'cancel_broadcast_message',
} }
@@ -10,6 +10,14 @@
<h1 class="govuk-visually-hidden">Dashboard</h1> <h1 class="govuk-visually-hidden">Dashboard</h1>
<h2 class="heading-medium govuk-!-margin-bottom-2">Waiting for approval</h2>
{{ ajax_block(
partials,
url_for('.broadcast_dashboard_updates', service_id=current_service.id),
'pending_approval_broadcasts'
) }}
<h2 class="heading-medium govuk-!-margin-bottom-2">Live broadcasts</h2> <h2 class="heading-medium govuk-!-margin-bottom-2">Live broadcasts</h2>
{{ ajax_block( {{ ajax_block(
@@ -21,7 +21,11 @@
</div> </div>
{% endcall %} {% endcall %}
{% call field(align='right') %} {% call field(align='right') %}
{% if item.status == 'broadcasting' %} {% if item.status == 'pending-approval' %}
<p class="govuk-body govuk-!-margin-top-6 govuk-!-margin-bottom-0">
Prepared by {{ item.created_by.name }}
</p>
{% elif item.status == 'broadcasting' %}
<p class="govuk-body govuk-!-margin-top-6 govuk-!-margin-bottom-0"> <p class="govuk-body govuk-!-margin-top-6 govuk-!-margin-bottom-0">
Live until {{ item.finishes_at|format_datetime_relative }} Live until {{ item.finishes_at|format_datetime_relative }}
</p> </p>
@@ -28,7 +28,7 @@
{{ broadcast_message.template|string }} {{ broadcast_message.template|string }}
{% call form_wrapper() %} {% call form_wrapper() %}
{{ sticky_page_footer('Start broadcast') }} {{ sticky_page_footer('Submit for approval') }}
{% endcall %} {% endcall %}
{% endblock %} {% endblock %}
+35 -19
View File
@@ -1,7 +1,7 @@
{% 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 %}
{% extends "withnav_template.html" %} {% extends "withnav_template.html" %}
@@ -16,26 +16,42 @@
back_link=url_for('.broadcast_dashboard', service_id=current_service.id) back_link=url_for('.broadcast_dashboard', service_id=current_service.id)
) }} ) }}
<p class="govuk-body govuk-!-margin-bottom-3"> {% if broadcast_message.status == 'pending-approval' %}
Created by {{ broadcast_message.created_by.name }} and approved by {% call form_wrapper(class="banner govuk-!-margin-bottom-6") %}
{{ broadcast_message.approved_by.name }}. <p class="govuk-body govuk-!-margin-top-0 govuk-!-margin-bottom-3">
</p> {{ broadcast_message.created_by.name }} wants to broadcast this
message until {{ broadcast_message.finishes_at|format_datetime_relative }}.
</p>
{{ page_footer(
"Start broadcasting now",
delete_link=url_for('main.reject_broadcast_message', service_id=current_service.id, broadcast_message_id=broadcast_message.id),
delete_link_text='Reject this broadcast'
) }}
{% endcall %}
{% else %}
<p class="govuk-body govuk-!-margin-bottom-3">
Created by {{ broadcast_message.created_by.name }} and approved by
{{ broadcast_message.approved_by.name }}.
</p>
<p class="govuk-body govuk-!-margin-bottom-3"> <p class="govuk-body govuk-!-margin-bottom-3">
Started broadcasting Started broadcasting
{{ broadcast_message.starts_at|format_datetime_human }}. {{ broadcast_message.starts_at|format_datetime_human }}.
</p> </p>
<p class="govuk-body"> <p class="govuk-body">
{% if broadcast_message.status == 'broadcasting' %} {% if broadcast_message.status == 'pending-approval' %}
Live until {{ broadcast_message.finishes_at|format_datetime_relative }}&ensp;<a href="{{ url_for('.cancel_broadcast_message', service_id=current_service.id, broadcast_message_id=broadcast_message.id) }}" class="destructive-link destructive-link--no-visited-state">Stop broadcast early</a> Will broadcast until {{ broadcast_message.finishes_at|format_datetime_relative }}.
{% elif broadcast_message.status == 'cancelled' %} {% elif broadcast_message.status == 'broadcasting' %}
Stopped by {{ broadcast_message.cancelled_by.name }} Live until {{ broadcast_message.finishes_at|format_datetime_relative }}&ensp;<a href="{{ url_for('.cancel_broadcast_message', service_id=current_service.id, broadcast_message_id=broadcast_message.id) }}" class="destructive-link destructive-link--no-visited-state">Stop broadcast early</a>
{{ broadcast_message.cancelled_at|format_datetime_human }}. {% elif broadcast_message.status == 'cancelled' %}
{% else %} Stopped by {{ broadcast_message.cancelled_by.name }}
Finished broadcasting {{ broadcast_message.finishes_at|format_datetime_human }}. {{ broadcast_message.cancelled_at|format_datetime_human }}.
{% endif %} {% else %}
</p> Finished broadcasting {{ broadcast_message.finishes_at|format_datetime_human }}.
{% endif %}
</p>
{% endif %}
{% for area in broadcast_message.areas %} {% for area in broadcast_message.areas %}
{% if loop.first %} {% if loop.first %}
+224 -4
View File
@@ -62,6 +62,7 @@ def test_empty_broadcast_dashboard(
assert [ assert [
normalize_spaces(row.text) for row in page.select('tbody tr .table-empty-message') normalize_spaces(row.text) for row in page.select('tbody tr .table-empty-message')
] == [ ] == [
'You do not have any broadcasts waiting for approval',
'You do not have any live broadcasts at the moment', 'You do not have any live broadcasts at the moment',
'You do not have any previous broadcasts', 'You do not have any previous broadcasts',
] ]
@@ -78,13 +79,29 @@ def test_broadcast_dashboard(
'.broadcast_dashboard', '.broadcast_dashboard',
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
) )
assert normalize_spaces(page.select('main h2')[0].text) == (
'Waiting for approval'
)
assert [ assert [
normalize_spaces(row.text) for row in page.select('table')[0].select('tbody tr') normalize_spaces(row.text) for row in page.select('table')[0].select('tbody tr')
] == [ ] == [
'Example template To England and Scotland Live until tomorrow at 2:20am', 'Example template To England and Scotland Prepared by Test User',
] ]
assert normalize_spaces(page.select('main h2')[1].text) == (
'Live broadcasts'
)
assert [ assert [
normalize_spaces(row.text) for row in page.select('table')[1].select('tbody tr') normalize_spaces(row.text) for row in page.select('table')[1].select('tbody tr')
] == [
'Example template To England and Scotland Live until tomorrow at 2:20am',
]
assert normalize_spaces(page.select('main h2')[2].text) == (
'Previous broadcasts'
)
assert [
normalize_spaces(row.text) for row in page.select('table')[2].select('tbody tr')
] == [ ] == [
'Example template To England and Scotland Stopped 10 February at 2:20am', 'Example template To England and Scotland Stopped 10 February at 2:20am',
'Example template To England and Scotland Finished yesterday at 8:20pm', 'Example template To England and Scotland Finished yesterday at 8:20pm',
@@ -107,8 +124,13 @@ def test_broadcast_dashboard_json(
json_response = json.loads(response.get_data(as_text=True)) json_response = json.loads(response.get_data(as_text=True))
assert json_response.keys() == {'live_broadcasts', 'previous_broadcasts'} assert json_response.keys() == {
'pending_approval_broadcasts',
'live_broadcasts',
'previous_broadcasts',
}
assert 'Prepared by Test User' in json_response['pending_approval_broadcasts']
assert 'Live until tomorrow at 2:20am' in json_response['live_broadcasts'] assert 'Live until tomorrow at 2:20am' in json_response['live_broadcasts']
assert 'Finished yesterday at 8:20pm' in json_response['previous_broadcasts'] assert 'Finished yesterday at 8:20pm' in json_response['previous_broadcasts']
@@ -291,12 +313,11 @@ def test_start_broadcasting(
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid, broadcast_message_id=fake_uuid,
data={ data={
'starts_at': '2020-02-02T02:02:02.222222',
'finishes_at': '2020-02-05T02:02:02.222222', 'finishes_at': '2020-02-05T02:02:02.222222',
}, },
) )
mock_update_broadcast_message_status.assert_called_once_with( mock_update_broadcast_message_status.assert_called_once_with(
'broadcasting', 'pending-approval',
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid, broadcast_message_id=fake_uuid,
) )
@@ -379,6 +400,205 @@ def test_view_broadcast_message_page(
] == expected_paragraphs ] == expected_paragraphs
@freeze_time('2020-02-22T22:22:22.000000')
def test_view_pending_broadcast(
mocker,
client_request,
service_one,
mock_get_broadcast_template,
fake_uuid,
):
mocker.patch(
'app.broadcast_message_api_client.get_broadcast_message',
return_value=broadcast_message_json(
id_=fake_uuid,
service_id=SERVICE_ONE_ID,
template_id=fake_uuid,
created_by_id=fake_uuid,
finishes_at='2020-02-23T23:23:23.000000',
status='pending-approval',
),
)
service_one['permissions'] += ['broadcast']
page = client_request.get(
'.view_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
)
assert (
normalize_spaces(page.select_one('.banner').text)
) == (
'Test User wants to broadcast this message until tomorrow at 11:23pm. '
'Start broadcasting now Reject this broadcast'
)
form = page.select_one('form.banner')
assert form['method'] == 'post'
assert 'action' not in form
assert form.select_one('button[type=submit]')
link = form.select_one('a.govuk-link.govuk-link--destructive')
assert link.text == 'Reject this broadcast'
assert link['href'] == url_for(
'.reject_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
)
@pytest.mark.parametrize('initial_status, expected_approval', (
('draft', False,),
('pending-approval', True),
('rejected', False),
('broadcasting', False),
('cancelled', False),
))
@freeze_time('2020-02-22T22:22:22.000000')
def test_approve_broadcast(
mocker,
client_request,
service_one,
mock_get_broadcast_template,
fake_uuid,
mock_update_broadcast_message,
mock_update_broadcast_message_status,
initial_status,
expected_approval,
):
mocker.patch(
'app.broadcast_message_api_client.get_broadcast_message',
return_value=broadcast_message_json(
id_=fake_uuid,
service_id=SERVICE_ONE_ID,
template_id=fake_uuid,
created_by_id=fake_uuid,
finishes_at='2020-02-23T23:23:23.000000',
status=initial_status,
),
)
service_one['permissions'] += ['broadcast']
client_request.post(
'.view_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_expected_redirect=url_for(
'.view_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_external=True,
)
)
if expected_approval:
mock_update_broadcast_message.assert_called_once_with(
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
data={
'starts_at': '2020-02-22T22:22:22',
},
)
mock_update_broadcast_message_status.assert_called_once_with(
'broadcasting',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
)
else:
assert mock_update_broadcast_message.called is False
assert mock_update_broadcast_message_status.called is False
@freeze_time('2020-02-22T22:22:22.000000')
def test_reject_broadcast(
mocker,
client_request,
service_one,
mock_get_broadcast_template,
fake_uuid,
mock_update_broadcast_message,
mock_update_broadcast_message_status,
):
mocker.patch(
'app.broadcast_message_api_client.get_broadcast_message',
return_value=broadcast_message_json(
id_=fake_uuid,
service_id=SERVICE_ONE_ID,
template_id=fake_uuid,
created_by_id=fake_uuid,
finishes_at='2020-02-23T23:23:23.000000',
status='pending-approval',
),
)
service_one['permissions'] += ['broadcast']
client_request.get(
'.reject_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_expected_redirect=url_for(
'.broadcast_dashboard',
service_id=SERVICE_ONE_ID,
_external=True,
)
)
assert mock_update_broadcast_message.called is False
mock_update_broadcast_message_status.assert_called_once_with(
'rejected',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
)
@pytest.mark.parametrize('initial_status', (
'draft',
'rejected',
'broadcasting',
'cancelled',
))
@freeze_time('2020-02-22T22:22:22.000000')
def test_cant_reject_broadcast_in_wrong_state(
mocker,
client_request,
service_one,
mock_get_broadcast_template,
fake_uuid,
mock_update_broadcast_message,
mock_update_broadcast_message_status,
initial_status,
):
mocker.patch(
'app.broadcast_message_api_client.get_broadcast_message',
return_value=broadcast_message_json(
id_=fake_uuid,
service_id=SERVICE_ONE_ID,
template_id=fake_uuid,
created_by_id=fake_uuid,
finishes_at='2020-02-23T23:23:23.000000',
status=initial_status,
),
)
service_one['permissions'] += ['broadcast']
client_request.get(
'.reject_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_expected_redirect=url_for(
'.view_broadcast_message',
service_id=SERVICE_ONE_ID,
broadcast_message_id=fake_uuid,
_external=True,
)
)
assert mock_update_broadcast_message.called is False
assert mock_update_broadcast_message_status.called is False
def test_no_view_page_for_draft( def test_no_view_page_for_draft(
client_request, client_request,
service_one, service_one,
+3
View File
@@ -4270,6 +4270,9 @@ def mock_get_broadcast_messages(
partial_json( partial_json(
status='draft', status='draft',
), ),
partial_json(
status='pending-approval',
),
partial_json( partial_json(
status='broadcasting', status='broadcasting',
starts_at=( starts_at=(