diff --git a/app/__init__.py b/app/__init__.py
index 7169dcd27..dd4d89b2c 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -251,6 +251,7 @@ def register_v2_blueprints(application):
from app.v2.template.get_template import v2_template_blueprint as get_template
from app.v2.templates.get_templates import v2_templates_blueprint as get_templates
from app.v2.template.post_template import v2_template_blueprint as post_template
+ from app.v2.broadcast.post_broadcast import v2_broadcast_blueprint as post_broadcast
from app.authentication.auth import requires_auth
post_notifications.before_request(requires_auth)
@@ -271,6 +272,9 @@ def register_v2_blueprints(application):
get_inbound_sms.before_request(requires_auth)
application.register_blueprint(get_inbound_sms)
+ post_broadcast.before_request(requires_auth)
+ application.register_blueprint(post_broadcast)
+
def init_app(app):
diff --git a/app/broadcast_message/translators.py b/app/broadcast_message/translators.py
new file mode 100644
index 000000000..c8fde2e58
--- /dev/null
+++ b/app/broadcast_message/translators.py
@@ -0,0 +1,31 @@
+from bs4 import BeautifulSoup
+
+
+def cap_xml_to_dict(cap_xml):
+ # This function assumes that it’s being passed valid CAP XML
+ cap = BeautifulSoup(cap_xml, "xml")
+ return {
+ "reference": cap.alert.identifier.text,
+ "category": cap.alert.info.category.text,
+ "expires": cap.alert.info.expires.text,
+ "content": cap.alert.info.description.text,
+ "areas": [
+ {
+ "name": area.areaDesc.text,
+ "polygons": [
+ cap_xml_polygon_to_list(polygon.text)
+ for polygon in area.find_all('polygon')
+ ]
+ }
+ for area in cap.alert.info.find_all('area')
+ ]
+ }
+
+
+def cap_xml_polygon_to_list(polygon_string):
+ return [
+ [
+ float(coordinate) for coordinate in pair.split(',')
+ ]
+ for pair in polygon_string.split(' ')
+ ]
diff --git a/app/v2/broadcast/__init__.py b/app/v2/broadcast/__init__.py
new file mode 100644
index 000000000..0a2b29987
--- /dev/null
+++ b/app/v2/broadcast/__init__.py
@@ -0,0 +1,10 @@
+from flask import Blueprint
+from app.v2.errors import register_errors
+
+v2_broadcast_blueprint = Blueprint(
+ "v2_broadcast_blueprint",
+ __name__,
+ url_prefix='/v2/broadcast',
+)
+
+register_errors(v2_broadcast_blueprint)
diff --git a/app/v2/broadcast/broadcast_schemas.py b/app/v2/broadcast/broadcast_schemas.py
new file mode 100644
index 000000000..1ac2b437d
--- /dev/null
+++ b/app/v2/broadcast/broadcast_schemas.py
@@ -0,0 +1,97 @@
+post_broadcast_schema = {
+ "$schema": "http://json-schema.org/draft-07/schema",
+ "type": "object",
+ "required": [
+ "reference",
+ "category",
+ "content",
+ "areas",
+ ],
+ "additionalProperties": False,
+ "properties": {
+ "reference": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "category": {
+ "type": "string",
+ "enum": [
+ "Geo",
+ "Met",
+ "Safety",
+ "Security",
+ "Rescue",
+ "Fire",
+ "Health",
+ "Env",
+ "Transport",
+ "Infra",
+ "CBRNE",
+ "Other",
+ ],
+ },
+ "expires": {
+ "type": "string",
+ "format": "date-time",
+ },
+ "content": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1395,
+ },
+ "web": {
+ "type": "string",
+ "format": "uri",
+ },
+ "areas": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/definitions/area",
+ },
+ },
+ },
+ "definitions": {
+ "area": {
+ "type": "object",
+ "required": [
+ "name",
+ "polygons",
+ ],
+ "additionalProperties": False,
+ "properties": {
+ "name": {
+ "type": "string",
+ },
+ "polygons": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/polygon",
+ },
+ ],
+ },
+ },
+ },
+ },
+ "polygon": {
+ "type": "array",
+ "minItems": 4,
+ "items": {
+ "$ref": "#/definitions/coordinatePair",
+ },
+ },
+ "coordinatePair": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ },
+ "minItems": 2,
+ "maxItems": 2,
+ },
+ },
+}
diff --git a/app/v2/broadcast/post_broadcast.py b/app/v2/broadcast/post_broadcast.py
new file mode 100644
index 000000000..79b32ae4a
--- /dev/null
+++ b/app/v2/broadcast/post_broadcast.py
@@ -0,0 +1,71 @@
+from itertools import chain
+from flask import current_app, jsonify, request
+from notifications_utils.polygons import Polygons
+from app import authenticated_service, api_user
+from app.broadcast_message.translators import cap_xml_to_dict
+from app.dao.dao_utils import dao_save_object
+from app.notifications.validators import check_service_has_permission
+from app.models import BROADCAST_TYPE, BroadcastMessage, BroadcastStatusType
+from app.schema_validation import validate
+from app.v2.broadcast import v2_broadcast_blueprint
+from app.v2.broadcast.broadcast_schemas import post_broadcast_schema
+from app.v2.errors import BadRequestError
+from app.xml_schemas import validate_xml
+
+
+@v2_broadcast_blueprint.route("", methods=['POST'])
+def create_broadcast():
+
+ check_service_has_permission(
+ BROADCAST_TYPE,
+ authenticated_service.permissions,
+ )
+
+ if request.content_type != 'application/cap+xml':
+ raise BadRequestError(
+ message=f'Content type {request.content_type} not supported',
+ status_code=415,
+ )
+
+ cap_xml = request.get_data(as_text=True)
+
+ if not validate_xml(cap_xml, 'CAP-v1.2.xsd'):
+ raise BadRequestError(
+ message=f'Request data is not valid CAP XML',
+ status_code=400,
+ )
+
+ broadcast_json = cap_xml_to_dict(cap_xml)
+
+ validate(broadcast_json, post_broadcast_schema)
+
+ polygons = Polygons(list(chain.from_iterable((
+ area['polygons'] for area in broadcast_json['areas']
+ ))))
+
+ broadcast_message = BroadcastMessage(
+ service_id=authenticated_service.id,
+ content=broadcast_json['content'],
+ reference=broadcast_json['reference'],
+ areas={
+ 'areas': [
+ area['name'] for area in broadcast_json['areas']
+ ],
+ 'simple_polygons': polygons.smooth.simplify.as_coordinate_pairs_long_lat,
+ },
+ status=BroadcastStatusType.PENDING_APPROVAL,
+ api_key_id=api_user.id,
+ # The client may pass in broadcast_json['expires'] but it’s
+ # simpler for now to ignore it and have the rules around expiry
+ # for broadcasts created with the API match those created from
+ # the admin app
+ )
+
+ dao_save_object(broadcast_message)
+
+ current_app.logger.info(
+ f'Broadcast message {broadcast_message.id} created for service '
+ f'{authenticated_service.id} with reference {broadcast_json["reference"]}'
+ )
+
+ return jsonify(broadcast_message.serialize()), 201
diff --git a/app/xml_schemas/CAP-v1.2.xsd b/app/xml_schemas/CAP-v1.2.xsd
new file mode 100644
index 000000000..ed97952be
--- /dev/null
+++ b/app/xml_schemas/CAP-v1.2.xsd
@@ -0,0 +1,218 @@
+
+
+
+
+
+ CAP Alert Message (version 1.2)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/xml_schemas/__init__.py b/app/xml_schemas/__init__.py
new file mode 100644
index 000000000..4d8d11ef0
--- /dev/null
+++ b/app/xml_schemas/__init__.py
@@ -0,0 +1,19 @@
+from lxml import etree
+from pathlib import Path
+
+
+def validate_xml(document, schema_file_name):
+
+ path = Path(__file__).resolve().parent / schema_file_name
+ contents = path.read_text()
+
+ schema_root = etree.XML(contents.encode('utf-8'))
+ schema = etree.XMLSchema(schema_root)
+ parser = etree.XMLParser(schema=schema)
+
+ try:
+ etree.fromstring(document, parser)
+ except etree.XMLSyntaxError:
+ return False
+
+ return True
diff --git a/requirements-app.txt b/requirements-app.txt
index a71754683..51b9e660e 100644
--- a/requirements-app.txt
+++ b/requirements-app.txt
@@ -23,13 +23,15 @@ SQLAlchemy==1.3.22
strict-rfc3339==0.7
rfc3987==1.3.8
cachetools==4.2.0
+beautifulsoup4==4.9.3
+lxml==4.6.2
notifications-python-client==5.7.1
# PaaS
awscli-cwlogs==1.4.6
-git+https://github.com/alphagov/notifications-utils.git@43.5.9#egg=notifications-utils==43.5.9
+git+https://github.com/alphagov/notifications-utils.git@43.8.0#egg=notifications-utils==43.8.0
# gds-metrics requires prometheseus 0.2.0, override that requirement as 0.7.1 brings significant performance gains
prometheus-client==0.9.0
diff --git a/requirements.txt b/requirements.txt
index 3e1cefe8c..d98dbfaad 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -25,31 +25,33 @@ SQLAlchemy==1.3.22
strict-rfc3339==0.7
rfc3987==1.3.8
cachetools==4.2.0
+beautifulsoup4==4.9.3
+lxml==4.6.2
notifications-python-client==5.7.1
# PaaS
awscli-cwlogs==1.4.6
-git+https://github.com/alphagov/notifications-utils.git@43.5.9#egg=notifications-utils==43.5.9
+git+https://github.com/alphagov/notifications-utils.git@43.8.0#egg=notifications-utils==43.8.0
# gds-metrics requires prometheseus 0.2.0, override that requirement as 0.7.1 brings significant performance gains
prometheus-client==0.9.0
gds-metrics==0.2.4
## The following requirements were added by pip freeze:
-alembic==1.4.3
+alembic==1.5.2
amqp==1.4.9
anyjson==0.3.3
attrs==20.3.0
-awscli==1.18.206
+awscli==1.18.219
bcrypt==3.2.0
billiard==3.3.0.23
bleach==3.2.1
blinker==1.4
boto==2.49.0
-boto3==1.16.46
-botocore==1.19.46
+boto3==1.16.59
+botocore==1.19.59
certifi==2020.12.5
chardet==4.0.0
click==7.1.2
@@ -60,19 +62,19 @@ flask-redis==0.4.0
future==0.18.2
geojson==2.5.0
govuk-bank-holidays==0.8
-greenlet==0.4.17
+greenlet==1.0.0
idna==2.10
-importlib-metadata==3.3.0
+importlib-metadata==3.4.0
Jinja2==2.11.2
jmespath==0.10.0
kombu==3.0.37
-Mako==1.1.3
+Mako==1.1.4
MarkupSafe==1.1.1
mistune==0.8.4
monotonic==1.5
orderedset==2.0.3
packaging==20.8
-phonenumbers==8.12.15
+phonenumbers==8.12.16
pyasn1==0.4.8
pycparser==2.20
pyparsing==2.4.7
@@ -86,9 +88,11 @@ PyYAML==5.3.1
redis==3.5.3
requests==2.25.1
rsa==4.5
-s3transfer==0.3.3
+s3transfer==0.3.4
+Shapely==1.7.1
six==1.15.0
smartypants==2.0.1
+soupsieve==2.1
statsd==3.3.0
typing-extensions==3.7.4.3
urllib3==1.26.2
diff --git a/tests/app/v2/broadcast/__init__.py b/tests/app/v2/broadcast/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/app/v2/broadcast/sample_cap_xml_documents.py b/tests/app/v2/broadcast/sample_cap_xml_documents.py
new file mode 100644
index 000000000..86dba8bd3
--- /dev/null
+++ b/tests/app/v2/broadcast/sample_cap_xml_documents.py
@@ -0,0 +1,34 @@
+WAINFLEET = """
+
+ 50385fcb0ab7aa447bbd46d848ce8466E
+ www.gov.uk/environment-agency
+ 2020-02-16T23:01:13-00:00
+ Actual
+ Update
+ Flood warning service
+ Public
+ www.gov.uk/environment-agency,4f6d28b10ab7aa447bbd46d85f1e9effE,2020-02-16T19:20:03+00:00
+
+ en-GB
+ Met
+ 053/055 Issue Severe Flood Warning EA
+ Immediate
+ Severe
+ Likely
+ 2020-02-26T23:01:14-00:00
+ Environment Agency
+ A severe flood warning has been issued. Storm Dennis has resulted in significant rainfall in the Steeping River catchment with several bands of heavy rain passing through the area during today (Sunday 16 Feb). River levels along the Steeping River and the Wainfleet relief channel are expected to be similar to those in June 2019. This could result in flood embankments being overtopped. Should this happen, there is an increased risk that flood embankments could breach. It is expected that peak levels along the Steeping River in Wainfleet will be between midnight and 3am tonight. A multi-agency meeting is taking place this evening. Further messages will be issued should this be required. Do not walk on flood embankments and avoid riverside paths. Our staff are out in the area to check the flood defences and assist the emergency services and council. We will be closely monitoring the situation throughout the night.
+ # To check the latest information for your area - Visit [GOV.UK](https://flood-warning-information.service.gov.uk) to see the current flood warnings, view river and sea levels or check the 5-day flood risk forecast: https://flood-warning-information.service.gov.uk - Follow [@EnvAgency](https://twitter.com/EnvAgency) and [#floodaware](https://twitter.com/hashtag/floodaware) on Twitter. - Tune into weather, news and travel bulletins on local television and radio. - For access to flood warning information offline call Floodline on 0345 988 1188 using quickdial code: 307052. # What you should consider doing now - Call 999 if you are in immediate danger. - Co-operate with the emergency services and evacuate your property if told to do so. Most evacuation centres will let you bring your pets. - Act on your flood plan if you have one. - Move your family and pets to a safe place with a means of escape. - Use flood protection equipment (such as flood barriers, air brick covers and pumps) to protect your property. Unless you have proper equipment do not waste valuable time trying to keep the water out. - Move important items upstairs or to a safe place in your property, starting with cherished items of personal value that you will not be able to replace (such as family photographs). Next move valuables (such as computers), movable furniture and furnishings. - You may need to leave your property, so pack a bag with enough items for a few nights away. Include essential items including a torch with spare batteries, mobile phone and charger, warm clothes, home insurance information, water, food, first aid kit and any prescription medicines or baby care items you may need. - Turn off gas, electricity and water mains supplies before flood water starts to enter your property. Never touch an electrical switch if you are standing in water. - If it is safe to do so, make sure neighbours are aware of the situation and offer help to anyone who may need it. - Avoid walking, cycling or driving through flood water - 30 cm of fast-flowing water can move a car and 6 inches can knock an adult off their feet. - Flood water is dangerous and may be polluted. Wash your hands thoroughly if you’ve been in contact with it. ##### Businesses - Act on your business flood plan if you have one. - Move your staff and customers to a safe place with a means of escape. - Move stock and other valuable items upstairs or to a safe place in your building. For media enquiries please contact our media teams: https://www.gov.uk/government/organisations/environment-agency/about/media-enquiries
+ https://flood-warning-information.service.gov.uk
+ 0345 988 1188
+
+ River Steeping in Wainfleet All Saints
+ 53.10569,0.24453 53.10593,0.24430 53.10601,0.24375 53.10615,0.24349 53.10629,0.24356 53.10656,0.24336 53.10697,0.24354 53.10684,0.24298 53.10694,0.24264 53.10721,0.24302 53.10752,0.24310 53.10777,0.24308 53.10805,0.24320 53.10803,0.24187 53.10776,0.24085 53.10774,0.24062 53.10702,0.24056 53.10679,0.24088 53.10658,0.24071 53.10651,0.24049 53.10656,0.24022 53.10642,0.24022 53.10632,0.24052 53.10629,0.24082 53.10612,0.24093 53.10583,0.24133 53.10564,0.24178 53.10541,0.24282 53.10569,0.24453
+
+ TargetAreaCode
+ 053FWFSTEEP4
+
+
+
+
+"""
diff --git a/tests/app/v2/broadcast/test_post_broadcast.py b/tests/app/v2/broadcast/test_post_broadcast.py
new file mode 100644
index 000000000..0cc2839df
--- /dev/null
+++ b/tests/app/v2/broadcast/test_post_broadcast.py
@@ -0,0 +1,131 @@
+from flask import json
+from freezegun import freeze_time
+from tests import create_authorization_header
+from unittest.mock import ANY
+from . import sample_cap_xml_documents
+
+
+def test_broadcast_for_service_without_permission_returns_400(
+ client,
+ sample_service,
+):
+ auth_header = create_authorization_header(service_id=sample_service.id)
+ response = client.post(
+ path='/v2/broadcast',
+ data='',
+ headers=[('Content-Type', 'application/json'), auth_header],
+ )
+
+ assert response.status_code == 400
+ assert response.get_json()['errors'][0]['message'] == (
+ 'Service is not allowed to send broadcast messages'
+ )
+
+
+def test_valid_post_broadcast_returns_201(
+ client,
+ sample_broadcast_service,
+):
+ auth_header = create_authorization_header(service_id=sample_broadcast_service.id)
+
+ response = client.post(
+ path='/v2/broadcast',
+ data=json.dumps({
+ 'content': 'This is a test',
+ 'reference': 'abc123',
+ 'category': 'Other',
+ 'areas': [
+ {
+ 'name': 'Hackney Marshes',
+ 'polygons': [[
+ [-0.038280487060546875, 51.55738264619775],
+ [-0.03184318542480469, 51.553913882566754],
+ [-0.023174285888671875, 51.55812972989382],
+ [-0.023174285888671999, 51.55812972989999],
+ [-0.029869079589843747, 51.56165153059717],
+ [-0.038280487060546875, 51.55738264619775],
+ ]],
+ },
+ ],
+ }),
+ headers=[('Content-Type', 'application/json'), auth_header],
+ )
+
+ assert response.status_code == 415
+ assert json.loads(response.get_data(as_text=True)) == {
+ 'errors': [{
+ 'error': 'BadRequestError',
+ 'message': 'Content type application/json not supported'
+ }],
+ 'status_code': 415,
+ }
+
+
+def test_valid_post_cap_xml_broadcast_returns_201(
+ client,
+ sample_broadcast_service,
+):
+ auth_header = create_authorization_header(service_id=sample_broadcast_service.id)
+
+ response = client.post(
+ path='/v2/broadcast',
+ data=sample_cap_xml_documents.WAINFLEET,
+ headers=[('Content-Type', 'application/cap+xml'), auth_header],
+ )
+
+ assert response.status_code == 201
+
+ response_json = json.loads(response.get_data(as_text=True))
+
+ assert response_json['approved_at'] is None
+ assert response_json['approved_by_id'] == None
+ assert response_json['areas'] == [
+ 'River Steeping in Wainfleet All Saints'
+ ]
+ assert response_json['cancelled_at'] == None
+ assert response_json['cancelled_by_id'] == None
+ assert response_json['content'].startswith(
+ 'A severe flood warning has been issued. Storm Dennis'
+ )
+ assert response_json['content'].endswith(
+ 'closely monitoring the situation throughout the night. '
+ )
+ assert response_json['reference'] == '50385fcb0ab7aa447bbd46d848ce8466E'
+ assert response_json['created_at'] # datetime generated by the DB so can’t freeze it
+ assert response_json['created_by_id'] == None
+ assert response_json['finishes_at'] is None
+ assert response_json['id'] == ANY
+ assert response_json['personalisation'] is None
+ assert response_json['service_id'] == str(sample_broadcast_service.id)
+ assert len(response_json['simple_polygons']) == 1
+ assert len(response_json['simple_polygons'][0]) == 23
+ assert response_json['simple_polygons'][0][0] == [53.10561946699971, 0.2441253049430708]
+ assert response_json['simple_polygons'][0][-1] == [53.10561946699971, 0.2441253049430708]
+ assert response_json['starts_at'] is None
+ assert response_json['status'] == 'pending-approval'
+ assert response_json['template_id'] is None
+ assert response_json['template_name'] is None
+ assert response_json['template_version'] is None
+ assert response_json['updated_at'] is None
+
+
+def test_invalid_post_cap_xml_broadcast_returns_400(
+ client,
+ sample_broadcast_service,
+):
+ auth_header = create_authorization_header(service_id=sample_broadcast_service.id)
+
+ response = client.post(
+ path='/v2/broadcast',
+ data="Oh no",
+ headers=[('Content-Type', 'application/cap+xml'), auth_header],
+ )
+
+ assert response.status_code == 400
+ assert json.loads(response.get_data(as_text=True)) == {
+ 'errors': [{
+ 'error': 'BadRequestError',
+ 'message': 'Request data is not valid CAP XML'
+ }],
+ 'status_code': 400,
+ }