diff --git a/app/__init__.py b/app/__init__.py index 7b7da7cfe..17c81d24d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -14,6 +14,7 @@ from app.clients.email.aws_ses import AwsSesClient from app.encryption import Encryption DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" +DATE_FORMAT = "%Y-%m-%d" db = SQLAlchemy() ma = Marshmallow() diff --git a/app/dao/notifications_dao.py b/app/dao/notifications_dao.py index 47e0d660f..825e2ead6 100644 --- a/app/dao/notifications_dao.py +++ b/app/dao/notifications_dao.py @@ -11,6 +11,13 @@ def dao_get_notification_statistics_for_service(service_id): ).order_by(desc(NotificationStatistics.day)).all() +def dao_get_notification_statistics_for_service_and_day(service_id, day): + return NotificationStatistics.query.filter_by( + service_id=service_id, + day=day + ).order_by(desc(NotificationStatistics.day)).first() + + def dao_create_notification(notification, notification_type): try: if notification.job_id: diff --git a/app/notifications/rest.py b/app/notifications/rest.py index c50d2cdae..bf8da9efd 100644 --- a/app/notifications/rest.py +++ b/app/notifications/rest.py @@ -8,9 +8,9 @@ from flask import ( url_for ) -from utils.template import Template, NeededByTemplateError, NoPlaceholderForDataError +from utils.template import Template -from app import api_user, encryption, create_uuid, DATETIME_FORMAT +from app import api_user, encryption, create_uuid, DATETIME_FORMAT, DATE_FORMAT from app.authentication.auth import require_admin from app.dao import ( templates_dao, @@ -127,6 +127,18 @@ def send_notification(notification_type): assert False service_id = api_user['client'] + service = services_dao.dao_fetch_service_by_id(api_user['client']) + + service_stats = notifications_dao.dao_get_notification_statistics_for_service_and_day( + service_id, + datetime.utcnow().strftime(DATE_FORMAT) + ) + if service_stats: + total_sms_count = service_stats.sms_requested + total_email_count = service_stats.emails_requested + + if total_email_count + total_sms_count >= service.limit: + return jsonify(result="error", message='Exceeded send limits ({}) for today'.format(service.limit)), 429 notification, errors = ( sms_template_notification_schema if notification_type == 'sms' else email_notification_schema @@ -167,7 +179,6 @@ def send_notification(notification_type): } ), 400 - service = services_dao.dao_fetch_service_by_id(api_user['client']) notification_id = create_uuid() if notification_type == 'sms': diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 3d0cc531e..c9869d5c7 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -101,12 +101,13 @@ def sample_service(notify_db, notify_db_session, service_name="Sample service", user=None, - restricted=False): + restricted=False, + limit=1000): if user is None: user = sample_user(notify_db, notify_db_session) data = { 'name': service_name, - 'limit': 1000, + 'limit': limit, 'active': False, 'restricted': restricted, 'email_from': email_safe(service_name) diff --git a/tests/app/dao/test_notification_dao.py b/tests/app/dao/test_notification_dao.py index b76e36959..f3df4deb4 100644 --- a/tests/app/dao/test_notification_dao.py +++ b/tests/app/dao/test_notification_dao.py @@ -1,5 +1,8 @@ import pytest import uuid +from app import ( + DATE_FORMAT +) from freezegun import freeze_time from sqlalchemy.exc import SQLAlchemyError, IntegrityError from app.models import Notification, Job, NotificationStatistics @@ -10,7 +13,8 @@ from app.dao.notifications_dao import ( get_notification, get_notification_for_job, get_notifications_for_job, - dao_get_notification_statistics_for_service + dao_get_notification_statistics_for_service, + dao_get_notification_statistics_for_service_and_day ) from tests.app.conftest import sample_job @@ -31,10 +35,48 @@ def test_should_be_able_to_get_statistics_for_a_service(sample_template): assert len(stats) == 1 assert stats[0].emails_requested == 0 assert stats[0].sms_requested == 1 - assert stats[0].day == notification.created_at.strftime('%Y-%m-%d') + assert stats[0].day == notification.created_at.strftime(DATE_FORMAT) assert stats[0].service_id == notification.service_id +def test_should_be_able_to_get_statistics_for_a_service_for_a_day(sample_template): + now = datetime.utcnow() + data = { + 'to': '+44709123456', + 'service': sample_template.service, + 'service_id': sample_template.service.id, + 'template': sample_template, + 'created_at': now + } + + notification = Notification(**data) + dao_create_notification(notification, sample_template.template_type) + stat = dao_get_notification_statistics_for_service_and_day( + sample_template.service.id, now.strftime(DATE_FORMAT) + ) + assert stat.emails_requested == 0 + assert stat.sms_requested == 1 + assert stat.day == notification.created_at.strftime(DATE_FORMAT) + assert stat.service_id == notification.service_id + + +def test_should_return_none_if_no_statistics_for_a_service_for_a_day(sample_template): + now = datetime.utcnow() + data = { + 'to': '+44709123456', + 'service': sample_template.service, + 'service_id': sample_template.service.id, + 'template': sample_template, + 'created_at': now + } + + notification = Notification(**data) + dao_create_notification(notification, sample_template.template_type) + assert not dao_get_notification_statistics_for_service_and_day( + sample_template.service.id, (datetime.utcnow() - timedelta(days=1)).strftime(DATE_FORMAT) + ) + + def test_should_be_able_to_get_all_statistics_for_a_service(sample_template): data = { 'to': '+44709123456', @@ -88,13 +130,13 @@ def test_should_be_able_to_get_all_statistics_for_a_service_for_several_days(sam assert len(stats) == 3 assert stats[0].emails_requested == 0 assert stats[0].sms_requested == 1 - assert stats[0].day == today.strftime('%Y-%m-%d') + assert stats[0].day == today.strftime(DATE_FORMAT) assert stats[1].emails_requested == 0 assert stats[1].sms_requested == 1 - assert stats[1].day == yesterday.strftime('%Y-%m-%d') + assert stats[1].day == yesterday.strftime(DATE_FORMAT) assert stats[2].emails_requested == 0 assert stats[2].sms_requested == 1 - assert stats[2].day == two_days_ago.strftime('%Y-%m-%d') + assert stats[2].day == two_days_ago.strftime(DATE_FORMAT) def test_should_be_empty_list_if_no_statistics_for_a_service(sample_service): diff --git a/tests/app/notifications/test_rest.py b/tests/app/notifications/test_rest.py index 1b84bc239..76051ee63 100644 --- a/tests/app/notifications/test_rest.py +++ b/tests/app/notifications/test_rest.py @@ -1,7 +1,7 @@ import uuid import app.celery.tasks from tests import create_authorization_header -from tests.app.conftest import sample_notification, sample_job, sample_service +from tests.app.conftest import sample_notification, sample_job, sample_service, sample_email_template, sample_template from flask import json from app.models import Service from app.dao.templates_dao import dao_get_all_templates_for_service @@ -95,7 +95,6 @@ def test_get_all_notifications_newest_first(notify_api, notify_db, notify_db_ses def test_get_all_notifications_for_service_in_order(notify_api, notify_db, notify_db_session): with notify_api.test_request_context(): with notify_api.test_client() as client: - service_1 = sample_service(notify_db, notify_db_session, service_name="1") service_2 = sample_service(notify_db, notify_db_session, service_name="2") @@ -126,7 +125,6 @@ def test_get_all_notifications_for_job_in_order(notify_api, notify_db, notify_db with notify_api.test_client() as client: main_job = sample_job(notify_db, notify_db_session, service=sample_service) another_job = sample_job(notify_db, notify_db_session, service=sample_service) - from time import sleep notification_1 = sample_notification(notify_db, notify_db_session, job=main_job, to_field="1") notification_2 = sample_notification(notify_db, notify_db_session, job=main_job, to_field="2") @@ -282,6 +280,7 @@ def test_should_reject_bad_phone_numbers(notify_api, sample_template, mocker): 'template': sample_template.id } auth_header = create_authorization_header( + service_id=sample_template.service.id, request_body=json.dumps(data), path='/notifications/sms', method='POST') @@ -293,7 +292,6 @@ def test_should_reject_bad_phone_numbers(notify_api, sample_template, mocker): json_resp = json.loads(response.get_data(as_text=True)) app.celery.tasks.send_sms.apply_async.assert_not_called() - assert json_resp['result'] == 'error' assert len(json_resp['message'].keys()) == 1 assert 'Invalid phone number: Must not contain letters or symbols' in json_resp['message']['to'] @@ -365,9 +363,7 @@ def test_send_notification_with_placeholders_replaced(notify_api, sample_templat assert response.status_code == 201 -def test_send_notification_with_missing_personalisation( - notify_api, sample_template_with_placeholders, mocker -): +def test_send_notification_with_missing_personalisation(notify_api, sample_template_with_placeholders, mocker): with notify_api.test_request_context(): with notify_api.test_client() as client: mocker.patch('app.celery.tasks.send_sms.apply_async') @@ -398,7 +394,7 @@ def test_send_notification_with_missing_personalisation( def test_send_notification_with_too_much_personalisation_data( - notify_api, sample_template_with_placeholders, mocker + notify_api, sample_template_with_placeholders, mocker ): with notify_api.test_request_context(): with notify_api.test_client() as client: @@ -753,3 +749,102 @@ def test_should_allow_valid_email_notification(notify_api, sample_email_template ) assert response.status_code == 201 assert notification_id + + +@freeze_time("2016-01-01 12:00:00.061258") +def test_should_block_api_call_if_over_day_limit(notify_db, notify_db_session, notify_api, mocker): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_email.apply_async') + mocker.patch('app.encryption.encrypt', return_value="something_encrypted") + + service = sample_service(notify_db, notify_db_session, limit=1) + email_template = sample_email_template(notify_db, notify_db_session, service=service) + sample_notification(notify_db, notify_db_session, template=email_template, service=service) + + data = { + 'to': 'ok@ok.com', + 'template': email_template.id + } + + auth_header = create_authorization_header( + request_body=json.dumps(data), + path='/notifications/email', + method='POST', + service_id=service.id + ) + + response = client.post( + path='/notifications/email', + data=json.dumps(data), + headers=[('Content-Type', 'application/json'), auth_header]) + json_resp = json.loads(response.get_data(as_text=True)) + + assert response.status_code == 429 + assert 'Exceeded send limits (1) for today' in json_resp['message'] + + +@freeze_time("2016-01-01 12:00:00.061258") +def test_should_block_api_call_if_over_day_limit_regardless_of_type(notify_db, notify_db_session, notify_api, mocker): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + mocker.patch('app.encryption.encrypt', return_value="something_encrypted") + + service = sample_service(notify_db, notify_db_session, limit=1) + email_template = sample_email_template(notify_db, notify_db_session, service=service) + sms_template = sample_template(notify_db, notify_db_session, service=service) + sample_notification(notify_db, notify_db_session, template=email_template, service=service) + + data = { + 'to': '+441234123123', + 'template': sms_template.id + } + + auth_header = create_authorization_header( + request_body=json.dumps(data), + path='/notifications/sms', + method='POST', + service_id=service.id + ) + + response = client.post( + path='/notifications/sms', + data=json.dumps(data), + headers=[('Content-Type', 'application/json'), auth_header]) + json_resp = json.loads(response.get_data(as_text=True)) + + assert response.status_code == 429 + assert 'Exceeded send limits (1) for today' in json_resp['message'] + + +@freeze_time("2016-01-01 12:00:00.061258") +def test_should_allow_api_call_if_under_day_limit_regardless_of_type(notify_db, notify_db_session, notify_api, mocker): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + mocker.patch('app.celery.tasks.send_sms.apply_async') + mocker.patch('app.encryption.encrypt', return_value="something_encrypted") + + service = sample_service(notify_db, notify_db_session, limit=2) + email_template = sample_email_template(notify_db, notify_db_session, service=service) + sms_template = sample_template(notify_db, notify_db_session, service=service) + sample_notification(notify_db, notify_db_session, template=email_template, service=service) + + data = { + 'to': '+447634123123', + 'template': sms_template.id + } + + auth_header = create_authorization_header( + request_body=json.dumps(data), + path='/notifications/sms', + method='POST', + service_id=service.id + ) + + response = client.post( + path='/notifications/sms', + data=json.dumps(data), + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 201