Merge pull request #579 from alphagov/usage

get usage stats from notification_history
This commit is contained in:
Leo Hemsted
2016-08-05 10:22:08 +01:00
committed by GitHub
12 changed files with 291 additions and 217 deletions

View File

@@ -20,7 +20,7 @@ from notifications_utils.recipients import (
)
from app.dao.templates_dao import dao_get_template_by_id
from notifications_utils.template import Template
from notifications_utils.template import Template, get_sms_fragment_count
from notifications_utils.renderers import HTMLEmail, PlainTextEmail, SMSMessage
from app.models import SMS_TYPE, EMAIL_TYPE, KEY_TYPE_TEST
@@ -68,6 +68,7 @@ def send_sms_to_provider(self, service_id, notification_id):
send_sms_response.apply_async(
(provider.get_name(), str(notification_id), notification.to), queue='research-mode'
)
notification.billable_units = 0
else:
provider.send_sms(
to=validate_and_format_phone_number(notification.to),
@@ -75,17 +76,17 @@ def send_sms_to_provider(self, service_id, notification_id):
reference=str(notification_id),
sender=service.sms_sender
)
notification.billable_units = get_sms_fragment_count(template.replaced_content_count)
update_provider_stats(
notification_id,
SMS_TYPE,
provider.get_name(),
content_char_count=template.replaced_content_count
billable_units=notification.billable_units
)
notification.sent_at = datetime.utcnow()
notification.sent_by = provider.get_name(),
notification.content_char_count = template.replaced_content_count
notification.sent_by = provider.get_name()
notification.status = 'sending'
dao_update_notification(notification)
except SmsClientException as e:
@@ -163,7 +164,8 @@ def send_email_to_provider(self, service_id, notification_id):
update_provider_stats(
notification_id,
EMAIL_TYPE,
provider.get_name()
provider.get_name(),
billable_units=1
)
notification.reference = reference
notification.sent_at = datetime.utcnow()

View File

@@ -299,30 +299,22 @@ def update_provider_stats(
id_,
notification_type,
provider_name,
content_char_count=None):
billable_units=1):
notification = Notification.query.filter(Notification.id == id_).one()
provider = ProviderDetails.query.filter_by(identifier=provider_name).one()
def unit_count():
if notification_type == EMAIL_TYPE:
return 1
else:
if (content_char_count):
return get_sms_fragment_count(content_char_count)
return get_sms_fragment_count(notification.content_char_count)
update_count = db.session.query(ProviderStatistics).filter_by(
day=date.today(),
service_id=notification.service_id,
provider_id=provider.id
).update({'unit_count': ProviderStatistics.unit_count + unit_count()})
).update({'unit_count': ProviderStatistics.unit_count + billable_units})
if update_count == 0:
provider_stats = ProviderStatistics(
day=notification.created_at.date(),
service_id=notification.service_id,
provider_id=provider.id,
unit_count=unit_count()
unit_count=billable_units
)
db.session.add(provider_stats)

View File

