mirror of
https://github.com/GSA/notifications-api.git
synced 2025-12-24 09:21:39 -05:00
Reject unapproved broadcast upon cancel API request
When a service sends us a cancel broadcast XML via API, if that broadcast was not approved yet, reject it.
This commit is contained in:
@@ -42,10 +42,10 @@ def _parse_nullable_datetime(dt):
|
||||
return dt
|
||||
|
||||
|
||||
def _update_broadcast_message(broadcast_message, new_status, updating_user):
|
||||
def validate_and_update_broadcast_message_status(broadcast_message, new_status, updating_user, from_api=False):
|
||||
if updating_user not in broadcast_message.service.users:
|
||||
# we allow platform admins to cancel broadcasts
|
||||
if not (new_status == BroadcastStatusType.CANCELLED and updating_user.platform_admin):
|
||||
# we allow platform admins to cancel broadcasts, and we don't check user if request was done via API
|
||||
if not from_api and not (new_status == BroadcastStatusType.CANCELLED and updating_user.platform_admin):
|
||||
raise InvalidRequest(
|
||||
f'User {updating_user.id} cannot update broadcast_message {broadcast_message.id} from other service',
|
||||
status_code=400
|
||||
@@ -82,6 +82,11 @@ def _update_broadcast_message(broadcast_message, new_status, updating_user):
|
||||
)
|
||||
broadcast_message.status = new_status
|
||||
|
||||
dao_save_object(broadcast_message)
|
||||
|
||||
if new_status in {BroadcastStatusType.BROADCASTING, BroadcastStatusType.CANCELLED}:
|
||||
_create_broadcast_event(broadcast_message)
|
||||
|
||||
|
||||
@broadcast_message_blueprint.route('', methods=['GET'])
|
||||
def get_broadcast_messages_for_service(service_id):
|
||||
@@ -201,11 +206,7 @@ def update_broadcast_message_status(service_id, broadcast_message_id):
|
||||
new_status = data['status']
|
||||
updating_user = get_user_by_id(data['created_by'])
|
||||
|
||||
_update_broadcast_message(broadcast_message, new_status, updating_user)
|
||||
dao_save_object(broadcast_message)
|
||||
|
||||
if new_status in {BroadcastStatusType.BROADCASTING, BroadcastStatusType.CANCELLED}:
|
||||
_create_broadcast_event(broadcast_message)
|
||||
validate_and_update_broadcast_message_status(broadcast_message, new_status, updating_user)
|
||||
|
||||
return jsonify(broadcast_message.serialize()), 200
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ def cap_xml_to_dict(cap_xml):
|
||||
return {
|
||||
"msgType": cap.alert.msgType.text,
|
||||
"reference": cap.alert.identifier.text,
|
||||
"references": cap.alert.references.text, # references to previous events belonging to the same alert
|
||||
"cap_event": cap.alert.info.event.text,
|
||||
"category": cap.alert.info.category.text,
|
||||
"expires": cap.alert.info.expires.text,
|
||||
|
||||
@@ -24,6 +24,12 @@ def dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_broadcast_message_by_references(references_to_original_broadcast):
|
||||
return BroadcastMessage.query.filter(
|
||||
BroadcastMessage.reference.in_(references_to_original_broadcast),
|
||||
).one()
|
||||
|
||||
|
||||
def dao_get_broadcast_event_by_id(broadcast_event_id):
|
||||
return BroadcastEvent.query.filter(BroadcastEvent.id == broadcast_event_id).one()
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ post_broadcast_schema = {
|
||||
"null",
|
||||
],
|
||||
},
|
||||
"references": {
|
||||
"type": [
|
||||
"string",
|
||||
"null",
|
||||
],
|
||||
},
|
||||
"cap_event": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -63,10 +69,10 @@ post_broadcast_schema = {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Alert",
|
||||
"Cancel",
|
||||
# The following are valid CAP but not supported by our
|
||||
# API at the moment
|
||||
# "Update",
|
||||
# "Cancel",
|
||||
# "Ack",
|
||||
# "Error",
|
||||
],
|
||||
|
||||
@@ -5,7 +5,9 @@ from notifications_utils.polygons import Polygons
|
||||
from notifications_utils.template import BroadcastMessageTemplate
|
||||
|
||||
from app import api_user, authenticated_service
|
||||
from app.broadcast_message.rest import validate_and_update_broadcast_message_status
|
||||
from app.broadcast_message.translators import cap_xml_to_dict
|
||||
from app.dao.broadcast_message_dao import dao_get_broadcast_message_by_references
|
||||
from app.dao.dao_utils import dao_save_object
|
||||
from app.models import BROADCAST_TYPE, BroadcastMessage, BroadcastStatusType
|
||||
from app.notifications.validators import check_service_has_permission
|
||||
@@ -37,52 +39,65 @@ def create_broadcast():
|
||||
message='Request data is not valid CAP XML',
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
broadcast_json = cap_xml_to_dict(cap_xml)
|
||||
|
||||
validate(broadcast_json, post_broadcast_schema)
|
||||
_validate_template(broadcast_json)
|
||||
|
||||
polygons = Polygons(list(chain.from_iterable((
|
||||
[
|
||||
[[y, x] for x, y in polygon]
|
||||
for polygon in area['polygons']
|
||||
] for area in broadcast_json['areas']
|
||||
))))
|
||||
if broadcast_json["msgType"] == "Cancel":
|
||||
references_to_original_broadcast = broadcast_json["references"].split(",")
|
||||
broadcast_message = dao_get_broadcast_message_by_references(references_to_original_broadcast)
|
||||
# do we need to check if service is active?
|
||||
validate_and_update_broadcast_message_status(
|
||||
broadcast_message,
|
||||
BroadcastStatusType.REJECTED,
|
||||
updating_user=None,
|
||||
from_api=True
|
||||
)
|
||||
return jsonify(broadcast_message.serialize()), 201
|
||||
|
||||
if len(polygons) > 12 or polygons.point_count > 250:
|
||||
simple_polygons = polygons.smooth.simplify
|
||||
else:
|
||||
simple_polygons = polygons
|
||||
_validate_template(broadcast_json)
|
||||
|
||||
broadcast_message = BroadcastMessage(
|
||||
service_id=authenticated_service.id,
|
||||
content=broadcast_json['content'],
|
||||
reference=broadcast_json['reference'],
|
||||
cap_event=broadcast_json['cap_event'],
|
||||
areas={
|
||||
'names': [
|
||||
area['name'] for area in broadcast_json['areas']
|
||||
],
|
||||
'simple_polygons': simple_polygons.as_coordinate_pairs_lat_long,
|
||||
},
|
||||
status=BroadcastStatusType.PENDING_APPROVAL,
|
||||
api_key_id=api_user.id,
|
||||
stubbed=authenticated_service.restricted
|
||||
# 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
|
||||
)
|
||||
polygons = Polygons(list(chain.from_iterable((
|
||||
[
|
||||
[[y, x] for x, y in polygon]
|
||||
for polygon in area['polygons']
|
||||
] for area in broadcast_json['areas']
|
||||
))))
|
||||
|
||||
dao_save_object(broadcast_message)
|
||||
if len(polygons) > 12 or polygons.point_count > 250:
|
||||
simple_polygons = polygons.smooth.simplify
|
||||
else:
|
||||
simple_polygons = polygons
|
||||
|
||||
current_app.logger.info(
|
||||
f'Broadcast message {broadcast_message.id} created for service '
|
||||
f'{authenticated_service.id} with reference {broadcast_json["reference"]}'
|
||||
)
|
||||
broadcast_message = BroadcastMessage(
|
||||
service_id=authenticated_service.id,
|
||||
content=broadcast_json['content'],
|
||||
reference=broadcast_json['reference'],
|
||||
cap_event=broadcast_json['cap_event'],
|
||||
areas={
|
||||
'names': [
|
||||
area['name'] for area in broadcast_json['areas']
|
||||
],
|
||||
'simple_polygons': simple_polygons.as_coordinate_pairs_lat_long,
|
||||
},
|
||||
status=BroadcastStatusType.PENDING_APPROVAL,
|
||||
api_key_id=api_user.id,
|
||||
stubbed=authenticated_service.restricted
|
||||
# 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
|
||||
)
|
||||
|
||||
return jsonify(broadcast_message.serialize()), 201
|
||||
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
|
||||
|
||||
|
||||
def _validate_template(broadcast_json):
|
||||
|
||||
@@ -35,6 +35,41 @@ WAINFLEET = """
|
||||
</alert>
|
||||
"""
|
||||
|
||||
WAINFLEET_CANCEL = """
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>5fc99d720abb86020b233422a503af78E</identifier>
|
||||
<sender>www.gov.uk/environment-agency</sender>
|
||||
<sent>2020-02-16T23:02:26-00:00</sent>
|
||||
<status>Actual</status>
|
||||
<msgType>Cancel</msgType>
|
||||
<source>Flood warning service</source>
|
||||
<scope>Public</scope>
|
||||
<code></code>
|
||||
<references>www.gov.uk/environment-agency,50385fcb0ab7aa447bbd46d848ce8466E,2020-02-16T23:01:13-00:00</references>
|
||||
<info>
|
||||
<language>en-GB</language>
|
||||
<category>Met</category>
|
||||
<event><![CDATA[Remove Severe Flood Warning - Cell Broadcast]]></event>
|
||||
<urgency>Immediate</urgency>
|
||||
<severity>Severe</severity>
|
||||
<certainty>Likely</certainty>
|
||||
<expires>2020-02-16T23:30:13-00:00</expires>
|
||||
<senderName>Environment Agency</senderName>
|
||||
<description>Cancel Warning</description>
|
||||
<web>https://flood-warning-information.service.gov.uk</web>
|
||||
<contact>0345 988 1188</contact>
|
||||
<area>
|
||||
<areaDesc>River Steeping in Wainfleet All Saints</areaDesc>
|
||||
<polygon>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</polygon>
|
||||
<geocode>
|
||||
<valueName>TargetAreaCode</valueName>
|
||||
<value>053FWFSTEEP4</value>
|
||||
</geocode>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
"""
|
||||
|
||||
UPDATE = """
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>PAAQ-4-mg5a94</identifier>
|
||||
@@ -118,89 +153,6 @@ UPDATE = """
|
||||
</alert>
|
||||
"""
|
||||
|
||||
CANCEL = """
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>PAAQ-4-mg5a94</identifier>
|
||||
<sender>wcatwc@noaa.gov</sender>
|
||||
<sent>2013-01-05T10:58:23-00:00</sent>
|
||||
<status>Actual</status>
|
||||
<msgType>Cancel</msgType>
|
||||
<source>WCATWC</source>
|
||||
<scope>Public</scope>
|
||||
<code>IPAWSv1.0</code>
|
||||
<references>wcatwc@noaa.gov,PAAQ-1-mg5a94,2013-01-05T09:01:16-00:00 wcatwc@noaa.gov,PAAQ-2-mg5a94,2013-01-05T09:30:16-00:00 wcatwc@noaa.gov,PAAQ-3-mg5a94,2013-01-05T10:17:31-00:00</references>
|
||||
<incidents>mg5a94</incidents>
|
||||
<info>
|
||||
<category>Geo</category>
|
||||
<event>Tsunami Cancellation</event>
|
||||
<responseType>None</responseType>
|
||||
<urgency>Past</urgency>
|
||||
<severity>Unknown</severity>
|
||||
<certainty>Unlikely</certainty>
|
||||
<onset>2013-01-05T10:58:23-00:00</onset>
|
||||
<expires>2013-01-05T10:58:23-00:00</expires>
|
||||
<senderName>NWS West Coast/Alaska Tsunami Warning Center Palmer AK</senderName>
|
||||
<headline>The tsunami Warning is canceled for the coastal areas of British Columbia and Alaska from the north tip of Vancouver Island, British Columbia to Cape Fairweather, Alaska (80 miles SE of Yakutat).</headline>
|
||||
<description>The tsunami Warning is canceled for the coastal areas of British Columbia and Alaska from the north tip of Vancouver Island, British Columbia to Cape Fairweather, Alaska (80 miles SE of Yakutat). - Event details: Preliminary magnitude 7.5 (Mw) earthquake / Lat: 55.300, Lon: -134.900 at 2013-01-05T08:58:20Z Tsunami cancellations indicate the end of the damaging tsunami threat. A cancellation is issued after an evaluation of sea level data confirms that a destructive tsunami will not impact the alerted region, or after tsunami levels have subsided to non-damaging levels. </description>
|
||||
<instruction>Recommended Actions: Do not re-occupy hazard zones until local emergency officials indicate it is safe to do so. This will be the last West Coast/Alaska Tsunami Warning Center message issued for this event. Refer to the internet site ntwc.arh.noaa.gov for more information. </instruction>
|
||||
<web>http://ntwc.arh.noaa.gov/events/PAAQ/2013/01/05/mg5a94/4/WEAK51/WEAK51.txt</web>
|
||||
<parameter>
|
||||
<valueName>EventLocationName</valueName>
|
||||
<value>95 miles NW of Dixon Entrance, Alaska</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EventPreliminaryMagnitude</valueName>
|
||||
<value>7.5</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EventPreliminaryMagnitudeType</valueName>
|
||||
<value>Mw</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EventOriginTime</valueName>
|
||||
<value>2013-01-05T08:58:20-00:00</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EventDepth</valueName>
|
||||
<value>5 kilometers</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EventLatLon</valueName>
|
||||
<value>55.300,-134.900 0.000</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>VTEC</valueName>
|
||||
<value>/O.CAN.PAAQ.TS.W.0001.000000T0000Z-000000T0000Z/</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>NWSUGC</valueName>
|
||||
<value>BCZ220-210-922-912-921-911-110-AKZ026>029-023-024-019>022-025-051258-</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>ProductDefinition</valueName>
|
||||
<value>Tsunami cancellations indicate the end of the damaging tsunami threat. A cancellation is issued after an evaluation of sea level data confirms that a destructive tsunami will not impact the alerted region, or after tsunami levels have subsided to non-damaging levels. </value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>WEAK51</valueName>
|
||||
<value>Public Tsunami Warnings, Watches, and Advisories for AK, BC, and US West Coast</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>EAS-ORG</valueName>
|
||||
<value>WXR</value>
|
||||
</parameter>
|
||||
<resource>
|
||||
<resourceDesc>Event Data as a JSON document</resourceDesc>
|
||||
<mimeType>application/json</mimeType>
|
||||
<uri>http://ntwc.arh.noaa.gov/events/PAAQ/2013/01/05/mg5a94/4/WEAK51/PAAQ.json</uri>
|
||||
</resource>
|
||||
<area>
|
||||
<areaDesc>95 miles NW of Dixon Entrance, Alaska</areaDesc>
|
||||
<circle>55.3,-134.9 0.0</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
"""
|
||||
|
||||
WITH_PLACEHOLDER_FOR_CONTENT = """
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>50385fcb0ab7aa447bbd46d848ce8466E</identifier>
|
||||
|
||||
@@ -119,6 +119,50 @@ def test_valid_post_cap_xml_broadcast_returns_201(
|
||||
assert response_json['updated_at'] is None
|
||||
|
||||
|
||||
def test_valid_cancel_broadcast_request_rejects_unapproved_alert_and_returns_201(
|
||||
client,
|
||||
sample_broadcast_service,
|
||||
):
|
||||
auth_header = create_service_authorization_header(service_id=sample_broadcast_service.id)
|
||||
|
||||
# create a broadcast
|
||||
response_for_create = client.post(
|
||||
path='/v2/broadcast',
|
||||
data=sample_cap_xml_documents.WAINFLEET,
|
||||
headers=[('Content-Type', 'application/cap+xml'), auth_header],
|
||||
)
|
||||
assert response_for_create.status_code == 201
|
||||
|
||||
response_json_for_create = json.loads(response_for_create.get_data(as_text=True))
|
||||
|
||||
assert response_json_for_create['cancelled_at'] is None
|
||||
assert response_json_for_create['cancelled_by_id'] is None
|
||||
assert response_json_for_create['reference'] == '50385fcb0ab7aa447bbd46d848ce8466E'
|
||||
assert response_json_for_create['status'] == 'pending-approval'
|
||||
|
||||
# cancel broadcast
|
||||
response_for_cancel = client.post(
|
||||
path='/v2/broadcast',
|
||||
data=sample_cap_xml_documents.WAINFLEET_CANCEL,
|
||||
headers=[('Content-Type', 'application/cap+xml'), auth_header],
|
||||
)
|
||||
assert response_for_cancel.status_code == 201
|
||||
|
||||
response_json_for_reject = json.loads(response_for_cancel.get_data(as_text=True))
|
||||
|
||||
assert response_json_for_reject['reference'] == response_json_for_create['reference']
|
||||
assert response_json_for_reject['status'] == 'rejected'
|
||||
assert response_json_for_reject['updated_at'] is not None
|
||||
|
||||
|
||||
def test_valid_cancel_broadcast_request_cancels_active_alert_and_returns_201():
|
||||
pass
|
||||
|
||||
|
||||
def test_cancel_request_does_not_cancel_broadcast_if_reference_does_not_match():
|
||||
pass
|
||||
|
||||
|
||||
def test_large_polygon_is_simplified(
|
||||
client,
|
||||
sample_broadcast_service,
|
||||
@@ -191,32 +235,22 @@ def test_invalid_post_cap_xml_broadcast_returns_400(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('xml_document, expected_error_message', (
|
||||
(sample_cap_xml_documents.CANCEL, (
|
||||
'msgType Cancel is not one of [Alert]'
|
||||
)),
|
||||
(sample_cap_xml_documents.UPDATE, (
|
||||
'msgType Update is not one of [Alert]'
|
||||
)),
|
||||
))
|
||||
def test_unsupported_message_types_400(
|
||||
client,
|
||||
sample_broadcast_service,
|
||||
xml_document,
|
||||
expected_error_message,
|
||||
):
|
||||
auth_header = create_service_authorization_header(service_id=sample_broadcast_service.id)
|
||||
|
||||
response = client.post(
|
||||
path='/v2/broadcast',
|
||||
data=xml_document,
|
||||
data=sample_cap_xml_documents.UPDATE,
|
||||
headers=[('Content-Type', 'application/cap+xml'), auth_header],
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert {
|
||||
'error': 'ValidationError',
|
||||
'message': expected_error_message,
|
||||
'message': 'msgType Update is not one of [Alert, Cancel]',
|
||||
} in (
|
||||
json.loads(response.get_data(as_text=True))['errors']
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user