Merge pull request #563 from alphagov/week-agg

New weekly aggregate function
This commit is contained in:
Leo Hemsted
2016-08-02 10:27:15 +01:00
committed by GitHub
7 changed files with 358 additions and 65 deletions

View File

@@ -157,3 +157,21 @@ def _stats_for_service_query(service_id):
Notification.notification_type, Notification.notification_type,
Notification.status, Notification.status,
) )
def dao_fetch_weekly_historical_stats_for_service(service_id):
monday_of_notification_week = func.date_trunc('week', NotificationHistory.created_at).label('week_start')
return db.session.query(
NotificationHistory.notification_type,
NotificationHistory.status,
monday_of_notification_week,
func.count(NotificationHistory.id).label('count')
).filter(
NotificationHistory.service_id == service_id
).group_by(
NotificationHistory.notification_type,
NotificationHistory.status,
monday_of_notification_week
).order_by(
asc(monday_of_notification_week), NotificationHistory.status
).all()

View File

@@ -1,4 +1,4 @@
from datetime import date from datetime import date, timedelta
from flask import ( from flask import (
jsonify, jsonify,
@@ -8,7 +8,6 @@ from flask import (
) )
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from app.models import EMAIL_TYPE, SMS_TYPE
from app.dao.api_key_dao import ( from app.dao.api_key_dao import (
save_model_api_key, save_model_api_key,
get_model_api_keys, get_model_api_keys,
@@ -23,7 +22,8 @@ from app.dao.services_dao import (
dao_add_user_to_service, dao_add_user_to_service,
dao_remove_user_from_service, dao_remove_user_from_service,
dao_fetch_stats_for_service, dao_fetch_stats_for_service,
dao_fetch_todays_stats_for_service dao_fetch_todays_stats_for_service,
dao_fetch_weekly_historical_stats_for_service
) )
from app.dao import notifications_dao from app.dao import notifications_dao
from app.dao.provider_statistics_dao import get_fragment_count from app.dao.provider_statistics_dao import get_fragment_count
@@ -43,6 +43,7 @@ from app.errors import (
register_errors, register_errors,
InvalidRequest InvalidRequest
) )
from app.service import statistics
service = Blueprint('service', __name__) service = Blueprint('service', __name__)
register_errors(service) register_errors(service)
@@ -236,29 +237,20 @@ def get_all_notifications_for_service(service_id):
), 200 ), 200
@service.route('/<uuid:service_id>/notifications/weekly', methods=['GET'])
def get_weekly_notification_stats(service_id):
service = dao_fetch_service_by_id(service_id)
stats = dao_fetch_weekly_historical_stats_for_service(service_id)
stats = statistics.format_weekly_notification_stats(stats, service.created_at)
return jsonify(data={week.date().isoformat(): statistics for week, statistics in stats.items()})
def get_detailed_service(service_id, today_only=False): def get_detailed_service(service_id, today_only=False):
service = dao_fetch_service_by_id(service_id) service = dao_fetch_service_by_id(service_id)
stats_fn = dao_fetch_todays_stats_for_service if today_only else dao_fetch_stats_for_service stats_fn = dao_fetch_todays_stats_for_service if today_only else dao_fetch_stats_for_service
statistics = stats_fn(service_id) stats = stats_fn(service_id)
service.statistics = format_statistics(statistics)
service.statistics = statistics.format_statistics(stats)
data = detailed_service_schema.dump(service).data data = detailed_service_schema.dump(service).data
return jsonify(data=data) return jsonify(data=data)
def format_statistics(statistics):
# statistics come in a named tuple with uniqueness from 'notification_type', 'status' - however missing
# statuses/notification types won't be represented and the status types need to be simplified/summed up
# so we can return emails/sms * created, sent, and failed
counts = {
template_type: {
status: 0 for status in ('requested', 'delivered', 'failed')
} for template_type in (EMAIL_TYPE, SMS_TYPE)
}
for row in statistics:
counts[row.notification_type]['requested'] += row.count
if row.status == 'delivered':
counts[row.notification_type]['delivered'] += row.count
elif row.status in ('failed', 'technical-failure', 'temporary-failure', 'permanent-failure'):
counts[row.notification_type]['failed'] += row.count
return counts