@@ -1,42 +1,47 @@
from sqlalchemy import func
from app.models import (ProviderStatistics, SMS_PROVIDERS, EMAIL_PROVIDERS, ProviderDetails)
from sqlalchemy import func, cast, Float, case
from app import db
from app.models import (
ProviderStatistics,
ProviderDetails,
NotificationHistory,
SMS_TYPE,
EMAIL_TYPE,
NOTIFICATION_STATUS_TYPES_BILLABLE,
KEY_TYPE_TEST
)
def get_provider_statistics(service, **kwargs):
return filter_query(ProviderStatistics.query, service, **kwargs)
def get_fragment_count(service, date_from, date_to):
sms_query = filter_query(
ProviderStatistics.query,
service,
providers=SMS_PROVIDERS,
date_from=date_from,
date_to=date_to
)
email_query = filter_query(
ProviderStatistics.query,
service,
providers=EMAIL_PROVIDERS,
date_from=date_from,
date_to=date_to
)
return {
'sms_count': int(sms_query.with_entities(
func.sum(ProviderStatistics.unit_count)).scalar()) if sms_query.count() > 0 else 0,
'email_count': int(email_query.with_entities(
func.sum(ProviderStatistics.unit_count)).scalar()) if email_query.count() > 0 else 0
}
def filter_query(query, service, **kwargs):
query = query.filter_by(service=service)
query = ProviderStatistics.query.filter_by(service=service)
if 'providers' in kwargs:
providers = ProviderDetails.query.filter(ProviderDetails.identifier.in_(kwargs['providers'])).all()
provider_ids = [provider.id for provider in providers]
query = query.filter(ProviderStatistics.provider_id.in_(provider_ids))
if 'date_from' in kwargs:
query.filter(ProviderStatistics.day >= kwargs['date_from'])
if 'date_to' in kwargs:
query.filter(ProviderStatistics.day <= kwargs['date_to'])
return query
def get_fragment_count(service_id):
shared_filters = [
NotificationHistory.service_id == service_id,
NotificationHistory.status.in_(NOTIFICATION_STATUS_TYPES_BILLABLE),
NotificationHistory.key_type != KEY_TYPE_TEST
]
sms_count = db.session.query(
func.sum(NotificationHistory.billable_units)
).filter(
NotificationHistory.notification_type == SMS_TYPE,
*shared_filters
)
email_count = db.session.query(
func.count(NotificationHistory.id)
).filter(
NotificationHistory.notification_type == EMAIL_TYPE,
*shared_filters
)
return {
'sms_count': int(sms_count.scalar() or 0),
'email_count': email_count.scalar() or 0
}

View File

@@ -329,9 +329,34 @@ class VerifyCode(db.Model):
def check_code(self, cde):
return check_hash(cde, self._code)
NOTIFICATION_CREATED = 'created'
NOTIFICATION_SENDING = 'sending'
NOTIFICATION_DELIVERED = 'delivered'
NOTIFICATION_PENDING = 'pending'
NOTIFICATION_FAILED = 'failed'
NOTIFICATION_TECHNICAL_FAILURE = 'technical-failure'
NOTIFICATION_TEMPORARY_FAILURE = 'temporary-failure'
NOTIFICATION_PERMANENT_FAILURE = 'permanent-failure'
NOTIFICATION_STATUS_TYPES = ['created', 'sending', 'delivered', 'pending', 'failed',
'technical-failure', 'temporary-failure', 'permanent-failure']
NOTIFICATION_STATUS_TYPES_BILLABLE = [
NOTIFICATION_SENDING,
NOTIFICATION_DELIVERED,
NOTIFICATION_FAILED,
NOTIFICATION_TECHNICAL_FAILURE,
NOTIFICATION_TEMPORARY_FAILURE,
NOTIFICATION_PERMANENT_FAILURE
]
NOTIFICATION_STATUS_TYPES = [
NOTIFICATION_CREATED,
NOTIFICATION_SENDING,
NOTIFICATION_DELIVERED,
NOTIFICATION_PENDING,
NOTIFICATION_FAILED,
NOTIFICATION_TECHNICAL_FAILURE,
NOTIFICATION_TEMPORARY_FAILURE,
NOTIFICATION_PERMANENT_FAILURE
]
NOTIFICATION_STATUS_TYPES_ENUM = db.Enum(*NOTIFICATION_STATUS_TYPES, name='notify_status_type')
@@ -352,11 +377,11 @@ class Notification(db.Model):
api_key_id = db.Column(UUID(as_uuid=True), db.ForeignKey('api_keys.id'), index=True, unique=False)
api_key = db.relationship('ApiKey')
key_type = db.Column(db.String, db.ForeignKey('key_types.name'), index=True, unique=False, nullable=False)
content_char_count = db.Column(db.Integer, nullable=True)
notification_type = db.Column(notification_types, nullable=False)
billable_units = db.Column(db.Integer, nullable=False, default=0)
notification_type = db.Column(notification_types, index=True, nullable=False)
created_at = db.Column(
db.DateTime,
index=False,
index=True,
unique=False,
nullable=False)
sent_at = db.Column(
@@ -371,7 +396,7 @@ class Notification(db.Model):
unique=False,
nullable=True,
onupdate=datetime.datetime.utcnow)
status = db.Column(NOTIFICATION_STATUS_TYPES_ENUM, nullable=False, default='created')
status = db.Column(NOTIFICATION_STATUS_TYPES_ENUM, index=True, nullable=False, default='created')
reference = db.Column(db.String, nullable=True, index=True)
_personalisation = db.Column(db.String, nullable=True)
@@ -402,13 +427,13 @@ class NotificationHistory(db.Model):
api_key_id = db.Column(UUID(as_uuid=True), db.ForeignKey('api_keys.id'), index=True, unique=False)
api_key = db.relationship('ApiKey')
key_type = db.Column(db.String, db.ForeignKey('key_types.name'), index=True, unique=False, nullable=False)
content_char_count = db.Column(db.Integer, nullable=True)
notification_type = db.Column(notification_types, nullable=False)
created_at = db.Column(db.DateTime, index=False, unique=False, nullable=False)
billable_units = db.Column(db.Integer, nullable=False, default=0)
notification_type = db.Column(notification_types, index=True, nullable=False)
created_at = db.Column(db.DateTime, index=True, unique=False, nullable=False)
sent_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
sent_by = db.Column(db.String, nullable=True)
updated_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
status = db.Column(NOTIFICATION_STATUS_TYPES_ENUM, nullable=False, default='created')
status = db.Column(NOTIFICATION_STATUS_TYPES_ENUM, index=True, nullable=False, default='created')
reference = db.Column(db.String, nullable=True, index=True)
@classmethod

