diff --git a/app/config.py b/app/config.py index d8c195d85..1ffdddefd 100644 --- a/app/config.py +++ b/app/config.py @@ -384,6 +384,9 @@ class Config(object): ENABLED_CBCS = {BroadcastProvider.EE, BroadcastProvider.THREE, BroadcastProvider.O2, BroadcastProvider.VODAFONE} + # as defined in api db migration 0331_add_broadcast_org.py + BROADCAST_ORGANISATION_ID = '38e4bf69-93b0-445d-acee-53ea53fe02df' + ###################### # Config overrides ### diff --git a/app/dao/broadcast_service_dao.py b/app/dao/broadcast_service_dao.py new file mode 100644 index 000000000..d228bfc31 --- /dev/null +++ b/app/dao/broadcast_service_dao.py @@ -0,0 +1,65 @@ +from datetime import datetime + +from flask import current_app + +from app import db +from app.models import ServiceBroadcastSettings, ServicePermission, Organisation, BROADCAST_TYPE +from app.dao.dao_utils import transactional + + +@transactional +def set_broadcast_service_type(service, service_mode, broadcast_channel, provider_restriction): + insert_or_update_service_broadcast_settings( + service, channel=broadcast_channel, provider_restriction=provider_restriction + ) + + # Remove all permissions and add broadcast permission + if not service.has_permission(BROADCAST_TYPE): + service_permission = ServicePermission(service_id=service.id, permission=BROADCAST_TYPE) + db.session.add(service_permission) + + ServicePermission.query.filter( + ServicePermission.service_id == service.id, + ServicePermission.permission != BROADCAST_TYPE + ).delete() + + # Refresh the service object as it has references to the service permissions but we don't yet + # want to commit the permission changes incase all of this needs to rollback + db.session.refresh(service) + + # Set service count as live false always + service.count_as_live = False + + # Set service into training mode or live mode + if service_mode == "live": + if service.restricted: + # Only update the go live at timestamp if this if moving from training mode + # to live mode, not if it's moving from one type of live mode service to another + service.go_live_at = datetime.utcnow() + service.restricted = False + else: + service.restricted = True + service.go_live_at = None + + # Add service to organisation + organisation = Organisation.query.filter_by( + id=current_app.config['BROADCAST_ORGANISATION_ID'] + ).one() + service.organisation_id = organisation.id + service.organisation_type = organisation.organisation_type + service.crown = organisation.crown + + db.session.add(service) + + +def insert_or_update_service_broadcast_settings(service, channel, provider_restriction=None): + if not service.service_broadcast_settings: + settings = ServiceBroadcastSettings() + settings.service = service + settings.channel = channel + settings.provider = provider_restriction + db.session.add(settings) + else: + service.service_broadcast_settings.channel = channel + service.service_broadcast_settings.provider = provider_restriction + db.session.add(service.service_broadcast_settings) diff --git a/app/schemas.py b/app/schemas.py index 7d9a59d06..f8b510f7b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -25,7 +25,7 @@ from notifications_utils.recipients import ( from app import ma from app import models -from app.models import ServicePermission +from app.models import ServicePermission, BROADCAST_TYPE from app.dao.permissions_dao import permission_dao from app.utils import DATETIME_FORMAT_NO_TIMEZONE, get_template_instance @@ -236,10 +236,21 @@ class ServiceSchema(BaseSchema, UUIDsAsStringsMixin): override_flag = False go_live_at = field_for(models.Service, 'go_live_at', format=DATETIME_FORMAT_NO_TIMEZONE) allowed_broadcast_provider = fields.Method(dump_only=True, serialize='_get_allowed_broadcast_provider') + broadcast_channel = fields.Method(dump_only=True, serialize='_get_broadcast_channel') def _get_allowed_broadcast_provider(self, service): return service.allowed_broadcast_provider + def _get_broadcast_channel(self, service): + # TODO: Once we've migrated data so that all broadcast services have `service.broadcast_channel` + # set then we can remove this logic and related tests and instead just return + # `service.broadcast_channel`. For the moment though, as we have some services with the broadcast + # permission that do not have a row in the service_broadcast_settings table, we need to hardcode + # this in here to give them a default that the admin app can use + if BROADCAST_TYPE in self.service_permissions(service): + return service.broadcast_channel if service.broadcast_channel else "test" + return None + def get_letter_logo_filename(self, service): return service.letter_branding and service.letter_branding.filename diff --git a/app/service/rest.py b/app/service/rest.py index 2f04ed2d1..d0c6af347 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -29,7 +29,9 @@ from app.dao.fact_notification_status_dao import ( fetch_stats_for_all_services_by_date_range, fetch_monthly_template_usage_for_service ) from app.dao.inbound_numbers_dao import dao_allocate_number_for_service -from app.dao.organisation_dao import dao_get_organisation_by_service_id +from app.dao.organisation_dao import ( + dao_get_organisation_by_service_id, +) from app.dao.returned_letters_dao import ( fetch_most_recent_returned_letter, fetch_recent_returned_letter_count, @@ -42,6 +44,7 @@ from app.dao.service_contact_list_dao import ( dao_get_contact_list_by_id, save_service_contact_list, ) +from app.dao.broadcast_service_dao import set_broadcast_service_type from app.dao.service_data_retention_dao import ( fetch_service_data_retention, fetch_service_data_retention_by_id, @@ -100,8 +103,13 @@ from app.errors import ( ) from app.letters.utils import letter_print_day from app.models import ( - KEY_TYPE_NORMAL, LETTER_TYPE, NOTIFICATION_CANCELLED, Permission, Service, - EmailBranding, LetterBranding, + KEY_TYPE_NORMAL, + LETTER_TYPE, + NOTIFICATION_CANCELLED, + Permission, + Service, + EmailBranding, + LetterBranding, ServiceContactList ) from app.notifications.process_notifications import persist_notification, send_notification_to_queue @@ -118,6 +126,7 @@ from app.service.service_senders_schema import ( add_service_letter_contact_block_request, add_service_sms_sender_request ) +from app.service.service_broadcast_settings_schema import service_broadcast_settings_schema from app.service.utils import get_guest_list_objects from app.service.sender import send_notification_to_service_users from app.service.send_notification import send_one_off_notification, send_pdf_letter_notification @@ -1070,3 +1079,27 @@ def create_contact_list(service_id): save_service_contact_list(list_to_save) return jsonify(list_to_save.serialize()), 201 + + +@service_blueprint.route('//set-as-broadcast-service', methods=['POST']) +def set_as_broadcast_service(service_id): + """ + This route does the following + - adds a service broadcast settings to define which channel broadcasts should go out on + - removes all current service permissions and adds the broadcast service permission + - sets the services `count_as_live` to false + - adds the service to the broadcast organisation + - puts the service into training mode or live mode + """ + data = validate(request.get_json(), service_broadcast_settings_schema) + service = dao_fetch_service_by_id(service_id) + + set_broadcast_service_type( + service, + service_mode=data["service_mode"], + broadcast_channel=data["broadcast_channel"], + provider_restriction=data["provider_restriction"] + ) + + data = service_schema.dump(service).data + return jsonify(data=data) diff --git a/app/service/service_broadcast_settings_schema.py b/app/service/service_broadcast_settings_schema.py new file mode 100644 index 000000000..bc18af07d --- /dev/null +++ b/app/service/service_broadcast_settings_schema.py @@ -0,0 +1,12 @@ +service_broadcast_settings_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Set a services broadcast settings", + "type": "object", + "title": "Set a services broadcast settings", + "properties": { + "broadcast_channel": {"enum": ["test", "severe"]}, + "service_mode": {"enum": ["training", "live"]}, + "provider_restriction": {"enum": [None, "three", "o2", "vodafone", "ee"]} + }, + "required": ["broadcast_channel", "service_mode", "provider_restriction"] +} diff --git a/tests/app/conftest.py b/tests/app/conftest.py index dd02564a1..ce91c2e5a 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -14,8 +14,9 @@ from app.dao.api_key_dao import save_model_api_key from app.dao.invited_user_dao import save_invited_user from app.dao.jobs_dao import dao_create_job from app.dao.notifications_dao import dao_create_notification -from app.dao.organisation_dao import dao_create_organisation +from app.dao.organisation_dao import dao_create_organisation, dao_add_service_to_organisation from app.dao.services_dao import (dao_create_service, dao_add_user_to_service) +from app.dao.broadcast_service_dao import insert_or_update_service_broadcast_settings from app.dao.templates_dao import dao_create_template from app.dao.users_dao import create_secret_code, create_user_code from app.history_meta import create_history @@ -42,7 +43,7 @@ from app.models import ( LETTER_TYPE, SERVICE_PERMISSION_TYPES, ServiceEmailReplyTo, - BROADCAST_TYPE + BROADCAST_TYPE, ) from tests import create_authorization_header from tests.app.db import ( @@ -150,7 +151,7 @@ def sample_service(notify_db_session): @pytest.fixture(scope='function') -def sample_broadcast_service(notify_db_session): +def sample_broadcast_service(notify_db_session, broadcast_organisation): user = create_user() service_name = 'Sample broadcast service' email_from = service_name.lower().replace(' ', '.') @@ -161,12 +162,15 @@ def sample_broadcast_service(notify_db_session): 'restricted': False, 'email_from': email_from, 'created_by': user, - 'crown': True + 'crown': True, + 'count_as_live': False, } service = Service.query.filter_by(name=service_name).first() if not service: service = Service(**data) dao_create_service(service, user, service_permissions=[BROADCAST_TYPE]) + insert_or_update_service_broadcast_settings(service, channel="severe") + dao_add_service_to_organisation(service, current_app.config['BROADCAST_ORGANISATION_ID']) else: if user not in service.users: dao_add_user_to_service(service, user) @@ -874,6 +878,16 @@ def sample_organisation(notify_db_session): return org +@pytest.fixture +def broadcast_organisation(notify_db_session): + org = Organisation.query.get(current_app.config['BROADCAST_ORGANISATION_ID']) + if not org: + org = Organisation(id=current_app.config['BROADCAST_ORGANISATION_ID'], name='broadcast organisation') + dao_create_organisation(org) + + return org + + @pytest.fixture def restore_provider_details(notify_db, notify_db_session): """ diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index efcb45ff7..66712b2c6 100644 --- a/tests/app/service/test_rest.py +++ b/tests/app/service/test_rest.py @@ -31,6 +31,7 @@ from app.models import ( EMAIL_TYPE, SMS_TYPE, LETTER_TYPE, + BROADCAST_TYPE, INTERNATIONAL_LETTERS, INTERNATIONAL_SMS_TYPE, INBOUND_SMS_TYPE, @@ -243,6 +244,7 @@ def test_get_service_by_id(admin_request, sample_service): assert json_resp['data']['email_branding'] is None assert json_resp['data']['prefix_sms'] is True assert json_resp['data']['allowed_broadcast_provider'] is None + assert json_resp['data']['broadcast_channel'] is None assert set(json_resp['data'].keys()) == { 'active', @@ -250,6 +252,7 @@ def test_get_service_by_id(admin_request, sample_service): 'billing_contact_email_addresses', 'billing_contact_names', 'billing_reference', + 'broadcast_channel', 'consent_to_research', 'contact_link', 'count_as_live', @@ -288,6 +291,38 @@ def test_get_service_by_id_returns_allowed_broadcast_provider(notify_db, admin_r assert json_resp['data']['allowed_broadcast_provider'] == 'ee' +def test_get_service_by_id_for_broadcast_service_takes_channel_from_service_broadcast_settings( + admin_request, sample_broadcast_service +): + assert sample_broadcast_service.broadcast_channel == 'severe' + + json_resp = admin_request.get('service.get_service_by_id', service_id=sample_broadcast_service.id) + assert json_resp['data']['id'] == str(sample_broadcast_service.id) + assert json_resp['data']['broadcast_channel'] == 'severe' + + +def test_get_service_by_id_for_service_with_broadcast_permission_sets_channel_as_test_if_no_service_broadcast_settings( + admin_request, notify_db_session +): + service = create_service(service_permissions=[BROADCAST_TYPE]) + assert BROADCAST_TYPE in [p.permission for p in service.permissions] + assert service.broadcast_channel is None + + json_resp = admin_request.get('service.get_service_by_id', service_id=service.id) + assert json_resp['data']['id'] == str(service.id) + assert json_resp['data']['broadcast_channel'] == 'test' + + +def test_get_service_by_id_for_non_broadcast_service_sets_channel_as_none( + admin_request, sample_service +): + assert BROADCAST_TYPE not in [p.permission for p in sample_service.permissions] + + json_resp = admin_request.get('service.get_service_by_id', service_id=sample_service.id) + assert json_resp['data']['id'] == str(sample_service.id) + assert json_resp['data']['broadcast_channel'] is None + + @pytest.mark.parametrize('detailed', [True, False]) def test_get_service_by_id_returns_organisation_type(admin_request, sample_service, detailed): json_resp = admin_request.get('service.get_service_by_id', service_id=sample_service.id, detailed=detailed) @@ -3645,3 +3680,391 @@ def test_get_returned_letter(admin_request, sample_letter_template): assert not response[4]['original_file_name'] assert not response[4]['job_row_number'] assert response[4]['uploaded_letter_file_name'] == 'filename.pdf' + + +@pytest.mark.parametrize('channel', ["test", "severe"]) +def test_set_as_broadcast_service_sets_broadcast_channel( + admin_request, sample_service, broadcast_organisation, channel +): + assert sample_service.service_broadcast_settings is None + data = { + 'broadcast_channel': channel, + 'service_mode': 'live', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample service' + assert result['data']['broadcast_channel'] == channel + + records = ServiceBroadcastSettings.query.filter_by(service_id=sample_service.id).all() + assert len(records) == 1 + assert records[0].service_id == sample_service.id + assert records[0].channel == channel + + +def test_set_as_broadcast_service_updates_channel_for_broadcast_service( + admin_request, sample_broadcast_service +): + assert sample_broadcast_service.broadcast_channel == "severe" + + data = { + 'broadcast_channel': "test", + 'service_mode': 'training', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_broadcast_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample broadcast service' + assert result['data']['broadcast_channel'] == "test" + + records = ServiceBroadcastSettings.query.filter_by(service_id=sample_broadcast_service.id).all() + assert len(records) == 1 + assert records[0].service_id == sample_broadcast_service.id + assert records[0].channel == "test" + + +@pytest.mark.parametrize('channel', ["government", "extreme", "exercise", "random", ""]) +def test_set_as_broadcast_service_rejects_unknown_channels( + admin_request, sample_service, broadcast_organisation, channel +): + data = { + 'broadcast_channel': channel, + 'service_mode': 'live', + 'provider_restriction': None, + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + ) + + +def test_set_as_broadcast_service_rejects_if_no_channel( + admin_request, notify_db, sample_service, broadcast_organisation +): + data = { + 'service_mode': 'training', + 'provider_restriction': None, + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + ) + + +def test_set_as_broadcast_service_gives_broadcast_permission_and_removes_other_permissions( + admin_request, sample_service, broadcast_organisation +): + current_permissions = [p.permission for p in sample_service.permissions] + assert len(current_permissions) > 0 + assert BROADCAST_TYPE not in current_permissions + + data = { + 'broadcast_channel': "severe", + 'service_mode': 'training', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['permissions'] == [BROADCAST_TYPE] + + permissions = ServicePermission.query.filter_by(service_id=sample_service.id).all() + assert [p.permission for p in permissions] == [BROADCAST_TYPE] + + +def test_set_as_broadcast_service_maintains_broadcast_permission_for_existing_broadcast_service( + admin_request, sample_broadcast_service +): + current_permissions = [p.permission for p in sample_broadcast_service.permissions] + assert current_permissions == [BROADCAST_TYPE] + + data = { + 'broadcast_channel': "severe", + 'service_mode': 'live', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_broadcast_service.id, + _data=data, + ) + assert result['data']['permissions'] == [BROADCAST_TYPE] + + permissions = ServicePermission.query.filter_by(service_id=sample_broadcast_service.id).all() + assert [p.permission for p in permissions] == [BROADCAST_TYPE] + + +def test_set_as_broadcast_service_sets_count_as_live_to_false( + admin_request, sample_service, broadcast_organisation +): + assert sample_service.count_as_live is True + + data = { + 'broadcast_channel': "severe", + 'service_mode': 'live', + 'provider_restriction': None, + } + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['count_as_live'] is False + + service_from_db = Service.query.filter_by(id=sample_service.id).all()[0] + assert service_from_db.count_as_live is False + + +def test_set_as_broadcast_service_sets_service_org_to_broadcast_org( + admin_request, sample_service, broadcast_organisation +): + assert sample_service.organisation_id != current_app.config['BROADCAST_ORGANISATION_ID'] + + data = { + 'broadcast_channel': "severe", + 'service_mode': 'training', + 'provider_restriction': None, + } + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['organisation'] == current_app.config['BROADCAST_ORGANISATION_ID'] + + service_from_db = Service.query.filter_by(id=sample_service.id).all()[0] + assert str(service_from_db.organisation_id) == current_app.config['BROADCAST_ORGANISATION_ID'] + + +def test_set_as_broadcast_service_does_not_error_if_run_on_a_service_that_is_already_a_broadcast_service( + admin_request, sample_service, broadcast_organisation +): + data = { + 'broadcast_channel': "severe", + 'service_mode': "live", + 'provider_restriction': None, + } + for _ in range(2): + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + + +@freeze_time('2021-02-02') +def test_set_as_broadcast_service_sets_service_to_live_mode( + admin_request, notify_db, sample_service, broadcast_organisation +): + sample_service.restricted = True + notify_db.session.add(sample_service) + notify_db.session.commit() + assert sample_service.restricted is True + assert sample_service.go_live_at is None + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'live', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample service' + assert result['data']['restricted'] is False + assert result['data']['go_live_at'] == '2021-02-02 00:00:00.000000' + + +def test_set_as_broadcast_service_doesnt_override_existing_go_live_at( + admin_request, notify_db, sample_broadcast_service +): + sample_broadcast_service.restricted = False + sample_broadcast_service.go_live_at = datetime(2021, 1, 1) + notify_db.session.add(sample_broadcast_service) + notify_db.session.commit() + assert sample_broadcast_service.restricted is False + assert sample_broadcast_service.go_live_at is not None + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'live', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_broadcast_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample broadcast service' + assert result['data']['restricted'] is False + assert result['data']['go_live_at'] == '2021-01-01 00:00:00.000000' + + +def test_set_as_broadcast_service_sets_service_to_training_mode( + admin_request, notify_db, sample_broadcast_service +): + sample_broadcast_service.restricted = False + sample_broadcast_service.go_live_at = datetime(2021, 1, 1) + notify_db.session.add(sample_broadcast_service) + notify_db.session.commit() + assert sample_broadcast_service.restricted is False + assert sample_broadcast_service.go_live_at is not None + + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'training', + 'provider_restriction': None, + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_broadcast_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample broadcast service' + assert result['data']['restricted'] is True + assert result['data']['go_live_at'] is None + + +@pytest.mark.parametrize('service_mode', ["testing", ""]) +def test_set_as_broadcast_service_rejects_unknown_service_mode( + admin_request, sample_service, broadcast_organisation, service_mode +): + data = { + 'broadcast_channel': 'severe', + 'service_mode': service_mode, + 'provider_restriction': None, + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + ) + + +def test_set_as_broadcast_service_rejects_if_no_service_mode( + admin_request, sample_service, broadcast_organisation +): + data = { + 'broadcast_channel': 'severe', + 'provider_restriction': None, + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + ) + + +@pytest.mark.parametrize('provider', [None, "three", "ee", "vodafone", "o2"]) +def test_set_as_broadcast_service_sets_mobile_provider_restriction( + admin_request, sample_service, broadcast_organisation, provider +): + assert sample_service.service_broadcast_settings is None + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'live', + 'provider_restriction': provider + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + ) + assert result['data']['name'] == 'Sample service' + assert result['data']['allowed_broadcast_provider'] == provider + + records = ServiceBroadcastSettings.query.filter_by(service_id=sample_service.id).all() + assert len(records) == 1 + assert records[0].service_id == sample_service.id + assert records[0].provider == provider + + +@pytest.mark.parametrize('provider', [None, "vodafone"]) +def test_set_as_broadcast_service_updates_mobile_provider_restriction( + admin_request, notify_db, sample_broadcast_service, provider +): + sample_broadcast_service.service_broadcast_settings.provider = "o2" + notify_db.session.add(sample_broadcast_service) + notify_db.session.commit() + assert sample_broadcast_service.service_broadcast_settings.provider == "o2" + + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'live', + 'provider_restriction': provider + } + + result = admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_broadcast_service.id, + _data=data, + ) + + assert result['data']['name'] == 'Sample broadcast service' + assert result['data']['allowed_broadcast_provider'] == provider + + records = ServiceBroadcastSettings.query.filter_by(service_id=sample_broadcast_service.id).all() + assert len(records) == 1 + assert records[0].service_id == sample_broadcast_service.id + assert records[0].provider == provider + + +@pytest.mark.parametrize('provider', ["three, o2", "giffgaff", "", "None"]) +def test_set_as_broadcast_service_rejects_unknown_provider_restriction( + admin_request, sample_service, broadcast_organisation, provider +): + data = { + 'broadcast_channel': 'test', + 'service_mode': 'live', + 'provider_restriction': provider + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + ) + + +def test_set_as_broadcast_service_errors_if_no_mobile_provider_restriction( + admin_request, sample_service, broadcast_organisation +): + data = { + 'broadcast_channel': 'severe', + 'service_mode': 'live', + } + + admin_request.post( + 'service.set_as_broadcast_service', + service_id=sample_service.id, + _data=data, + _expected_status=400, + )