From 52dbdb7518d3ff5da67a88dca4589d5cefea47b5 Mon Sep 17 00:00:00 2001 From: Pea Tyczynska Date: Wed, 19 Jan 2022 16:21:09 +0000 Subject: [PATCH] Move validate_and_update_broadcast_message_status to a utils file This is because that function is used both when broadcast status is updated via API and via admin, so it's a shared resource. Also move and update tests for updating broadcast message status so things are tested at source and repetition is avoided. --- app/broadcast_message/rest.py | 98 +----- app/broadcast_message/utils.py | 95 ++++++ app/v2/broadcast/post_broadcast.py | 5 +- tests/app/broadcast_message/test_rest.py | 255 --------------- tests/app/broadcast_message/test_utils.py | 304 ++++++++++++++++++ tests/app/v2/broadcast/test_post_broadcast.py | 72 +---- 6 files changed, 420 insertions(+), 409 deletions(-) create mode 100644 app/broadcast_message/utils.py create mode 100644 tests/app/broadcast_message/test_utils.py diff --git a/app/broadcast_message/rest.py b/app/broadcast_message/rest.py index b18f8d069..1388c2481 100644 --- a/app/broadcast_message/rest.py +++ b/app/broadcast_message/rest.py @@ -1,7 +1,5 @@ -from datetime import datetime - import iso8601 -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, jsonify, request from notifications_utils.template import BroadcastMessageTemplate from app.broadcast_message.broadcast_message_schema import ( @@ -9,8 +7,9 @@ from app.broadcast_message.broadcast_message_schema import ( update_broadcast_message_schema, update_broadcast_message_status_schema, ) -from app.celery.broadcast_message_tasks import send_broadcast_event -from app.config import QueueNames +from app.broadcast_message.utils import ( + validate_and_update_broadcast_message_status, +) from app.dao.broadcast_message_dao import ( dao_get_broadcast_message_by_id_and_service_id, dao_get_broadcast_messages_for_service, @@ -20,12 +19,7 @@ from app.dao.services_dao import dao_fetch_service_by_id from app.dao.templates_dao import dao_get_template_by_id_and_service_id from app.dao.users_dao import get_user_by_id from app.errors import InvalidRequest, register_errors -from app.models import ( - BroadcastEvent, - BroadcastEventMessageType, - BroadcastMessage, - BroadcastStatusType, -) +from app.models import BroadcastMessage, BroadcastStatusType from app.schema_validation import validate broadcast_message_blueprint = Blueprint( @@ -42,44 +36,6 @@ def _parse_nullable_datetime(dt): return dt -def validate_and_update_broadcast_message_status(broadcast_message, new_status, updating_user): - if new_status not in BroadcastStatusType.ALLOWED_STATUS_TRANSITIONS[broadcast_message.status]: - raise InvalidRequest( - f'Cannot move broadcast_message {broadcast_message.id} from {broadcast_message.status} to {new_status}', - status_code=400 - ) - - if new_status == BroadcastStatusType.BROADCASTING: - # training mode services can approve their own broadcasts - if updating_user == broadcast_message.created_by and not broadcast_message.service.restricted: - raise InvalidRequest( - f'User {updating_user.id} cannot approve their own broadcast_message {broadcast_message.id}', - status_code=400 - ) - elif len(broadcast_message.areas['simple_polygons']) == 0: - raise InvalidRequest( - f'broadcast_message {broadcast_message.id} has no selected areas and so cannot be broadcasted.', - status_code=400 - ) - else: - broadcast_message.approved_at = datetime.utcnow() - broadcast_message.approved_by = updating_user - - if new_status == BroadcastStatusType.CANCELLED: - broadcast_message.cancelled_at = datetime.utcnow() - broadcast_message.cancelled_by = updating_user - - current_app.logger.info( - f'broadcast_message {broadcast_message.id} moving from {broadcast_message.status} to {new_status}' - ) - 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): # TODO: should this return template content/data in some way? or can we rely on them being cached admin side. @@ -209,47 +165,3 @@ def update_broadcast_message_status(service_id, broadcast_message_id): validate_and_update_broadcast_message_status(broadcast_message, new_status, updating_user) return jsonify(broadcast_message.serialize()), 200 - - -def _create_broadcast_event(broadcast_message): - """ - If the service is live and the broadcast message is not stubbed, creates a broadcast event, stores it in the - database, and triggers the task to send the CAP XML off. - """ - service = broadcast_message.service - - if not broadcast_message.stubbed and not service.restricted: - msg_types = { - BroadcastStatusType.BROADCASTING: BroadcastEventMessageType.ALERT, - BroadcastStatusType.CANCELLED: BroadcastEventMessageType.CANCEL, - } - - event = BroadcastEvent( - service=service, - broadcast_message=broadcast_message, - message_type=msg_types[broadcast_message.status], - transmitted_content={"body": broadcast_message.content}, - transmitted_areas=broadcast_message.areas, - # TODO: Probably move this somewhere more standalone too and imply that it shouldn't change. Should it - # include a service based identifier too? eg "flood-warnings@notifications.service.gov.uk" or similar - transmitted_sender='notifications.service.gov.uk', - - # TODO: Should this be set to now? Or the original starts_at? - transmitted_starts_at=broadcast_message.starts_at, - transmitted_finishes_at=broadcast_message.finishes_at, - ) - - dao_save_object(event) - - send_broadcast_event.apply_async( - kwargs={'broadcast_event_id': str(event.id)}, - queue=QueueNames.BROADCASTS - ) - elif broadcast_message.stubbed != service.restricted: - # It's possible for a service to create a broadcast in trial mode, and then approve it after the - # service is live (or vice versa). We don't think it's safe to send such broadcasts, as the service - # has changed since they were created. Log an error instead. - current_app.logger.error( - f'Broadcast event not created. Stubbed status of broadcast message was {broadcast_message.stubbed}' - f' but service was {"in trial mode" if service.restricted else "live"}' - ) diff --git a/app/broadcast_message/utils.py b/app/broadcast_message/utils.py new file mode 100644 index 000000000..1673ab2bb --- /dev/null +++ b/app/broadcast_message/utils.py @@ -0,0 +1,95 @@ +from datetime import datetime + +from flask import current_app + +from app.celery.broadcast_message_tasks import send_broadcast_event +from app.config import QueueNames +from app.dao.dao_utils import dao_save_object +from app.errors import InvalidRequest +from app.models import ( + BroadcastEvent, + BroadcastEventMessageType, + BroadcastStatusType, +) + + +def validate_and_update_broadcast_message_status(broadcast_message, new_status, updating_user): + if new_status not in BroadcastStatusType.ALLOWED_STATUS_TRANSITIONS[broadcast_message.status]: + raise InvalidRequest( + f'Cannot move broadcast_message {broadcast_message.id} from {broadcast_message.status} to {new_status}', + status_code=400 + ) + + if new_status == BroadcastStatusType.BROADCASTING: + # training mode services can approve their own broadcasts + if updating_user == broadcast_message.created_by and not broadcast_message.service.restricted: + raise InvalidRequest( + f'User {updating_user.id} cannot approve their own broadcast_message {broadcast_message.id}', + status_code=400 + ) + elif len(broadcast_message.areas['simple_polygons']) == 0: + raise InvalidRequest( + f'broadcast_message {broadcast_message.id} has no selected areas and so cannot be broadcasted.', + status_code=400 + ) + else: + broadcast_message.approved_at = datetime.utcnow() + broadcast_message.approved_by = updating_user + + if new_status == BroadcastStatusType.CANCELLED: + broadcast_message.cancelled_at = datetime.utcnow() + broadcast_message.cancelled_by = updating_user + + current_app.logger.info( + f'broadcast_message {broadcast_message.id} moving from {broadcast_message.status} to {new_status}' + ) + broadcast_message.status = new_status + + dao_save_object(broadcast_message) + + if new_status in {BroadcastStatusType.BROADCASTING, BroadcastStatusType.CANCELLED}: + _create_broadcast_event(broadcast_message) + + +def _create_broadcast_event(broadcast_message): + """ + If the service is live and the broadcast message is not stubbed, creates a broadcast event, stores it in the + database, and triggers the task to send the CAP XML off. + """ + service = broadcast_message.service + + if not broadcast_message.stubbed and not service.restricted: + msg_types = { + BroadcastStatusType.BROADCASTING: BroadcastEventMessageType.ALERT, + BroadcastStatusType.CANCELLED: BroadcastEventMessageType.CANCEL, + } + + event = BroadcastEvent( + service=service, + broadcast_message=broadcast_message, + message_type=msg_types[broadcast_message.status], + transmitted_content={"body": broadcast_message.content}, + transmitted_areas=broadcast_message.areas, + # TODO: Probably move this somewhere more standalone too and imply that it shouldn't change. Should it + # include a service based identifier too? eg "flood-warnings@notifications.service.gov.uk" or similar + transmitted_sender='notifications.service.gov.uk', + + # TODO: Should this be set to now? Or the original starts_at? + transmitted_starts_at=broadcast_message.starts_at, + transmitted_finishes_at=broadcast_message.finishes_at, + ) + + dao_save_object(event) + + send_broadcast_event.apply_async( + kwargs={'broadcast_event_id': str(event.id)}, + queue=QueueNames.BROADCASTS + ) + elif broadcast_message.stubbed != service.restricted: + # It's possible for a service to create a broadcast in trial mode, and then approve it after the + # service is live (or vice versa). We don't think it's safe to send such broadcasts, as the service + # has changed since they were created. Log an error instead. + current_app.logger.error( + f'Broadcast event not created. Stubbed status of broadcast message was {broadcast_message.stubbed}' + f' but service was {"in trial mode" if service.restricted else "live"}' + ) diff --git a/app/v2/broadcast/post_broadcast.py b/app/v2/broadcast/post_broadcast.py index 6d6518cfc..a597c2aa0 100644 --- a/app/v2/broadcast/post_broadcast.py +++ b/app/v2/broadcast/post_broadcast.py @@ -3,13 +3,12 @@ from itertools import chain from flask import current_app, jsonify, request from notifications_utils.polygons import Polygons from notifications_utils.template import BroadcastMessageTemplate -from sqlalchemy.orm.exc import NoResultFound from app import api_user, authenticated_service -from app.broadcast_message.rest import ( +from app.broadcast_message.translators import cap_xml_to_dict +from app.broadcast_message.utils 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_and_service_id, ) diff --git a/tests/app/broadcast_message/test_rest.py b/tests/app/broadcast_message/test_rest.py index fff5a814b..d26093851 100644 --- a/tests/app/broadcast_message/test_rest.py +++ b/tests/app/broadcast_message/test_rest.py @@ -620,91 +620,6 @@ def test_update_broadcast_message_allows_service_user_and_platform_admin_to_canc assert cancel_event.transmitted_content == {"body": "emergency broadcast"} -def test_update_broadcast_message_status_stores_approved_by_and_approved_at_and_queues_task( - admin_request, - sample_broadcast_service, - mocker -): - t = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') - bm = create_broadcast_message( - t, - status=BroadcastStatusType.PENDING_APPROVAL, - areas={ - "ids": ["london"], - "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] - } - ) - approver = create_user(email='approver@gov.uk') - sample_broadcast_service.users.append(approver) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(approver.id)}, - service_id=t.service_id, - broadcast_message_id=bm.id, - _expected_status=200 - ) - - assert response['status'] == BroadcastStatusType.BROADCASTING - assert response['approved_at'] is not None - assert response['approved_by_id'] == str(approver.id) - - assert len(bm.events) == 1 - alert_event = bm.events[0] - - mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') - - assert alert_event.service_id == sample_broadcast_service.id - assert alert_event.transmitted_areas == bm.areas - assert alert_event.message_type == BroadcastEventMessageType.ALERT - assert alert_event.transmitted_finishes_at == bm.finishes_at - assert alert_event.transmitted_content == {"body": "emergency broadcast"} - - -@pytest.mark.parametrize('broadcast_message_stubbed, service_restricted_before_approval', [ - (True, True), - (True, False), - (False, True), -]) -def test_update_broadcast_message_status_updates_details_but_does_not_queue_task_if_bm_is_stubbed_or_service_not_live( - admin_request, - sample_broadcast_service, - mocker, - broadcast_message_stubbed, - service_restricted_before_approval, -): - sample_broadcast_service.restricted = broadcast_message_stubbed - t = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') - bm = create_broadcast_message( - t, - status=BroadcastStatusType.PENDING_APPROVAL, - areas={"ids": ["london"], "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]]}, - stubbed=broadcast_message_stubbed - ) - approver = create_user(email='approver@gov.uk') - sample_broadcast_service.users.append(approver) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - sample_broadcast_service.restricted = service_restricted_before_approval - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(approver.id)}, - service_id=t.service_id, - broadcast_message_id=bm.id, - _expected_status=200 - ) - - assert response['status'] == BroadcastStatusType.BROADCASTING - assert response['approved_at'] is not None - assert response['approved_by_id'] == str(approver.id) - - # The broadcast can be approved, but does not create a broadcast_event in the database or put a task on the queue - assert len(bm.events) == 0 - assert len(mock_task.mock_calls) == 0 - - def test_update_broadcast_message_status_aborts_if_service_is_suspended( admin_request, sample_broadcast_service, @@ -721,123 +636,6 @@ def test_update_broadcast_message_status_aborts_if_service_is_suspended( ) -def test_update_broadcast_message_status_creates_event_with_correct_content_if_broadcast_has_no_template( - admin_request, - sample_broadcast_service, - mocker -): - bm = create_broadcast_message( - service=sample_broadcast_service, - template=None, - content='tailor made emergency broadcast content', - status=BroadcastStatusType.PENDING_APPROVAL, - areas={ - "ids": ["london"], - "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] - } - ) - approver = create_user(email='approver@gov.uk') - sample_broadcast_service.users.append(approver) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(approver.id)}, - service_id=sample_broadcast_service.id, - broadcast_message_id=bm.id, - _expected_status=200 - ) - - assert response['status'] == BroadcastStatusType.BROADCASTING - - assert len(bm.events) == 1 - alert_event = bm.events[0] - - mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') - - assert alert_event.transmitted_content == {"body": "tailor made emergency broadcast content"} - - -@pytest.mark.parametrize('is_platform_admin', [True, False]) -def test_update_broadcast_message_status_rejects_approval_from_creator( - admin_request, - sample_broadcast_service, - mocker, - is_platform_admin -): - t = create_template(sample_broadcast_service, BROADCAST_TYPE) - bm = create_broadcast_message(t, status=BroadcastStatusType.PENDING_APPROVAL) - user = sample_broadcast_service.created_by - user.platform_admin = is_platform_admin - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(t.created_by_id)}, - service_id=t.service_id, - broadcast_message_id=bm.id, - _expected_status=400 - ) - - assert mock_task.called is False - assert 'cannot approve their own broadcast' in response['message'] - - -def test_update_broadcast_message_status_rejects_approval_of_broadcast_with_no_areas( - admin_request, - sample_broadcast_service, - mocker -): - template = create_template(sample_broadcast_service, BROADCAST_TYPE) - broadcast = create_broadcast_message(template, status=BroadcastStatusType.PENDING_APPROVAL) - approver = create_user(email='approver@gov.uk') - sample_broadcast_service.users.append(approver) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(approver.id)}, - service_id=template.service_id, - broadcast_message_id=broadcast.id, - _expected_status=400 - ) - - assert mock_task.called is False - assert response[ - 'message' - ] == f'broadcast_message {broadcast.id} has no selected areas and so cannot be broadcasted.' - - -def test_update_broadcast_message_status_allows_trial_mode_services_to_approve_own_message( - notify_db, - admin_request, - sample_broadcast_service, - mocker -): - sample_broadcast_service.restricted = True - t = create_template(sample_broadcast_service, BROADCAST_TYPE) - bm = create_broadcast_message( - t, - status=BroadcastStatusType.PENDING_APPROVAL, - areas={"ids": ["london"], "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]]} - ) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': BroadcastStatusType.BROADCASTING, 'created_by': str(t.created_by_id)}, - service_id=t.service_id, - broadcast_message_id=bm.id, - _expected_status=200 - ) - - assert response['status'] == BroadcastStatusType.BROADCASTING - assert response['approved_at'] is not None - assert response['created_by_id'] == str(t.created_by_id) - assert response['approved_by_id'] == str(t.created_by_id) - assert not mock_task.called - - def test_update_broadcast_message_status_rejects_approval_from_user_not_on_that_service( admin_request, sample_broadcast_service, @@ -858,56 +656,3 @@ def test_update_broadcast_message_status_rejects_approval_from_user_not_on_that_ assert mock_task.called is False assert 'cannot update broadcast' in response['message'] - - -@pytest.mark.parametrize('current_status, new_status', [ - (BroadcastStatusType.DRAFT, BroadcastStatusType.DRAFT), - (BroadcastStatusType.DRAFT, BroadcastStatusType.BROADCASTING), - (BroadcastStatusType.DRAFT, BroadcastStatusType.CANCELLED), - - (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.PENDING_APPROVAL), - (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.CANCELLED), - (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.COMPLETED), - - (BroadcastStatusType.REJECTED, BroadcastStatusType.REJECTED), - (BroadcastStatusType.REJECTED, BroadcastStatusType.BROADCASTING), - (BroadcastStatusType.REJECTED, BroadcastStatusType.CANCELLED), - (BroadcastStatusType.REJECTED, BroadcastStatusType.COMPLETED), - - (BroadcastStatusType.BROADCASTING, BroadcastStatusType.DRAFT), - (BroadcastStatusType.BROADCASTING, BroadcastStatusType.PENDING_APPROVAL), - (BroadcastStatusType.BROADCASTING, BroadcastStatusType.BROADCASTING), - - (BroadcastStatusType.COMPLETED, BroadcastStatusType.DRAFT), - (BroadcastStatusType.COMPLETED, BroadcastStatusType.PENDING_APPROVAL), - (BroadcastStatusType.COMPLETED, BroadcastStatusType.BROADCASTING), - (BroadcastStatusType.COMPLETED, BroadcastStatusType.CANCELLED), - - (BroadcastStatusType.CANCELLED, BroadcastStatusType.DRAFT), - (BroadcastStatusType.CANCELLED, BroadcastStatusType.PENDING_APPROVAL), - (BroadcastStatusType.CANCELLED, BroadcastStatusType.BROADCASTING), - (BroadcastStatusType.CANCELLED, BroadcastStatusType.COMPLETED), -]) -def test_update_broadcast_message_status_restricts_status_transitions_to_explicit_list( - admin_request, - sample_broadcast_service, - mocker, - current_status, - new_status -): - t = create_template(sample_broadcast_service, BROADCAST_TYPE) - bm = create_broadcast_message(t, status=current_status) - approver = create_user(email='approver@gov.uk') - sample_broadcast_service.users.append(approver) - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - - response = admin_request.post( - 'broadcast_message.update_broadcast_message_status', - _data={'status': new_status, 'created_by': str(approver.id)}, - service_id=t.service_id, - broadcast_message_id=bm.id, - _expected_status=400 - ) - - assert mock_task.called is False - assert f'from {current_status} to {new_status}' in response['message'] diff --git a/tests/app/broadcast_message/test_utils.py b/tests/app/broadcast_message/test_utils.py new file mode 100644 index 000000000..a02ac66bf --- /dev/null +++ b/tests/app/broadcast_message/test_utils.py @@ -0,0 +1,304 @@ +import pytest + +from app.broadcast_message.utils import ( + validate_and_update_broadcast_message_status, +) +from app.errors import InvalidRequest +from app.models import ( + BROADCAST_TYPE, + BroadcastEventMessageType, + BroadcastStatusType, +) +from tests.app.db import create_broadcast_message, create_template, create_user + + +def test_validate_and_update_broadcast_message_status_stores_approved_by_and_approved_at_and_queues_task( + sample_broadcast_service, + mocker +): + template = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') + broadcast_message = create_broadcast_message( + template, + status=BroadcastStatusType.PENDING_APPROVAL, + areas={ + "ids": ["london"], + "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] + } + ) + approver = create_user(email='approver@gov.uk') + sample_broadcast_service.users.append(approver) + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.BROADCASTING, approver + ) + + assert broadcast_message.status == BroadcastStatusType.BROADCASTING + assert broadcast_message.approved_at is not None + assert broadcast_message.approved_by_id == approver.id + + assert len(broadcast_message.events) == 1 + alert_event = broadcast_message.events[0] + + mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') + + assert alert_event.service_id == sample_broadcast_service.id + assert alert_event.transmitted_areas == broadcast_message.areas + assert alert_event.message_type == BroadcastEventMessageType.ALERT + assert alert_event.transmitted_finishes_at == broadcast_message.finishes_at + assert alert_event.transmitted_content == {"body": "emergency broadcast"} + + +@pytest.mark.parametrize("cancel_route", ["admin_interface", "api_call"]) +def test_validate_and_update_broadcast_message_status_for_cancelling_broadcast( + sample_broadcast_service, + mocker, + cancel_route +): + template = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') + broadcast_message = create_broadcast_message( + template, + status=BroadcastStatusType.BROADCASTING, + areas={ + "ids": ["london"], + "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] + } + ) + if cancel_route == "admin_interface": + canceller = sample_broadcast_service.created_by + else: + canceller = None + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.CANCELLED, canceller + ) + + assert broadcast_message.status == BroadcastStatusType.CANCELLED + assert broadcast_message.cancelled_at is not None + assert broadcast_message.cancelled_by_id == (canceller.id if canceller else None) + + assert len(broadcast_message.events) == 1 + alert_event = broadcast_message.events[0] + + mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') + + assert alert_event.service_id == sample_broadcast_service.id + assert alert_event.message_type == BroadcastEventMessageType.CANCEL + + +@pytest.mark.parametrize("reject_route", ["admin_interface", "api_call"]) +def test_validate_and_update_broadcast_message_status_for_rejecting_broadcast( + sample_broadcast_service, + mocker, + reject_route +): + template = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') + broadcast_message = create_broadcast_message( + template, + status=BroadcastStatusType.PENDING_APPROVAL, + areas={ + "ids": ["london"], + "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] + } + ) + if reject_route == "admin_interface": + canceller = sample_broadcast_service.created_by + else: + canceller = None + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.REJECTED, canceller + ) + + assert broadcast_message.status == BroadcastStatusType.REJECTED + assert broadcast_message.cancelled_at is None + assert broadcast_message.cancelled_by_id is None + assert broadcast_message.updated_at is not None + + assert not mock_task.called + assert len(broadcast_message.events) == 0 + + +@pytest.mark.parametrize('current_status, new_status', [ + (BroadcastStatusType.DRAFT, BroadcastStatusType.DRAFT), + (BroadcastStatusType.DRAFT, BroadcastStatusType.BROADCASTING), + (BroadcastStatusType.DRAFT, BroadcastStatusType.CANCELLED), + + (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.PENDING_APPROVAL), + (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.CANCELLED), + (BroadcastStatusType.PENDING_APPROVAL, BroadcastStatusType.COMPLETED), + + (BroadcastStatusType.REJECTED, BroadcastStatusType.REJECTED), + (BroadcastStatusType.REJECTED, BroadcastStatusType.BROADCASTING), + (BroadcastStatusType.REJECTED, BroadcastStatusType.CANCELLED), + (BroadcastStatusType.REJECTED, BroadcastStatusType.COMPLETED), + + (BroadcastStatusType.BROADCASTING, BroadcastStatusType.DRAFT), + (BroadcastStatusType.BROADCASTING, BroadcastStatusType.PENDING_APPROVAL), + (BroadcastStatusType.BROADCASTING, BroadcastStatusType.BROADCASTING), + + (BroadcastStatusType.COMPLETED, BroadcastStatusType.DRAFT), + (BroadcastStatusType.COMPLETED, BroadcastStatusType.PENDING_APPROVAL), + (BroadcastStatusType.COMPLETED, BroadcastStatusType.BROADCASTING), + (BroadcastStatusType.COMPLETED, BroadcastStatusType.CANCELLED), + + (BroadcastStatusType.CANCELLED, BroadcastStatusType.DRAFT), + (BroadcastStatusType.CANCELLED, BroadcastStatusType.PENDING_APPROVAL), + (BroadcastStatusType.CANCELLED, BroadcastStatusType.BROADCASTING), + (BroadcastStatusType.CANCELLED, BroadcastStatusType.COMPLETED), +]) +def test_validate_and_update_broadcast_message_status_restricts_status_transitions_to_explicit_list( + sample_broadcast_service, + mocker, + current_status, + new_status +): + t = create_template(sample_broadcast_service, BROADCAST_TYPE) + broadcast_message = create_broadcast_message(t, status=current_status) + approver = create_user(email='approver@gov.uk') + sample_broadcast_service.users.append(approver) + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + with pytest.raises(expected_exception=InvalidRequest) as e: + validate_and_update_broadcast_message_status(broadcast_message, new_status, approver) + + assert mock_task.called is False + assert f'from {current_status} to {new_status}' in str(e.value) + + +@pytest.mark.parametrize('is_platform_admin', [True, False]) +def test_validate_and_update_broadcast_message_status_rejects_approval_from_creator( + sample_broadcast_service, + mocker, + is_platform_admin +): + template = create_template(sample_broadcast_service, BROADCAST_TYPE) + broadcast_message = create_broadcast_message(template, status=BroadcastStatusType.PENDING_APPROVAL) + creator_and_approver = sample_broadcast_service.created_by + creator_and_approver.platform_admin = is_platform_admin + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + with pytest.raises(expected_exception=InvalidRequest) as e: + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.BROADCASTING, creator_and_approver + ) + + assert mock_task.called is False + assert 'cannot approve their own broadcast' in str(e.value) + + +def test_validate_and_update_broadcast_message_status_rejects_approval_of_broadcast_with_no_areas( + admin_request, + sample_broadcast_service, + mocker +): + template = create_template(sample_broadcast_service, BROADCAST_TYPE) + broadcast = create_broadcast_message(template, status=BroadcastStatusType.PENDING_APPROVAL) + approver = create_user(email='approver@gov.uk') + sample_broadcast_service.users.append(approver) + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + with pytest.raises(expected_exception=InvalidRequest) as e: + validate_and_update_broadcast_message_status(broadcast, BroadcastStatusType.BROADCASTING, approver) + + assert mock_task.called is False + assert f'broadcast_message {broadcast.id} has no selected areas and so cannot be broadcasted.' in str(e.value) + + +def test_validate_and_update_broadcast_message_status_allows_trial_mode_services_to_approve_own_message( + notify_db, + sample_broadcast_service, + mocker +): + sample_broadcast_service.restricted = True + template = create_template(sample_broadcast_service, BROADCAST_TYPE) + broadcast_message = create_broadcast_message( + template, + status=BroadcastStatusType.PENDING_APPROVAL, + areas={"ids": ["london"], "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]]} + ) + creator_and_approver = sample_broadcast_service.created_by + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.BROADCASTING, creator_and_approver + ) + + assert broadcast_message.status == BroadcastStatusType.BROADCASTING + assert broadcast_message.approved_at is not None + assert broadcast_message.created_by_id == template.created_by_id + assert broadcast_message.approved_by_id == template.created_by_id + assert not mock_task.called + + +@pytest.mark.parametrize('broadcast_message_stubbed, service_restricted_before_approval', [ + (True, True), + (True, False), + (False, True), +]) +def test_validate_and_update_broadcast_message_status_when_broadcast_message_is_stubbed_or_service_not_live( + admin_request, + sample_broadcast_service, + mocker, + broadcast_message_stubbed, + service_restricted_before_approval, +): + sample_broadcast_service.restricted = broadcast_message_stubbed + template = create_template(sample_broadcast_service, BROADCAST_TYPE, content='emergency broadcast') + broadcast_message = create_broadcast_message( + template, + status=BroadcastStatusType.PENDING_APPROVAL, + areas={"ids": ["london"], "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]]}, + stubbed=broadcast_message_stubbed + ) + approver = create_user(email='approver@gov.uk') + sample_broadcast_service.users.append(approver) + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + sample_broadcast_service.restricted = service_restricted_before_approval + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.BROADCASTING, approver + ) + assert broadcast_message.status == BroadcastStatusType.BROADCASTING + assert broadcast_message.approved_at is not None + assert broadcast_message.approved_by_id == approver.id + + # The broadcast can be approved, but does not create a broadcast_event in the database or put a task on the queue + assert len(broadcast_message.events) == 0 + assert len(mock_task.mock_calls) == 0 + + +def test_validate_and_update_broadcast_message_status_creates_event_with_correct_content_if_broadcast_has_no_template( + admin_request, + sample_broadcast_service, + mocker +): + broadcast_message = create_broadcast_message( + service=sample_broadcast_service, + template=None, + content='tailor made emergency broadcast content', + status=BroadcastStatusType.PENDING_APPROVAL, + areas={ + "ids": ["london"], + "simple_polygons": [[[51.30, 0.7], [51.28, 0.8], [51.25, -0.7]]] + } + ) + approver = create_user(email='approver@gov.uk') + sample_broadcast_service.users.append(approver) + mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') + + validate_and_update_broadcast_message_status( + broadcast_message, BroadcastStatusType.BROADCASTING, approver + ) + + assert broadcast_message.status == BroadcastStatusType.BROADCASTING + + assert len(broadcast_message.events) == 1 + alert_event = broadcast_message.events[0] + + mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') + + assert alert_event.transmitted_content == {"body": "tailor made emergency broadcast content"} diff --git a/tests/app/v2/broadcast/test_post_broadcast.py b/tests/app/v2/broadcast/test_post_broadcast.py index de88ac1c8..c4e261629 100644 --- a/tests/app/v2/broadcast/test_post_broadcast.py +++ b/tests/app/v2/broadcast/test_post_broadcast.py @@ -119,9 +119,16 @@ 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( +@pytest.mark.parametrize("is_approved,expected_status", [ + [True, "cancelled"], + [False, "rejected"] +]) +def test_valid_cancel_broadcast_request_calls_validate_and_update_broadcast_message_status_and_returns_201( client, sample_broadcast_service, + mocker, + is_approved, + expected_status ): auth_header = create_service_authorization_header(service_id=sample_broadcast_service.id) @@ -135,74 +142,23 @@ def test_valid_cancel_broadcast_request_rejects_unapproved_alert_and_returns_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( - client, - sample_broadcast_service, - notify_db, - notify_db_session, - mocker -): - 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' - - # approve broadcast broadcast_message = dao_get_broadcast_message_by_id_and_service_id( response_json_for_create["id"], response_json_for_create["service_id"] ) - broadcast_message.status = 'broadcasting' + # approve broadcast + if is_approved: + broadcast_message.status = 'broadcasting' + + mock_update = mocker.patch('app.v2.broadcast.post_broadcast.validate_and_update_broadcast_message_status') # cancel broadcast - mock_task = mocker.patch('app.celery.broadcast_message_tasks.send_broadcast_event.apply_async') - 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 - - assert len(broadcast_message.events) == 1 - alert_event = broadcast_message.events[0] - mock_task.assert_called_once_with(kwargs={'broadcast_event_id': str(alert_event.id)}, queue='broadcast-tasks') - - assert broadcast_message.status == 'cancelled' - assert broadcast_message.cancelled_at is not None - assert broadcast_message.cancelled_by_id is None # broadcast cancelled via API, so not by any single user - assert broadcast_message.updated_at is not None + mock_update.assert_called_once_with(broadcast_message, expected_status, updating_user=None) def test_cancel_request_does_not_cancel_broadcast_if_reference_does_not_match(