View File

@@ -168,13 +168,7 @@ def remove_user_from_service(service_id, user_id):
@service.route('/<uuid:service_id>/fragment/aggregate_statistics')
def get_service_provider_aggregate_statistics(service_id):
service = dao_fetch_service_by_id(service_id)
data = from_to_date_schema.load(request.args).data
return jsonify(data=get_fragment_count(
service,
date_from=(data.pop('date_from') if 'date_from' in data else date.today()),
date_to=(data.pop('date_to') if 'date_to' in data else date.today())
))
return jsonify(data=get_fragment_count(service_id))
# This is placeholder get method until more thought

View File

@@ -1,4 +1,4 @@
from flask import jsonify, current_app, Blueprint
from flask import jsonify, Blueprint
from apispec import APISpec
@@ -11,7 +11,7 @@ api_spec = APISpec(
)
api_spec.definition('NotificationWithTemplateSchema', properties={
"content_char_count": {
"billable_units": {
"format": "int32",
"type": "integer"
},

View File

@@ -0,0 +1,101 @@
"""empty message
Revision ID: 0045_billable_units
Revises: 0044_jobs_to_notification_hist
Create Date: 2016-08-02 16:36:42.455838
"""
# revision identifiers, used by Alembic.
revision = '0045_billable_units'
down_revision = '0044_jobs_to_notification_hist'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm.session import Session
from app.models import Service
def upgrade():
op.add_column('notifications', sa.Column('billable_units', sa.Integer()))
op.add_column('notification_history', sa.Column('billable_units', sa.Integer()))
op.execute('update notifications set billable_units = 0')
op.execute('update notification_history set billable_units = 0')
op.alter_column('notifications', 'billable_units', nullable=False)
op.alter_column('notification_history', 'billable_units', nullable=False)
conn = op.get_bind()
# caveats
# only adjusts notifications for services that have never been in research mode. On live, research mode was
# limited to only services that we have set up ourselves so deemed this acceptable.
billable_services = conn.execute('''
SELECT id FROM services_history WHERE id not in (select id from services_history where research_mode)
''')
# set to 'null' if there are no billable services so we don't get a syntax error in the update statement
service_ids = ','.join("'{}'".format(service.id) for service in billable_services) or 'null'
update_statement = '''
UPDATE {}
SET billable_units = (
CASE
WHEN content_char_count <= 160 THEN 1
ELSE ceil(content_char_count::float / 153::float)
END
)
WHERE content_char_count is not null
AND service_id in ({})
AND notification_type = 'sms'
'''
conn = op.get_bind()
conn.execute(update_statement.format('notifications', service_ids))
conn.execute(update_statement.format('notification_history', service_ids))
op.drop_column('notifications', 'content_char_count')
op.drop_column('notification_history', 'content_char_count')
def downgrade():
op.add_column('notifications', sa.Column(
'content_char_count',
sa.INTEGER(),
autoincrement=False,
nullable=True)
)
op.add_column('notification_history', sa.Column(
'content_char_count',
sa.INTEGER(),
autoincrement=False,
nullable=True)
)
conn = op.get_bind()
# caveats
# only adjusts notifications for services that have never been in research mode. On live, research mode was
# limited to only services that we have set up ourselves
billable_services = conn.execute('''
SELECT id FROM services_history WHERE id not in (select id from services_history where research_mode)
''')
# set to 'null' if there are no billable services so we don't get a syntax error in the update statement
service_ids = ','.join("'{}'".format(service.id) for service in billable_services) or 'null'
# caveats:
# only approximates character counts - billable * 153 to get at least a decent ballpark
# research mode messages assumed to be one message length
update_statement = '''
UPDATE {}
SET content_char_count = GREATEST(billable_units, 1) * 150
WHERE service_id in ({})
AND notification_type = 'sms'
'''
conn = op.get_bind()
conn.execute(update_statement.format('notifications', service_ids))
conn.execute(update_statement.format('notification_history', service_ids))
op.drop_column('notifications', 'billable_units')
op.drop_column('notification_history', 'billable_units')

View File

@@ -111,7 +111,7 @@ def test_should_send_personalised_template_to_correct_sms_provider_and_persist(
assert notification.status == 'sending'
assert notification.sent_at <= datetime.utcnow()
assert notification.sent_by == 'mmg'
assert notification.content_char_count == len("Sample service: Hello Jo\nYour thing is due soon")
assert notification.billable_units == 1
assert notification.personalisation == {"name": "Jo"}
@@ -194,7 +194,6 @@ def test_send_sms_should_use_template_version_from_notification_not_latest(
assert persisted_notification.template_id == sample_template.id
assert persisted_notification.template_version == version_on_notification
assert persisted_notification.template_version != sample_template.version
assert persisted_notification.content_char_count == len("Sample service: This is a template:\nwith a newline")
assert persisted_notification.status == 'sending'
assert not persisted_notification.personalisation
@@ -546,3 +545,30 @@ def test_send_email_should_use_service_reply_to_email(
html_body=ANY,
reply_to_address=sample_service.reply_to_email_address
)
def test_should_not_set_billable_units_if_research_mode(notify_db, sample_service, sample_notification, mocker):
mocker.patch('app.mmg_client.send_sms')
mocker.patch('app.mmg_client.get_name', return_value="mmg")
mocker.patch('app.celery.research_mode_tasks.send_sms_response.apply_async')
sample_service.research_mode = True
notify_db.session.add(sample_service)
notify_db.session.commit()
send_sms_to_provider(
sample_notification.service_id,
sample_notification.id
)
persisted_notification = notifications_dao.get_notification(sample_service.id, sample_notification.id)
assert persisted_notification.billable_units == 0
def _get_provider_statistics(service, **kwargs):
query = ProviderStatistics.query.filter_by(service=service)
if 'providers' in kwargs:
providers = ProviderDetails.query.filter(ProviderDetails.identifier.in_(kwargs['providers'])).all()
provider_ids = [provider.id for provider in providers]
query = query.filter(ProviderStatistics.provider_id.in_(provider_ids))
return query

View File

@@ -323,7 +323,7 @@ def sample_notification(notify_db,
status='created',
reference=None,
created_at=None,
content_char_count=160,
billable_units=1,
create=True,
personalisation=None,
api_key_id=None,
@@ -356,7 +356,7 @@ def sample_notification(notify_db,
'status': status,
'reference': reference,
'created_at': created_at,
'content_char_count': content_char_count,
'billable_units': billable_units,
'personalisation': personalisation,
'notification_type': template.template_type,
'api_key_id': api_key_id,

View File

@@ -1028,7 +1028,7 @@ def _notification_json(sample_template, job_id=None, id=None, status=None):
'template_id': sample_template.id,
'template_version': sample_template.version,
'created_at': datetime.utcnow(),
'content_char_count': 160,
'billable_units': 1,
'notification_type': sample_template.template_type,
'key_type': KEY_TYPE_NORMAL
}

View File

@@ -1,8 +1,11 @@
from datetime import (date, timedelta)
from app.models import ProviderStatistics
from datetime import datetime
import uuid
import pytest
from app.models import NotificationHistory, KEY_TYPE_NORMAL, KEY_TYPE_TEAM, KEY_TYPE_TEST, NOTIFICATION_STATUS_TYPES
from app.dao.notifications_dao import update_provider_stats
from app.dao.provider_statistics_dao import (
get_provider_statistics, get_fragment_count)
from app.dao.provider_statistics_dao import get_provider_statistics, get_fragment_count
from tests.app.conftest import sample_notification as create_sample_notification
@@ -44,24 +47,24 @@ def test_should_update_provider_statistics_sms_multi(notify_db,
notify_db,
notify_db_session,
template=sample_template,
content_char_count=160)
update_provider_stats(n1.id, 'sms', mmg_provider.identifier)
billable_units=1)
update_provider_stats(n1.id, 'sms', mmg_provider.identifier, n1.billable_units)
n2 = create_sample_notification(
notify_db,
notify_db_session,
template=sample_template,
content_char_count=161)
update_provider_stats(n2.id, 'sms', mmg_provider.identifier)
billable_units=2)
update_provider_stats(n2.id, 'sms', mmg_provider.identifier, n2.billable_units)
n3 = create_sample_notification(
notify_db,
notify_db_session,
template=sample_template,
content_char_count=307)
update_provider_stats(n3.id, 'sms', mmg_provider.identifier)
billable_units=4)
update_provider_stats(n3.id, 'sms', mmg_provider.identifier, n3.billable_units)
provider_stats = get_provider_statistics(
sample_template.service,
providers=[mmg_provider.identifier]).one()
assert provider_stats.unit_count == 6
assert provider_stats.unit_count == 7
def test_should_update_provider_statistics_email_multi(notify_db,
@@ -89,63 +92,66 @@ def test_should_update_provider_statistics_email_multi(notify_db,
assert provider_stats.unit_count == 3
def test_should_aggregate_fragment_count(notify_db,
notify_db_session,
sample_service,
mmg_provider,
firetext_provider,
ses_provider):
day = date.today()
stats_mmg = ProviderStatistics(
service=sample_service,
day=day,
provider_id=mmg_provider.id,
unit_count=2
)
def test_get_fragment_count_with_no_data(sample_template):
assert get_fragment_count(sample_template.service_id)['sms_count'] == 0
assert get_fragment_count(sample_template.service_id)['email_count'] == 0
stats_firetext = ProviderStatistics(
service=sample_service,
day=day,
provider_id=firetext_provider.id,
unit_count=3
)
stats_ses = ProviderStatistics(
service=sample_service,
day=day,
provider_id=ses_provider.id,
unit_count=1
def test_get_fragment_count_separates_sms_and_email(notify_db, sample_template, sample_email_template):
noti_hist(notify_db, sample_template)
noti_hist(notify_db, sample_template)
noti_hist(notify_db, sample_email_template)
assert get_fragment_count(sample_template.service_id) == {
'sms_count': 2,
'email_count': 1
}
def test_get_fragment_count_filters_on_status(notify_db, sample_template):
for status in NOTIFICATION_STATUS_TYPES:
noti_hist(notify_db, sample_template, status=status)
# sending, delivered, failed, technical-failure, temporary-failure, permanent-failure
assert get_fragment_count(sample_template.service_id)['sms_count'] == 6
def test_get_fragment_count_filters_on_service_id(notify_db, sample_template, service_factory):
service_2 = service_factory.get('service 2', email_from='service.2')
noti_hist(notify_db, sample_template)
assert get_fragment_count(service_2.id)['sms_count'] == 0
def test_get_fragment_count_sums_billable_units_for_sms(notify_db, sample_template):
noti_hist(notify_db, sample_template, billable_units=1)
noti_hist(notify_db, sample_template, billable_units=2)
assert get_fragment_count(sample_template.service_id)['sms_count'] == 3
@pytest.mark.parametrize('key_type,sms_count', [
(KEY_TYPE_NORMAL, 1),
(KEY_TYPE_TEAM, 1),
(KEY_TYPE_TEST, 0),
])
def test_get_fragment_count_ignores_test_api_keys(notify_db, sample_template, key_type, sms_count):
noti_hist(notify_db, sample_template, key_type=key_type)
assert get_fragment_count(sample_template.service_id)['sms_count'] == sms_count
def noti_hist(notify_db, template, status='delivered', billable_units=None, key_type=KEY_TYPE_NORMAL):
if not billable_units and template.template_type == 'sms':
billable_units = 1
notification_history = NotificationHistory(
id=uuid.uuid4(),
service=template.service,
template=template,
template_version=template.version,
status=status,
created_at=datetime.utcnow(),
billable_units=billable_units,
notification_type=template.template_type,
key_type=key_type
)
notify_db.session.add(stats_mmg)
notify_db.session.add(stats_firetext)
notify_db.session.add(stats_ses)
notify_db.session.add(notification_history)
notify_db.session.commit()
results = get_fragment_count(sample_service, day, day)
assert results['sms_count'] == 5
assert results['email_count'] == 1
def test_should_aggregate_fragment_count_over_days(notify_db,
notify_db_session,
sample_service,
mmg_provider):
today = date.today()
yesterday = today - timedelta(days=1)
stats_today = ProviderStatistics(
service=sample_service,
day=today,
provider_id=mmg_provider.id,
unit_count=2
)
stats_yesterday = ProviderStatistics(
service=sample_service,
day=yesterday,
provider_id=mmg_provider.id,
unit_count=3
)
notify_db.session.add(stats_today)
notify_db.session.add(stats_yesterday)
notify_db.session.commit()
results = get_fragment_count(sample_service, yesterday, today)
assert results['sms_count'] == 5
assert results['email_count'] == 0
return notification_history

View File

@@ -1,77 +0,0 @@
import json
from datetime import (date, timedelta)
from flask import url_for
from tests import create_authorization_header
def test_fragment_count(notify_api, sample_provider_statistics):
with notify_api.test_request_context():
with notify_api.test_client() as client:
endpoint = url_for(
'service.get_service_provider_aggregate_statistics',
service_id=str(sample_provider_statistics.service.id))
auth_header = create_authorization_header()
resp = client.get(
endpoint,
headers=[auth_header]
)
assert resp.status_code == 200
json_resp = json.loads(resp.get_data(as_text=True))
assert json_resp['data']['sms_count'] == 1
def test_fragment_count_from_to(notify_api, sample_provider_statistics):
with notify_api.test_request_context():
with notify_api.test_client() as client:
today_str = date.today().strftime('%Y-%m-%d')
endpoint = url_for(
'service.get_service_provider_aggregate_statistics',
service_id=str(sample_provider_statistics.service.id),
date_from=today_str,
date_to=today_str)
auth_header = create_authorization_header()
resp = client.get(
endpoint,
headers=[auth_header]
)
assert resp.status_code == 200
json_resp = json.loads(resp.get_data(as_text=True))
assert json_resp['data']['sms_count'] == 1
def test_fragment_count_from_greater_than_to(notify_api, sample_provider_statistics):
with notify_api.test_request_context():
with notify_api.test_client() as client:
today_str = date.today().strftime('%Y-%m-%d')
yesterday_str = date.today() - timedelta(days=1)
endpoint = url_for(
'service.get_service_provider_aggregate_statistics',
service_id=str(sample_provider_statistics.service.id),
date_from=today_str,
date_to=yesterday_str)
auth_header = create_authorization_header()
resp = client.get(
endpoint,
headers=[auth_header]
)
assert resp.status_code == 400
json_resp = json.loads(resp.get_data(as_text=True))
assert 'date_from needs to be greater than date_to' in json_resp['message']['_schema']
def test_fragment_count_in_future(notify_api, sample_provider_statistics):
with notify_api.test_request_context():
with notify_api.test_client() as client:
tomorrow_str = (date.today() + timedelta(days=1)).strftime('%Y-%m-%d')
endpoint = url_for(
'service.get_service_provider_aggregate_statistics',
service_id=str(sample_provider_statistics.service.id),
date_from=tomorrow_str)
auth_header = create_authorization_header()
resp = client.get(
endpoint,
headers=[auth_header]
)
assert resp.status_code == 400
json_resp = json.loads(resp.get_data(as_text=True))
assert 'Date cannot be in the future' in json_resp['message']['date_from']