53
app/service/statistics.py Normal file
View File

@@ -0,0 +1,53 @@
import itertools
from datetime import datetime, timedelta
from app.models import EMAIL_TYPE, SMS_TYPE
def format_statistics(statistics):
# statistics come in a named tuple with uniqueness from 'notification_type', 'status' - however missing
# statuses/notification types won't be represented and the status types need to be simplified/summed up
# so we can return emails/sms * created, sent, and failed
counts = _create_zeroed_stats_dicts()
for row in statistics:
_update_statuses_from_row(counts[row.notification_type], row)
return counts
def format_weekly_notification_stats(statistics, service_created_at):
preceeding_monday = (service_created_at - timedelta(days=service_created_at.weekday()))
# turn a datetime into midnight that day http://stackoverflow.com/a/1937636
preceeding_monday_midnight = datetime.combine(preceeding_monday.date(), datetime.min.time())
week_dict = {
week: _create_zeroed_stats_dicts()
for week in _weeks_for_range(preceeding_monday_midnight, datetime.utcnow())
}
for row in statistics:
_update_statuses_from_row(week_dict[row.week_start][row.notification_type], row)
return week_dict
def _create_zeroed_stats_dicts():
return {
template_type: {
status: 0 for status in ('requested', 'delivered', 'failed')
} for template_type in (EMAIL_TYPE, SMS_TYPE)
}
def _update_statuses_from_row(update_dict, row):
update_dict['requested'] += row.count
if row.status == 'delivered':
update_dict['delivered'] += row.count
elif row.status in ('failed', 'technical-failure', 'temporary-failure', 'permanent-failure'):
update_dict['failed'] += row.count
def _weeks_for_range(start, end):
"""
Generator that yields dates from `start` to `end`, in 7 day intervals. End is inclusive.
"""
infinite_date_generator = (start + timedelta(days=i) for i in itertools.count(step=7))
return itertools.takewhile(lambda x: x <= end, infinite_date_generator)

View File

