Merge pull request #3060 from alphagov/add-broadcast-api-endpoint

Add public API endpoint to create broadcast messages
This commit is contained in:
Chris Hill-Scott
2021-01-28 12:59:41 +00:00
committed by GitHub
12 changed files with 632 additions and 11 deletions

View File

@@ -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):

View File

@@ -0,0 +1,31 @@
from bs4 import BeautifulSoup
def cap_xml_to_dict(cap_xml):
# This function assumes that its 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(' ')
]

View File

@@ -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)

View File

@@ -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,
},
},
}

View File

@@ -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 its
# 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

View File

@@ -0,0 +1,218 @@
<?xml version = "1.0" encoding = "UTF-8"?>
<!-- Copyright OASIS Open 2010 All Rights Reserved -->
<schema xmlns = "http://www.w3.org/2001/XMLSchema"
targetNamespace = "urn:oasis:names:tc:emergency:cap:1.2"
xmlns:cap = "urn:oasis:names:tc:emergency:cap:1.2"
xmlns:xs = "http://www.w3.org/2001/XMLSchema"
elementFormDefault = "qualified"
attributeFormDefault = "unqualified"
version = "1.2">
<element name = "alert">
<annotation>
<documentation>CAP Alert Message (version 1.2)</documentation>
</annotation>
<complexType>
<sequence>
<element name = "identifier" type = "xs:string"/>
<element name = "sender" type = "xs:string"/>
<element name = "sent">
<simpleType>
<restriction base = "xs:dateTime">
<pattern value = "\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[-,+]\d\d:\d\d"/>
</restriction>
</simpleType>
</element>
<element name = "status">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Actual"/>
<enumeration value = "Exercise"/>
<enumeration value = "System"/>
<enumeration value = "Test"/>
<enumeration value = "Draft"/>
</restriction>
</simpleType>
</element>
<element name = "msgType">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Alert"/>
<enumeration value = "Update"/>
<enumeration value = "Cancel"/>
<enumeration value = "Ack"/>
<enumeration value = "Error"/>
</restriction>
</simpleType>
</element>
<element name = "source" type = "xs:string" minOccurs = "0"/>
<element name = "scope">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Public"/>
<enumeration value = "Restricted"/>
<enumeration value = "Private"/>
</restriction>
</simpleType>
</element>
<element name = "restriction" type = "xs:string" minOccurs = "0"/>
<element name = "addresses" type = "xs:string" minOccurs = "0"/>
<element name = "code" type = "xs:string" minOccurs = "0" maxOccurs = "unbounded"/>
<element name = "note" type = "xs:string" minOccurs = "0"/>
<element name = "references" type = "xs:string" minOccurs = "0"/>
<element name = "incidents" type = "xs:string" minOccurs = "0"/>
<element name = "info" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element name = "language" type = "xs:language" default = "en-US" minOccurs = "0"/>
<element name = "category" maxOccurs = "unbounded">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Geo"/>
<enumeration value = "Met"/>
<enumeration value = "Safety"/>
<enumeration value = "Security"/>
<enumeration value = "Rescue"/>
<enumeration value = "Fire"/>
<enumeration value = "Health"/>
<enumeration value = "Env"/>
<enumeration value = "Transport"/>
<enumeration value = "Infra"/>
<enumeration value = "CBRNE"/>
<enumeration value = "Other"/>
</restriction>
</simpleType>
</element>
<element name = "event" type = "xs:string"/>
<element name = "responseType" minOccurs = "0" maxOccurs = "unbounded">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Shelter"/>
<enumeration value = "Evacuate"/>
<enumeration value = "Prepare"/>
<enumeration value = "Execute"/>
<enumeration value = "Avoid"/>
<enumeration value = "Monitor"/>
<enumeration value = "Assess"/>
<enumeration value = "AllClear"/>
<enumeration value = "None"/>
</restriction>
</simpleType>
</element>
<element name = "urgency">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Immediate"/>
<enumeration value = "Expected"/>
<enumeration value = "Future"/>
<enumeration value = "Past"/>
<enumeration value = "Unknown"/>
</restriction>
</simpleType>
</element>
<element name = "severity">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Extreme"/>
<enumeration value = "Severe"/>
<enumeration value = "Moderate"/>
<enumeration value = "Minor"/>
<enumeration value = "Unknown"/>
</restriction>
</simpleType>
</element>
<element name = "certainty">
<simpleType>
<restriction base = "xs:string">
<enumeration value = "Observed"/>
<enumeration value = "Likely"/>
<enumeration value = "Possible"/>
<enumeration value = "Unlikely"/>
<enumeration value = "Unknown"/>
</restriction>
</simpleType>
</element>
<element name = "audience" type = "xs:string" minOccurs = "0"/>
<element name = "eventCode" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element ref = "cap:valueName"/>
<element ref = "cap:value"/>
</sequence>
</complexType>
</element>
<element name = "effective" minOccurs = "0">
<simpleType>
<restriction base = "xs:dateTime">
<pattern value = "\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[-,+]\d\d:\d\d"/>
</restriction>
</simpleType>
</element>
<element name = "onset" minOccurs = "0">
<simpleType>
<restriction base = "xs:dateTime">
<pattern value = "\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[-,+]\d\d:\d\d"/>
</restriction>
</simpleType>
</element>
<element name = "expires" minOccurs = "0">
<simpleType>
<restriction base = "xs:dateTime">
<pattern value = "\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[-,+]\d\d:\d\d"/>
</restriction>
</simpleType>
</element>
<element name = "senderName" type = "xs:string" minOccurs = "0"/>
<element name = "headline" type = "xs:string" minOccurs = "0"/>
<element name = "description" type = "xs:string" minOccurs = "0"/>
<element name = "instruction" type = "xs:string" minOccurs = "0"/>
<element name = "web" type = "xs:anyURI" minOccurs = "0"/>
<element name = "contact" type = "xs:string" minOccurs = "0"/>
<element name = "parameter" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element ref = "cap:valueName"/>
<element ref = "cap:value"/>
</sequence>
</complexType>
</element>
<element name = "resource" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element name = "resourceDesc" type = "xs:string"/>
<element name = "mimeType" type = "xs:string"/>
<element name = "size" type = "xs:integer" minOccurs = "0"/>
<element name = "uri" type = "xs:anyURI" minOccurs = "0"/>
<element name = "derefUri" type = "xs:string" minOccurs = "0"/>
<element name = "digest" type = "xs:string" minOccurs = "0"/>
</sequence>
</complexType>
</element>
<element name = "area" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element name = "areaDesc" type = "xs:string"/>
<element name = "polygon" type = "xs:string" minOccurs = "0" maxOccurs = "unbounded"/>
<element name = "circle" type = "xs:string" minOccurs = "0" maxOccurs = "unbounded"/>
<element name = "geocode" minOccurs = "0" maxOccurs = "unbounded">
<complexType>
<sequence>
<element ref = "cap:valueName"/>
<element ref = "cap:value"/>
</sequence>
</complexType>
</element>
<element name = "altitude" type = "xs:decimal" minOccurs = "0"/>
<element name = "ceiling" type = "xs:decimal" minOccurs = "0"/>
</sequence>
</complexType>
</element>
</sequence>
</complexType>
</element>
<any minOccurs = "0" maxOccurs = "unbounded" namespace = "http://www.w3.org/2000/09/xmldsig#" processContents = "lax"/>
</sequence>
</complexType>
</element>
<element name = "valueName" type = "xs:string"/>
<element name = "value" type = "xs:string"/>
</schema>

View File

@@ -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