Merge pull request #3500 from alphagov/choose-broadcast-area-flow

Add flow for choosing an area to broadcast to
This commit is contained in:
Chris Hill-Scott
2020-07-08 14:14:57 +01:00
committed by GitHub
18 changed files with 529 additions and 9 deletions

View File

@@ -612,7 +612,8 @@ def useful_headers_after_request(response):
"connect-src 'self' *.google-analytics.com;"
"object-src 'self';"
"font-src 'self' {asset_domain} data:;"
"img-src 'self' {asset_domain} *.google-analytics.com *.notifications.service.gov.uk {logo_domain} data:;"
"img-src 'self' {asset_domain} *.tile.openstreetmap.org *.google-analytics.com"
" *.notifications.service.gov.uk {logo_domain} data:;"
"frame-src 'self' www.youtube-nocookie.com;".format(
asset_domain=current_app.config['ASSET_DOMAIN'],
logo_domain=get_logo_cdn_domain(),

View File

@@ -0,0 +1,64 @@
.area-list {
&-item {
display: inline-block;
border: 2px solid $black;
padding: (govuk-spacing(1) + 1px) (govuk-spacing(2) + 35px) govuk-spacing(1) govuk-spacing(2);
margin: 0 govuk-spacing(1) govuk-spacing(2) 0;
position: relative;
&-remove {
font-size: 0;
&:before {
content: "×";
display: block;
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
width: 35px;
background: $govuk-blue;
color: $white;
box-shadow: -2px 0 0 0 $black, inset 1px 0 0 0 rgba($white, 0.1);
border: 2px solid $govuk-blue;
border-left: none;
font-size: 24px;
font-weight: normal;
line-height: 40px;
text-align: center;
text-decoration: none;
}
&:hover,
&:focus {
&:before {
color: $light-blue-25;
}
}
&:focus {
outline: none;
&:before {
background: $focus-colour;
color: $black;
border-color: $black;
}
}
}
}
.govuk-button {
margin-left: 3px;
}
}

View File

@@ -70,6 +70,7 @@ $path: '/static/images/';
@import 'components/preview-pane';
@import 'components/task-list';
@import 'components/loading-indicator';
@import 'components/area-list';
@import 'views/dashboard';
@import 'views/users';

View File

@@ -7,6 +7,7 @@ from app.main.views import ( # noqa isort:skip
add_service,
agreement,
api_keys,
broadcast,
choose_account,
code_not_received,
conversation,

View File

@@ -1809,3 +1809,18 @@ class AcceptAgreementForm(StripWhitespaceForm):
float(field.data)
except (TypeError, ValueError):
raise ValidationError("Must be a number")
class BroadcastAreaForm(StripWhitespaceForm):
areas = MultiCheckboxField('Choose areas to broadcast to')
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
self.areas.choices = choices
@classmethod
def from_library(cls, library):
return cls(choices=[
(area.id, area.name) for area in sorted(library)
])

105
app/main/views/broadcast.py Normal file
View File

@@ -0,0 +1,105 @@
from flask import redirect, render_template, request, session, url_for
from notifications_utils.broadcast_areas import broadcast_area_libraries
from orderedset import OrderedSet
from app import current_service
from app.main import main
from app.main.forms import BroadcastAreaForm, SearchByNameForm
from app.utils import service_has_permission, user_has_permissions
@main.route('/services/<uuid:service_id>/broadcast')
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def broadcast(service_id):
if 'broadcast_areas' in session:
session.pop('broadcast_areas')
return redirect(url_for(
'.preview_broadcast_areas',
service_id=current_service.id,
))
@main.route('/services/<uuid:service_id>/broadcast/areas')
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def preview_broadcast_areas(service_id):
selected_areas_ids = session.get('broadcast_areas', [])
return render_template(
'views/broadcast/preview-areas.html',
selected=list(broadcast_area_libraries.get_areas(
*selected_areas_ids
)),
area_polygons=broadcast_area_libraries.get_polygons_for_areas_lat_long(
*selected_areas_ids
)
)
@main.route('/services/<uuid:service_id>/broadcast/libraries')
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def choose_broadcast_library(service_id):
return render_template(
'views/broadcast/libraries.html',
libraries=broadcast_area_libraries,
selected=broadcast_area_libraries.get_areas(
*session.get('broadcast_areas', [])
),
)
@main.route('/services/<uuid:service_id>/broadcast/libraries/<library_slug>', methods=['GET', 'POST'])
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def choose_broadcast_area(service_id, library_slug):
library = broadcast_area_libraries.get(library_slug)
form = BroadcastAreaForm.from_library(library)
if form.validate_on_submit():
if not session.get('broadcast_areas'):
session['broadcast_areas'] = []
session['broadcast_areas'] = session['broadcast_areas'] + form.areas.data
session['broadcast_areas'] = list(OrderedSet(
session['broadcast_areas']
))
return redirect(url_for(
'.preview_broadcast_areas',
service_id=current_service.id,
))
return render_template(
'views/broadcast/areas.html',
form=form,
search_form=SearchByNameForm(),
show_search_form=(len(form.areas.choices) > 7),
page_title=library.name,
)
@main.route('/services/<uuid:service_id>/broadcast/remove/<area_slug>')
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def remove_broadcast_area(service_id, area_slug):
session['broadcast_areas'] = list(filter(
lambda saved_area_id: saved_area_id != area_slug,
session.get('broadcast_areas', []),
))
return redirect(url_for(
'.preview_broadcast_areas',
service_id=current_service.id,
))
@main.route('/services/<uuid:service_id>/broadcast/preview', methods=['GET', 'POST'])
@user_has_permissions('send_messages')
@service_has_permission('broadcast')
def preview_broadcast_message(service_id):
if request.method == 'POST':
return 'OK'
selected_areas = session.get('broadcast_areas', [])
return render_template(
'views/broadcast/preview-message.html',
selected=list(broadcast_area_libraries.get_areas(
*selected_areas
)),
)

View File

@@ -351,6 +351,12 @@ class HeaderNavigation(Navigation):
'old_guest_list',
'who_can_use_notify',
'who_its_for',
'broadcast',
'preview_broadcast_areas',
'choose_broadcast_library',
'choose_broadcast_area',
'remove_broadcast_area',
'preview_broadcast_message',
}
# header HTML now comes from GOVUK Frontend so requires a boolean, not an attribute
@@ -399,6 +405,12 @@ class MainNavigation(Navigation):
'view_template',
'view_template_version',
'view_template_versions',
'broadcast',
'preview_broadcast_areas',
'choose_broadcast_library',
'choose_broadcast_area',
'remove_broadcast_area',
'preview_broadcast_message',
},
'uploads': {
'upload_contact_list',
@@ -978,6 +990,12 @@ class CaseworkNavigation(Navigation):
'old_guest_list',
'who_can_use_notify',
'who_its_for',
'broadcast',
'preview_broadcast_areas',
'choose_broadcast_library',
'choose_broadcast_area',
'remove_broadcast_area',
'preview_broadcast_message',
}
@@ -1288,4 +1306,10 @@ class OrgNavigation(Navigation):
'old_guest_list',
'who_can_use_notify',
'who_its_for',
'broadcast',
'preview_broadcast_areas',
'choose_broadcast_library',
'choose_broadcast_area',
'remove_broadcast_area',
'preview_broadcast_message',
}