@@ -13,6 +13,7 @@ from app.models import (
ApiKey, ApiKey,
Job, Job,
Notification, Notification,
NotificationHistory,
InvitedUser, InvitedUser,
Permission, Permission,
ProviderStatistics, ProviderStatistics,
@@ -42,6 +43,8 @@ def service_factory(notify_db, notify_db_session):
def get(self, service_name, user=None, template_type=None, email_from=None): def get(self, service_name, user=None, template_type=None, email_from=None):
if not user: if not user:
user = sample_user(notify_db, notify_db_session) user = sample_user(notify_db, notify_db_session)
if not email_from:
email_from = service_name
service = sample_service(notify_db, notify_db_session, service_name, user, email_from=email_from) service = sample_service(notify_db, notify_db_session, service_name, user, email_from=email_from)
if template_type == 'email': if template_type == 'email':
sample_template( sample_template(
@@ -367,6 +370,31 @@ def sample_notification(notify_db,
return notification return notification
@pytest.fixture(scope='function')
def sample_notification_history(notify_db,
notify_db_session,
sample_template,
status='created',
created_at=None):
if created_at is None:
created_at = datetime.utcnow()
notification_history = NotificationHistory(
id=uuid.uuid4(),
service=sample_template.service,
template=sample_template,
template_version=sample_template.version,
status=status,
created_at=created_at,
notification_type=sample_template.template_type,
key_type=KEY_TYPE_NORMAL
)
notify_db.session.add(notification_history)
notify_db.session.commit()
return notification_history
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def mock_celery_send_sms_code(mocker): def mock_celery_send_sms_code(mocker):
return mocker.patch('app.celery.tasks.send_sms_code.apply_async') return mocker.patch('app.celery.tasks.send_sms_code.apply_async')

View File

@@ -1,6 +1,8 @@
from datetime import datetime
import uuid import uuid
import pytest import functools
import pytest
from sqlalchemy.orm.exc import FlushError, NoResultFound from sqlalchemy.orm.exc import FlushError, NoResultFound
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from freezegun import freeze_time from freezegun import freeze_time
@@ -16,7 +18,8 @@ from app.dao.services_dao import (
dao_update_service, dao_update_service,
delete_service_and_all_associated_db_objects, delete_service_and_all_associated_db_objects,
dao_fetch_stats_for_service, dao_fetch_stats_for_service,
dao_fetch_todays_stats_for_service dao_fetch_todays_stats_for_service,
dao_fetch_weekly_historical_stats_for_service
) )
from app.dao.users_dao import save_model_user from app.dao.users_dao import save_model_user
from app.models import ( from app.models import (
@@ -36,7 +39,8 @@ from app.models import (
) )
from tests.app.conftest import ( from tests.app.conftest import (
sample_notification as create_notification sample_notification as create_notification,
sample_notification_history as create_notification_history
) )
@@ -407,7 +411,7 @@ def test_fetch_stats_counts_correctly(notify_db, notify_db_session, sample_templ
create_notification(notify_db, notify_db_session, template=sample_email_template, status='technical-failure') create_notification(notify_db, notify_db_session, template=sample_email_template, status='technical-failure')
create_notification(notify_db, notify_db_session, template=sample_template, status='created') create_notification(notify_db, notify_db_session, template=sample_template, status='created')
stats = dao_fetch_stats_for_service(sample_template.service.id) stats = dao_fetch_stats_for_service(sample_template.service_id)
stats = sorted(stats, key=lambda x: (x.notification_type, x.status)) stats = sorted(stats, key=lambda x: (x.notification_type, x.status))
assert len(stats) == 3 assert len(stats) == 3
@@ -435,9 +439,83 @@ def test_fetch_stats_for_today_only_includes_today(notify_db, notify_db_session,
with freeze_time('2001-01-02T12:00:00'): with freeze_time('2001-01-02T12:00:00'):
right_now = create_notification(notify_db, None, to_field='3', status='created') right_now = create_notification(notify_db, None, to_field='3', status='created')
stats = dao_fetch_todays_stats_for_service(sample_template.service.id) stats = dao_fetch_todays_stats_for_service(sample_template.service_id)
stats = {row.status: row.count for row in stats} stats = {row.status: row.count for row in stats}
assert 'delivered' not in stats assert 'delivered' not in stats
assert stats['failed'] == 1 assert stats['failed'] == 1
assert stats['created'] == 1 assert stats['created'] == 1
def test_fetch_weekly_historical_stats_separates_weeks(notify_db, notify_db_session, sample_template):
notification_history = functools.partial(
create_notification_history,
notify_db,
notify_db_session,
sample_template
)
week_53_last_yr = notification_history(created_at=datetime(2016, 1, 1))
week_1_last_yr = notification_history(created_at=datetime(2016, 1, 5))
last_sunday = notification_history(created_at=datetime(2016, 7, 24, 23, 59))
last_monday_morning = notification_history(created_at=datetime(2016, 7, 25, 0, 0))
last_monday_evening = notification_history(created_at=datetime(2016, 7, 25, 23, 59))
today = notification_history(created_at=datetime.now(), status='delivered')
with freeze_time('Wed 27th July 2016'):
ret = dao_fetch_weekly_historical_stats_for_service(sample_template.service_id)
assert [(row.week_start, row.status) for row in ret] == [
(datetime(2015, 12, 28), 'created'),
(datetime(2016, 1, 4), 'created'),
(datetime(2016, 7, 18), 'created'),
(datetime(2016, 7, 25), 'created'),
(datetime(2016, 7, 25), 'delivered')
]
assert ret[-2].count == 2
assert ret[-1].count == 1
def test_fetch_weekly_historical_stats_ignores_second_service(notify_db, notify_db_session, service_factory):
template_1 = service_factory.get('1').templates[0]
template_2 = service_factory.get('2').templates[0]
notification_history = functools.partial(
create_notification_history,
notify_db,
notify_db_session
)
last_sunday = notification_history(template_1, created_at=datetime(2016, 7, 24, 23, 59))
last_monday_morning = notification_history(template_2, created_at=datetime(2016, 7, 25, 0, 0))
with freeze_time('Wed 27th July 2016'):
ret = dao_fetch_weekly_historical_stats_for_service(template_1.service_id)
assert len(ret) == 1
assert ret[0].week_start == datetime(2016, 7, 18)
assert ret[0].count == 1
def test_fetch_weekly_historical_stats_separates_types(notify_db,
notify_db_session,
sample_template,
sample_email_template):
notification_history = functools.partial(
create_notification_history,
notify_db,
notify_db_session,
created_at=datetime(2016, 7, 25)
)
notification_history(sample_template)
notification_history(sample_email_template)
with freeze_time('Wed 27th July 2016'):
ret = dao_fetch_weekly_historical_stats_for_service(sample_template.service_id)
assert len(ret) == 2
assert ret[0].week_start == datetime(2016, 7, 25)
assert ret[0].count == 1
assert ret[0].notification_type == 'email'
assert ret[1].week_start == datetime(2016, 7, 25)
assert ret[1].count == 1
assert ret[1].notification_type == 'sms'

View File

@@ -1,5 +1,4 @@
import json import json
import collections
import uuid import uuid
import pytest import pytest
@@ -18,9 +17,6 @@ from tests.app.conftest import (
) )
Row = collections.namedtuple('row', ('notification_type', 'status', 'count'))
def test_get_service_list(notify_api, service_factory): def test_get_service_list(notify_api, service_factory):
with notify_api.test_request_context(): with notify_api.test_request_context():
with notify_api.test_client() as client: with notify_api.test_client() as client:
@@ -1127,37 +1123,27 @@ def test_get_detailed_service(notify_db, notify_db_session, notify_api, sample_s
assert service['statistics']['sms'] == stats assert service['statistics']['sms'] == stats
# email_counts and sms_counts are 3-tuple of requested, delivered, failed @freeze_time('2016-07-28')
@pytest.mark.idparametrize('stats, email_counts, sms_counts', { def test_get_weekly_notification_stats(notify_api, sample_notification):
'empty': ([], [0, 0, 0], [0, 0, 0]), with notify_api.test_request_context(), notify_api.test_client() as client:
'always_increment_requested': ([ resp = client.get(
Row('email', 'delivered', 1), '/service/{}/notifications/weekly'.format(sample_notification.service_id),
Row('email', 'failed', 1) headers=[create_authorization_header()]
], [2, 1, 1], [0, 0, 0]), )
'dont_mix_email_and_sms': ([
Row('email', 'delivered', 1),
Row('sms', 'delivered', 1)
], [1, 1, 0], [1, 1, 0]),
'convert_fail_statuses_to_failed': ([
Row('email', 'failed', 1),
Row('email', 'technical-failure', 1),
Row('email', 'temporary-failure', 1),
Row('email', 'permanent-failure', 1),
], [4, 0, 4], [0, 0, 0]),
})
def test_format_statistics(stats, email_counts, sms_counts):
from app.service.rest import format_statistics
ret = format_statistics(stats) assert resp.status_code == 200
data = json.loads(resp.get_data(as_text=True))['data']
assert ret['email'] == { assert data == {
status: count '2016-07-25': {
for status, count 'sms': {
in zip(['requested', 'delivered', 'failed'], email_counts) 'requested': 1,
} 'delivered': 0,
'failed': 0
assert ret['sms'] == { },
status: count 'email': {
for status, count 'requested': 0,
in zip(['requested', 'delivered', 'failed'], sms_counts) 'delivered': 0,
'failed': 0
}
}
} }

View File

@@ -0,0 +1,138 @@
from datetime import datetime
import collections
import pytest
from freezegun import freeze_time
from app.service.statistics import (
format_statistics,
_weeks_for_range,
_create_zeroed_stats_dicts,
format_weekly_notification_stats
)
StatsRow = collections.namedtuple('row', ('notification_type', 'status', 'count'))
WeeklyStatsRow = collections.namedtuple('row', ('notification_type', 'status', 'week_start', 'count'))
# email_counts and sms_counts are 3-tuple of requested, delivered, failed
@pytest.mark.idparametrize('stats, email_counts, sms_counts', {
'empty': ([], [0, 0, 0], [0, 0, 0]),
'always_increment_requested': ([
StatsRow('email', 'delivered', 1),
StatsRow('email', 'failed', 1)
], [2, 1, 1], [0, 0, 0]),
'dont_mix_email_and_sms': ([
StatsRow('email', 'delivered', 1),
StatsRow('sms', 'delivered', 1)
], [1, 1, 0], [1, 1, 0]),
'convert_fail_statuses_to_failed': ([
StatsRow('email', 'failed', 1),
StatsRow('email', 'technical-failure', 1),
StatsRow('email', 'temporary-failure', 1),
StatsRow('email', 'permanent-failure', 1),
], [4, 0, 4], [0, 0, 0]),
})
def test_format_statistics(stats, email_counts, sms_counts):
ret = format_statistics(stats)
assert ret['email'] == {
status: count
for status, count
in zip(['requested', 'delivered', 'failed'], email_counts)
}
assert ret['sms'] == {
status: count
for status, count
in zip(['requested', 'delivered', 'failed'], sms_counts)
}
@pytest.mark.parametrize('start,end,dates', [
(datetime(2016, 7, 25), datetime(2016, 7, 25), [datetime(2016, 7, 25)]),
(datetime(2016, 7, 25), datetime(2016, 7, 28), [datetime(2016, 7, 25)]),
(datetime(2016, 7, 25), datetime(2016, 8, 1), [datetime(2016, 7, 25), datetime(2016, 8, 1)]),
(datetime(2016, 7, 25), datetime(2016, 8, 10), [
datetime(2016, 7, 25), datetime(2016, 8, 1), datetime(2016, 8, 8)
])
])
def test_weeks_for_range(start, end, dates):
assert list(_weeks_for_range(start, end)) == dates
def test_create_zeroed_stats_dicts():
assert _create_zeroed_stats_dicts() == {
'sms': {'requested': 0, 'delivered': 0, 'failed': 0},
'email': {'requested': 0, 'delivered': 0, 'failed': 0},
}
def _stats(requested, delivered, failed):
return {'requested': requested, 'delivered': delivered, 'failed': failed}
@freeze_time('2016-07-28T12:00:00')
@pytest.mark.parametrize('created_at, statistics, expected_results', [
# with no stats and just today, return this week's stats
(datetime(2016, 7, 28), [], {
datetime(2016, 7, 25): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
}
}),
# with a random created time, still create the dict for midnight
(datetime(2016, 7, 28, 12, 13, 14), [], {
datetime(2016, 7, 25, 0, 0, 0): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
}
}),
# with no stats but a service
(datetime(2016, 7, 14), [], {
datetime(2016, 7, 11): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
},
datetime(2016, 7, 18): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
},
datetime(2016, 7, 25): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
}
}),
# two stats for same week dont re-zero each other
(datetime(2016, 7, 21), [
WeeklyStatsRow('email', 'created', datetime(2016, 7, 18), 1),
WeeklyStatsRow('sms', 'created', datetime(2016, 7, 18), 1),
], {
datetime(2016, 7, 18): {
'sms': _stats(1, 0, 0),
'email': _stats(1, 0, 0)
},
datetime(2016, 7, 25): {
'sms': _stats(0, 0, 0),
'email': _stats(0, 0, 0)
}
}),
# two stats for same type are added together
(datetime(2016, 7, 21), [
WeeklyStatsRow('sms', 'created', datetime(2016, 7, 18), 1),
WeeklyStatsRow('sms', 'delivered', datetime(2016, 7, 18), 1),
WeeklyStatsRow('sms', 'created', datetime(2016, 7, 25), 1),
], {
datetime(2016, 7, 18): {
'sms': _stats(2, 1, 0),
'email': _stats(0, 0, 0)
},
datetime(2016, 7, 25): {
'sms': _stats(1, 0, 0),
'email': _stats(0, 0, 0)
}
})
])
def test_format_weekly_notification_stats(statistics, created_at, expected_results):
assert format_weekly_notification_stats(statistics, created_at) == expected_results