mirror of
https://github.com/GSA/notifications-api.git
synced 2026-02-01 15:46:07 -05:00
Merge pull request #3060 from alphagov/add-broadcast-api-endpoint
Add public API endpoint to create broadcast messages
This commit is contained in:
@@ -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):
|
||||
|
||||
|
||||
31
app/broadcast_message/translators.py
Normal file
31
app/broadcast_message/translators.py
Normal file
@@ -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(' ')
|
||||
]
|
||||
10
app/v2/broadcast/__init__.py
Normal file
10
app/v2/broadcast/__init__.py
Normal 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)
|
||||
97
app/v2/broadcast/broadcast_schemas.py
Normal file
97
app/v2/broadcast/broadcast_schemas.py
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
71
app/v2/broadcast/post_broadcast.py
Normal file
71
app/v2/broadcast/post_broadcast.py
Normal 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 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
|
||||
218
app/xml_schemas/CAP-v1.2.xsd
Normal file
218
app/xml_schemas/CAP-v1.2.xsd
Normal 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>
|
||||
19
app/xml_schemas/__init__.py
Normal file
19
app/xml_schemas/__init__.py
Normal 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
|
||||
Reference in New Issue
Block a user