View File

@@ -14,6 +14,8 @@
{% block head %}
<link rel="stylesheet" media="screen" href="{{ asset_url('stylesheets/main.css') }}" />
<link rel="stylesheet" media="print" href="{{ asset_url('stylesheets/print.css') }}" />
{% block extra_stylesheets %}
{% endblock %}
<style>
.govuk-header__container { border-color: {{header_colour}} }
</style>
@@ -251,6 +253,8 @@
{% endblock %}
{% block bodyEnd %}
{% block extra_javascripts %}
{% endblock %}
<!--[if gt IE 8]><!-->
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
<!--<![endif]-->

View File

@@ -0,0 +1,29 @@
{% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import sticky_page_footer %}
{% from "components/checkbox.html" import checkboxes %}
{% from "components/form.html" import form_wrapper %}
{% from "components/live-search.html" import live_search %}
{% extends "withnav_template.html" %}
{% block service_page_title %}
{{ page_title }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
page_title,
back_link=url_for('.choose_broadcast_library', service_id=current_service.id),
)}}
{{ live_search(target_selector='.multiple-choice', show=show_search_form, form=search_form, label='Search by name') }}
{{ live_search(target_selector='.multiple-choice', show=show_search_form, form=search_form, label='Search by name') }}
{% call form_wrapper() %}
{{ checkboxes(form.areas, hide_legend=True) }}
{{ sticky_page_footer('Add to broadcast') }}
{% endcall %}
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% from "components/page-header.html" import page_header %}
{% extends "withnav_template.html" %}
{% block service_page_title %}
Choose where to broadcast to
{% endblock %}
{% block maincolumn_content %}
{{ page_header(
"Choose where to broadcast to",
back_link=url_for(".preview_broadcast_areas", service_id=current_service.id)
) }}
{% for library in libraries|sort %}
<a class="file-list-filename-large govuk-link govuk-link--no-visited-state" href="{{ url_for('.choose_broadcast_area', service_id=current_service.id, library_slug=library.id) }}">{{ library.name }}</a>
<p class="file-list-hint-large">{{ library.examples }}</p>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% from "components/button/macro.njk" import govukButton %}
{% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import sticky_page_footer %}
{% extends "withnav_template.html" %}
{% block service_page_title %}
Choose where to broadcast to
{% endblock %}
{% block extra_stylesheets %}
<link rel="stylesheet" href="{{ asset_url('stylesheets/leaflet.css') }}" />
<style>
#map {
z-index: 50;
margin-bottom: 30px;
border: 1px solid #b1b4b6;
}
</style>
{% endblock %}
{% block extra_javascripts %}
<script src="{{ asset_url('javascripts/leaflet.js') }}"></script>
<script>
var mapElement = document.getElementById('map');
mapElement.style.height = Math.max(320, window.innerHeight - mapElement.offsetTop - 100) + 'px';
var mymap = L.map(
'map',
{
scrollWheelZoom: false
}
)
var polygons = []
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(mymap);
{% for area in area_polygons %}
polygons.push(
L.polygon({{area}}, {
color: '#0b0b0c', // $black
fillColor: '#2B8CC4', // $light-blue
fillOpacity: 0.2,
weight: 2
})
);
{% endfor %}
var polygonGroup = L.featureGroup(polygons).addTo(mymap);
mymap.fitBounds(
polygonGroup.getBounds(),
{padding: [5, 5]}
);
</script>
{% endblock %}
{% block maincolumn_content %}
{{ page_header("Choose where to broadcast to", back_link="{{ url_for('.broadcast', service_id=current_service.id) }}") }}
{% for area in selected %}
{% if loop.first %}
<ul class="area-list">
{% endif %}
<li class="area-list-item">
{{ area.name }} <a class="area-list-item-remove" href="{{ url_for('.remove_broadcast_area', service_id=current_service.id, area_slug=area.id) }}">remove</a>
</li>
{% if loop.last %}
{{ govukButton({
"element": "a",
"text": "Add another area",
"href": url_for('.choose_broadcast_library', service_id=current_service.id),
"classes": "govuk-button--secondary govuk-!-margin-bottom-5"
}) }}
</ul>
{% endif %}
{% else %}
<p class="govuk-body">
{{ govukButton({
"element": "a",
"text": "Add broadcast areas",
"href": url_for('.choose_broadcast_library', service_id=current_service.id),
"classes": "govuk-button--secondary"
}) }}
</p>
{% endfor %}
{% if selected %}
<div id="map"></div>
<form action="{{ url_for('.preview_broadcast_message', service_id=current_service.id) }}" method="get">
{{ sticky_page_footer('Continue to preview') }}
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% from "components/button/macro.njk" import govukButton %}
{% from "components/form.html" import form_wrapper %}
{% from "components/page-header.html" import page_header %}
{% from "components/page-footer.html" import sticky_page_footer %}
{% extends "withnav_template.html" %}
{% block service_page_title %}
Preview message
{% endblock %}
{% block maincolumn_content %}
{{ page_header("Preview message", back_link=url_for('.preview_broadcast_areas', service_id=current_service.id)) }}
{% for area in selected %}
{% if loop.first %}
<ul class="area-list">
{% endif %}
<li class="area-list-item">
{{ area.name }}
</li>
{% if loop.last %}
</ul>
{% endif %}
{% endfor %}
{% if selected %}
<p class="govuk-body">
{{ "<message here>" }}
</p>
{% call form_wrapper() %}
{{ sticky_page_footer('Start broadcast') }}
{% endcall %}
{% endif %}
{% endblock %}

