diff --git a/app/__init__.py b/app/__init__.py index 46554c32e..2ae2d497e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -113,6 +113,7 @@ def register_blueprint(application): from app.organisation.rest import organisation_blueprint from app.organisation.invite_rest import organisation_invite_blueprint from app.complaint.complaint_rest import complaint_blueprint + from app.platform_stats.rest import platform_stats_blueprint service_blueprint.before_request(requires_admin_auth) application.register_blueprint(service_blueprint, url_prefix='/service') @@ -192,6 +193,9 @@ def register_blueprint(application): complaint_blueprint.before_request(requires_admin_auth) application.register_blueprint(complaint_blueprint) + platform_stats_blueprint.before_request(requires_admin_auth) + application.register_blueprint(platform_stats_blueprint, url_prefix='/platform-stats') + def register_v2_blueprints(application): from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint as get_inbound_sms diff --git a/app/dao/notifications_dao.py b/app/dao/notifications_dao.py index f531a15c6..e0d39e87f 100644 --- a/app/dao/notifications_dao.py +++ b/app/dao/notifications_dao.py @@ -609,3 +609,31 @@ def guess_notification_type(search_term): return EMAIL_TYPE else: return SMS_TYPE + + +@statsd(namespace='dao') +def fetch_aggregate_stats_by_date_range_for_all_services(start_date, end_date): + start_date = get_london_midnight_in_utc(start_date) + end_date = get_london_midnight_in_utc(end_date + timedelta(days=1)) + table = NotificationHistory + + if end_date >= datetime.utcnow() - timedelta(days=7): + table = Notification + + query = db.session.query( + table.notification_type, + table.status, + table.key_type, + func.count(table.id).label('count') + ).filter( + table.created_at >= start_date, + table.created_at < end_date + ).group_by( + table.notification_type, + table.key_type, + table.status + ).order_by( + table.notification_type, + ) + + return query.all() diff --git a/app/platform_stats/__init__.py b/app/platform_stats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/platform_stats/platform_stats_schema.py b/app/platform_stats/platform_stats_schema.py new file mode 100644 index 000000000..cb28a2620 --- /dev/null +++ b/app/platform_stats/platform_stats_schema.py @@ -0,0 +1,10 @@ +platform_stats_request = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "platform stats request schema", + "type": "object", + "title": "Platform stats request", + "properties": { + "start_date": {"type": ["string", "null"], "format": "date"}, + "end_date": {"type": ["string", "null"], "format": "date"}, + } +} diff --git a/app/platform_stats/rest.py b/app/platform_stats/rest.py new file mode 100644 index 000000000..54e94cc47 --- /dev/null +++ b/app/platform_stats/rest.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from flask import Blueprint, jsonify, request + +from app.dao.notifications_dao import fetch_aggregate_stats_by_date_range_for_all_services +from app.errors import register_errors +from app.platform_stats.platform_stats_schema import platform_stats_request +from app.service.statistics import format_admin_stats +from app.schema_validation import validate + +platform_stats_blueprint = Blueprint('platform_stats', __name__) + +register_errors(platform_stats_blueprint) + + +@platform_stats_blueprint.route('') +def get_platform_stats(): + if request.args: + validate(request.args, platform_stats_request) + + # If start and end date are not set, we are expecting today's stats. + today = str(datetime.utcnow().date()) + + start_date = datetime.strptime(request.args.get('start_date', today), '%Y-%m-%d').date() + end_date = datetime.strptime(request.args.get('end_date', today), '%Y-%m-%d').date() + data = fetch_aggregate_stats_by_date_range_for_all_services(start_date=start_date, end_date=end_date) + stats = format_admin_stats(data) + + return jsonify(stats) diff --git a/app/service/statistics.py b/app/service/statistics.py index 26b3fb289..df49b7ff2 100644 --- a/app/service/statistics.py +++ b/app/service/statistics.py @@ -14,6 +14,37 @@ def format_statistics(statistics): return counts +def format_admin_stats(statistics): + counts = create_stats_dict() + + for row in statistics: + if row.key_type == 'test': + counts[row.notification_type]['test-key'] += row.count + else: + counts[row.notification_type]['total'] += row.count + if row.status in ('technical-failure', 'permanent-failure', 'temporary-failure', 'virus-scan-failed'): + counts[row.notification_type]['failures'][row.status] += row.count + + return counts + + +def create_stats_dict(): + stats_dict = {} + for template in TEMPLATE_TYPES: + stats_dict[template] = {} + + for status in ('total', 'test-key'): + stats_dict[template][status] = 0 + + stats_dict[template]['failures'] = { + 'technical-failure': 0, + 'permanent-failure': 0, + 'temporary-failure': 0, + 'virus-scan-failed': 0, + } + return stats_dict + + def format_monthly_template_notification_stats(year, rows): stats = { datetime.strftime(date, '%Y-%m'): {} diff --git a/tests/app/dao/notification_dao/test_notification_dao.py b/tests/app/dao/notification_dao/test_notification_dao.py index 775598015..1e05a509c 100644 --- a/tests/app/dao/notification_dao/test_notification_dao.py +++ b/tests/app/dao/notification_dao/test_notification_dao.py @@ -33,7 +33,8 @@ from app.dao.notifications_dao import ( dao_get_notification_by_reference, dao_get_notifications_by_references, dao_get_notification_history_by_reference, - notifications_not_yet_sent + notifications_not_yet_sent, + fetch_aggregate_stats_by_date_range_for_all_services, ) from app.dao.services_dao import dao_update_service from app.models import ( @@ -1933,3 +1934,65 @@ def test_notifications_not_yet_sent_return_no_rows(sample_service, notification_ results = notifications_not_yet_sent(older_than, notification_type) assert len(results) == 0 + + +def test_fetch_aggregate_stats_by_date_range_for_all_services_returns_empty_list_when_no_stats(notify_db_session): + start_date = date(2018, 1, 1) + end_date = date(2018, 1, 5) + + result = fetch_aggregate_stats_by_date_range_for_all_services(start_date, end_date) + assert result == [] + + +@freeze_time('2018-01-08') +def test_fetch_aggregate_stats_by_date_range_for_all_services_groups_stats( + sample_template, + sample_email_template, + sample_letter_template, +): + today = datetime.now().date() + + for i in range(3): + create_notification(template=sample_email_template, status='permanent-failure', + created_at=today) + + create_notification(template=sample_email_template, status='sent', created_at=today) + create_notification(template=sample_template, status='sent', created_at=today) + create_notification(template=sample_template, status='sent', created_at=today, + key_type=KEY_TYPE_TEAM) + create_notification(template=sample_letter_template, status='virus-scan-failed', + created_at=today) + + result = fetch_aggregate_stats_by_date_range_for_all_services(today, today) + + assert len(result) == 5 + assert result[0] == ('email', 'permanent-failure', 'normal', 3) + assert result[1] == ('email', 'sent', 'normal', 1) + assert result[2] == ('sms', 'sent', 'normal', 1) + assert result[3] == ('sms', 'sent', 'team', 1) + assert result[4] == ('letter', 'virus-scan-failed', 'normal', 1) + + +def test_fetch_aggregate_stats_by_date_range_for_all_services_uses_bst_date(sample_template): + query_day = datetime(2018, 6, 5).date() + create_notification(sample_template, status='sent', created_at=datetime(2018, 6, 4, 23, 59)) + create_notification(sample_template, status='created', created_at=datetime(2018, 6, 5, 23, 00)) + + result = fetch_aggregate_stats_by_date_range_for_all_services(query_day, query_day) + + assert len(result) == 1 + assert result[0].status == 'sent' + + +@freeze_time('2018-01-08T12:00:00') +def test_fetch_aggregate_stats_by_date_range_for_all_services_gets_test_notifications_when_start_date_over_7_days_ago( + sample_template +): + ten_days_ago = datetime.utcnow().date() - timedelta(days=10) + today = datetime.utcnow().date() + + create_notification(sample_template, key_type=KEY_TYPE_TEST, created_at=datetime.utcnow()) + + result = fetch_aggregate_stats_by_date_range_for_all_services(ten_days_ago, today) + + assert len(result) == 1 diff --git a/tests/app/dao/test_services_dao.py b/tests/app/dao/test_services_dao.py index 997920fd5..8cd5acca7 100644 --- a/tests/app/dao/test_services_dao.py +++ b/tests/app/dao/test_services_dao.py @@ -674,7 +674,7 @@ def test_fetch_monthly_historical_stats_separates_months(notify_db, notify_db_se result = dao_fetch_monthly_historical_stats_for_service(sample_template.service_id, 2016) - for date, status, count in ( + for day, status, count in ( ('2016-04', 'sending', 0), ('2016-04', 'delivered', 0), ('2016-04', 'pending', 0), @@ -692,9 +692,9 @@ def test_fetch_monthly_historical_stats_separates_months(notify_db, notify_db_se ('2017-03', 'created', 2), ): - assert result[date]['sms'][status] == count - assert result[date]['email'][status] == 0 - assert result[date]['letter'][status] == 0 + assert result[day]['sms'][status] == count + assert result[day]['email'][status] == 0 + assert result[day]['letter'][status] == 0 assert result.keys() == { '2016-04', '2016-05', '2016-06', diff --git a/tests/app/platform_stats/__init__.py b/tests/app/platform_stats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/platform_stats/test_rest.py b/tests/app/platform_stats/test_rest.py new file mode 100644 index 000000000..5afa7fa43 --- /dev/null +++ b/tests/app/platform_stats/test_rest.py @@ -0,0 +1,37 @@ +from datetime import date, datetime + +from freezegun import freeze_time + + +@freeze_time('2018-06-01') +def test_get_platform_stats_uses_todays_date_if_no_start_or_end_date_is_provided(admin_request, mocker): + today = datetime.now().date() + dao_mock = mocker.patch('app.platform_stats.rest.fetch_aggregate_stats_by_date_range_for_all_services') + mocker.patch('app.service.rest.statistics.format_statistics') + + admin_request.get('platform_stats.get_platform_stats') + + dao_mock.assert_called_once_with(start_date=today, end_date=today) + + +def test_get_platform_stats_can_filter_by_date(admin_request, mocker): + start_date = date(2017, 1, 1) + end_date = date(2018, 1, 1) + dao_mock = mocker.patch('app.platform_stats.rest.fetch_aggregate_stats_by_date_range_for_all_services') + mocker.patch('app.service.rest.statistics.format_statistics') + + admin_request.get('platform_stats.get_platform_stats', start_date=start_date, end_date=end_date) + + dao_mock.assert_called_once_with(start_date=start_date, end_date=end_date) + + +def test_get_platform_stats_validates_the_date(admin_request): + start_date = '1234-56-78' + + response = admin_request.get( + 'platform_stats.get_platform_stats', start_date=start_date, + _expected_status=400 + ) + + assert response['errors'][0]['message'] == 'start_date time data {} does not match format %Y-%m-%d'.format( + start_date) diff --git a/tests/app/service/test_statistics.py b/tests/app/service/test_statistics.py index 7f56a5b9a..856bee499 100644 --- a/tests/app/service/test_statistics.py +++ b/tests/app/service/test_statistics.py @@ -3,11 +3,14 @@ import collections import pytest from app.service.statistics import ( + format_admin_stats, format_statistics, + create_stats_dict, create_zeroed_stats_dicts, ) StatsRow = collections.namedtuple('row', ('notification_type', 'status', 'count')) +NewStatsRow = collections.namedtuple('row', ('notification_type', 'status', 'key_type', 'count')) # email_counts and sms_counts are 3-tuple of requested, delivered, failed @@ -65,5 +68,64 @@ def test_create_zeroed_stats_dicts(): } -def _stats(requested, delivered, failed): - return {'requested': requested, 'delivered': delivered, 'failed': failed} +def test_create_stats_dict(): + assert create_stats_dict() == { + 'sms': {'total': 0, + 'test-key': 0, + 'failures': {'technical-failure': 0, + 'permanent-failure': 0, + 'temporary-failure': 0, + 'virus-scan-failed': 0}}, + 'email': {'total': 0, + 'test-key': 0, + 'failures': {'technical-failure': 0, + 'permanent-failure': 0, + 'temporary-failure': 0, + 'virus-scan-failed': 0}}, + 'letter': {'total': 0, + 'test-key': 0, + 'failures': {'technical-failure': 0, + 'permanent-failure': 0, + 'temporary-failure': 0, + 'virus-scan-failed': 0}} + } + + +def test_format_admin_stats_only_includes_test_key_notifications_in_test_key_section(): + rows = [ + NewStatsRow('email', 'technical-failure', 'test', 3), + NewStatsRow('sms', 'permanent-failure', 'test', 4), + NewStatsRow('letter', 'virus-scan-failed', 'test', 5), + ] + stats_dict = format_admin_stats(rows) + + assert stats_dict['email']['total'] == 0 + assert stats_dict['email']['failures']['technical-failure'] == 0 + assert stats_dict['email']['test-key'] == 3 + + assert stats_dict['sms']['total'] == 0 + assert stats_dict['sms']['failures']['permanent-failure'] == 0 + assert stats_dict['sms']['test-key'] == 4 + + assert stats_dict['letter']['total'] == 0 + assert stats_dict['letter']['failures']['virus-scan-failed'] == 0 + assert stats_dict['letter']['test-key'] == 5 + + +def test_format_admin_stats_counts_non_test_key_notifications_correctly(): + rows = [ + NewStatsRow('email', 'technical-failure', 'normal', 1), + NewStatsRow('email', 'created', 'team', 3), + NewStatsRow('sms', 'temporary-failure', 'normal', 6), + NewStatsRow('sms', 'sent', 'normal', 2), + NewStatsRow('letter', 'pending-virus-check', 'normal', 1), + ] + stats_dict = format_admin_stats(rows) + + assert stats_dict['email']['total'] == 4 + assert stats_dict['email']['failures']['technical-failure'] == 1 + + assert stats_dict['sms']['total'] == 8 + assert stats_dict['sms']['failures']['permanent-failure'] == 0 + + assert stats_dict['letter']['total'] == 1