View File

@@ -29,8 +29,15 @@
</div>
{% endif %}
{% elif template.template_type == 'broadcast' %}
{% if current_user.has_permissions('send_messages') %}
<div class="govuk-grid-column-one-half">
<a href="{{ url_for(".broadcast", service_id=current_service.id) }}" class="govuk-link govuk-link--no-visited-state pill-separate-item">
Prepare broadcast
</a>
</div>
{% endif %}
{% if current_user.has_permissions('manage_templates') %}
<div class="govuk-grid-column-full">
<div class="govuk-grid-column-one-half">
<a href="{{ url_for(".edit_service_template", service_id=current_service.id, template_id=template.id) }}" class="govuk-link govuk-link--no-visited-state pill-separate-item">
Edit
</a>

View File

@@ -23,7 +23,7 @@ notifications-python-client==5.6.0
awscli-cwlogs>=1.4,<1.5
itsdangerous==1.1.0
git+https://github.com/alphagov/notifications-utils.git@40.2.1#egg=notifications-utils==40.2.1
git+https://github.com/alphagov/notifications-utils.git@40.3.1#egg=notifications-utils==40.3.1
git+https://github.com/alphagov/govuk-frontend-jinja.git@v0.5.1-alpha#egg=govuk-frontend-jinja==0.5.1-alpha
# gds-metrics requires prometheseus 0.2.0, override that requirement as later versions bring significant performance gains

View File

@@ -25,7 +25,7 @@ notifications-python-client==5.6.0
awscli-cwlogs>=1.4,<1.5
itsdangerous==1.1.0
git+https://github.com/alphagov/notifications-utils.git@40.2.1#egg=notifications-utils==40.2.1
git+https://github.com/alphagov/notifications-utils.git@40.3.1#egg=notifications-utils==40.3.1
git+https://github.com/alphagov/govuk-frontend-jinja.git@v0.5.1-alpha#egg=govuk-frontend-jinja==0.5.1-alpha
# gds-metrics requires prometheseus 0.2.0, override that requirement as later versions bring significant performance gains
@@ -33,10 +33,10 @@ prometheus-client==0.8.0
gds-metrics==0.2.0
## The following requirements were added by pip freeze:
awscli==1.18.93
awscli==1.18.95
bleach==3.1.4
boto3==1.10.38
botocore==1.17.16
botocore==1.17.18
cachetools==4.1.0
certifi==2020.6.20
chardet==3.0.4
@@ -48,6 +48,7 @@ docutils==0.15.2
et-xmlfile==1.0.1
flask-redis==0.4.0
future==0.18.2
geojson==2.5.0
greenlet==0.4.16
idna==2.10
jdcal==1.4.1

View File

@@ -0,0 +1,103 @@
import pytest
from flask import url_for
from tests.conftest import SERVICE_ONE_ID
@pytest.mark.parametrize('endpoint, extra_args', (
('.broadcast', {}),
('.preview_broadcast_areas', {}),
('.choose_broadcast_library', {}),
('.choose_broadcast_area', {'library_slug': 'countries'}),
('.remove_broadcast_area', {'area_slug': 'england'}),
('.preview_broadcast_message', {}),
))
def test_broadcast_pages_403_without_permission(
client_request,
endpoint,
extra_args,
):
client_request.get(
endpoint,
service_id=SERVICE_ONE_ID,
_expected_status=403,
**extra_args
)
def test_broadcast_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.broadcast',
service_id=SERVICE_ONE_ID,
_expected_redirect=url_for(
'.preview_broadcast_areas',
service_id=SERVICE_ONE_ID,
_external=True,
),
),
def test_preview_broadcast_areas_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.preview_broadcast_areas',
service_id=SERVICE_ONE_ID,
)
def test_choose_broadcast_library_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.choose_broadcast_library',
service_id=SERVICE_ONE_ID,
)
def test_choose_broadcast_area_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.choose_broadcast_area',
service_id=SERVICE_ONE_ID,
library_slug='countries',
)
def test_remove_broadcast_area_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.remove_broadcast_area',
service_id=SERVICE_ONE_ID,
area_slug='england',
_expected_redirect=url_for(
'.preview_broadcast_areas',
service_id=SERVICE_ONE_ID,
_external=True,
),
),
def test_preview_broadcast_message_page(
client_request,
service_one,
):
service_one['permissions'] += ['broadcast']
client_request.get(
'.preview_broadcast_message',
service_id=SERVICE_ONE_ID,
),

View File

@@ -20,7 +20,8 @@ def test_owasp_useful_headers_set(
"object-src 'self';"
"font-src 'self' static.example.com data:;"
"img-src "
"'self' static.example.com *.google-analytics.com *.notifications.service.gov.uk static-logos.test.com data:;"
"'self' static.example.com *.tile.openstreetmap.org *.google-analytics.com"
" *.notifications.service.gov.uk static-logos.test.com data:;"
"frame-src 'self' www.youtube-nocookie.com;"
)
@@ -41,7 +42,8 @@ def test_headers_non_ascii_characters_are_replaced(
"connect-src 'self' *.google-analytics.com;"
"object-src 'self';"
"font-src 'self' static.example.com data:;"
"img-src "
"'self' static.example.com *.google-analytics.com *.notifications.service.gov.uk static-logos??.test.com data:;"
"img-src"
" 'self' static.example.com *.tile.openstreetmap.org *.google-analytics.com"
" *.notifications.service.gov.uk static-logos??.test.com data:;"
"frame-src 'self' www.youtube-nocookie.com;"
)

View File

@@ -693,6 +693,10 @@ def test_view_broadcast_template(
(link.text.strip(), link['href'])
for link in page.select('.pill-separate-item')
] == [
('Prepare broadcast', url_for(
'.broadcast',
service_id=SERVICE_ONE_ID,
)),
('Edit', url_for(
'.edit_service_template',
service_id=SERVICE_ONE_ID,