diff --git a/app/dao/fact_billing_dao.py b/app/dao/fact_billing_dao.py index ff8783907..b7895d693 100644 --- a/app/dao/fact_billing_dao.py +++ b/app/dao/fact_billing_dao.py @@ -2,7 +2,7 @@ from datetime import date, datetime, timedelta from flask import current_app from notifications_utils.timezones import convert_utc_to_bst -from sqlalchemy import Date, Integer, and_, desc, func +from sqlalchemy import Date, Integer, Numeric, and_, desc, func from sqlalchemy.dialects.postgresql import insert from sqlalchemy.sql.expression import case, literal @@ -716,3 +716,151 @@ def fetch_billing_details_for_all_services(): ).all() return billing_details + + +def fetch_daily_volumes_for_platform(start_date, end_date): + # query to return the total notifications sent per day for each channel. NB start and end dates are inclusive + + daily_volume_stats = db.session.query( + FactBilling.bst_date, + func.sum(case( + [ + (FactBilling.notification_type == SMS_TYPE, FactBilling.notifications_sent) + ], else_=0 + )).label('sms_totals'), + func.sum(case( + [ + (FactBilling.notification_type == SMS_TYPE, FactBilling.billable_units) + ], else_=0 + )).label('sms_fragment_totals'), + func.sum(case( + [ + (FactBilling.notification_type == SMS_TYPE, FactBilling.billable_units * FactBilling.rate_multiplier) + ], else_=0 + )).label('sms_fragments_times_multiplier'), + func.sum(case( + [ + (FactBilling.notification_type == EMAIL_TYPE, FactBilling.notifications_sent) + ], else_=0 + )).label('email_totals'), + func.sum(case( + [ + (FactBilling.notification_type == LETTER_TYPE, FactBilling.notifications_sent) + ], else_=0 + )).label('letter_totals'), + func.sum(case( + [ + (FactBilling.notification_type == LETTER_TYPE, FactBilling.billable_units) + ], else_=0 + )).label('letter_sheet_totals') + ).filter( + FactBilling.bst_date >= start_date, + FactBilling.bst_date <= end_date + ).group_by( + FactBilling.bst_date, + FactBilling.notification_type + ).subquery() + + aggregated_totals = db.session.query( + daily_volume_stats.c.bst_date.cast(db.Text).label('bst_date'), + func.sum(daily_volume_stats.c.sms_totals).cast(Integer).label('sms_totals'), + func.sum(daily_volume_stats.c.sms_fragment_totals).cast(Integer).label('sms_fragment_totals'), + func.sum( + daily_volume_stats.c.sms_fragments_times_multiplier).cast(Integer).label('sms_chargeable_units'), + func.sum(daily_volume_stats.c.email_totals).cast(Integer).label('email_totals'), + func.sum(daily_volume_stats.c.letter_totals).cast(Integer).label('letter_totals'), + func.sum(daily_volume_stats.c.letter_sheet_totals).cast(Integer).label('letter_sheet_totals') + ).group_by( + daily_volume_stats.c.bst_date + ).order_by( + daily_volume_stats.c.bst_date + ).all() + + return aggregated_totals + + +def fetch_volumes_by_service(start_date, end_date): + # query to return the volume totals by service aggregated for the date range given + # start and end dates are inclusive. + year_end_date = int(end_date.strftime('%Y')) + + volume_stats = db.session.query( + FactBilling.bst_date, + FactBilling.service_id, + func.sum(case([ + (FactBilling.notification_type == SMS_TYPE, FactBilling.notifications_sent) + ], else_=0)).label('sms_totals'), + func.sum(case([ + (FactBilling.notification_type == SMS_TYPE, FactBilling.billable_units * FactBilling.rate_multiplier) + ], else_=0)).label('sms_fragments_times_multiplier'), + func.sum(case([ + (FactBilling.notification_type == EMAIL_TYPE, FactBilling.notifications_sent) + ], else_=0)).label('email_totals'), + func.sum(case([ + (FactBilling.notification_type == LETTER_TYPE, FactBilling.notifications_sent) + ], else_=0)).label('letter_totals'), + func.sum(case([ + (FactBilling.notification_type == LETTER_TYPE, FactBilling.notifications_sent * FactBilling.rate) + ], else_=0)).label("letter_cost"), + func.sum(case( + [ + (FactBilling.notification_type == LETTER_TYPE, FactBilling.billable_units) + ], else_=0 + )).label('letter_sheet_totals') + ).filter( + FactBilling.bst_date >= start_date, + FactBilling.bst_date <= end_date + ).group_by( + FactBilling.bst_date, + FactBilling.service_id, + FactBilling.notification_type + ).subquery() + + annual_billing = db.session.query( + func.max(AnnualBilling.financial_year_start).label('financial_year_start'), + AnnualBilling.service_id, + AnnualBilling.free_sms_fragment_limit + ).filter( + AnnualBilling.financial_year_start <= year_end_date + ).group_by( + AnnualBilling.service_id, + AnnualBilling.free_sms_fragment_limit + ).subquery() + + results = db.session.query( + Service.name.label("service_name"), + Service.id.label("service_id"), + Service.organisation_id.label("organisation_id"), + Organisation.name.label("organisation_name"), + annual_billing.c.free_sms_fragment_limit.cast(Integer).label("free_allowance"), + func.coalesce(func.sum(volume_stats.c.sms_totals), 0).cast(Integer).label("sms_notifications"), + func.coalesce(func.sum(volume_stats.c.sms_fragments_times_multiplier), 0 + ).cast(Integer).label("sms_chargeable_units"), + func.coalesce(func.sum(volume_stats.c.email_totals), 0).cast(Integer).label("email_totals"), + func.coalesce(func.sum(volume_stats.c.letter_totals), 0).cast(Integer).label("letter_totals"), + func.coalesce(func.sum(volume_stats.c.letter_cost), 0).cast(Numeric).label("letter_cost"), + func.coalesce(func.sum(volume_stats.c.letter_sheet_totals), 0).cast(Integer).label("letter_sheet_totals") + ).select_from( + Service + ).outerjoin( + Organisation, Service.organisation_id == Organisation.id + ).join( + annual_billing, Service.id == annual_billing.c.service_id + ).outerjoin( # include services without volume + volume_stats, Service.id == volume_stats.c.service_id + ).filter( + Service.restricted.is_(False), + Service.count_as_live.is_(True), + Service.active.is_(True) + ).group_by( + Service.id, + Service.name, + Service.organisation_id, + Organisation.name, + annual_billing.c.free_sms_fragment_limit + ).order_by( + Organisation.name, + Service.name, + ).all() + + return results diff --git a/app/platform_stats/rest.py b/app/platform_stats/rest.py index 92be84f6b..d43ffe005 100644 --- a/app/platform_stats/rest.py +++ b/app/platform_stats/rest.py @@ -5,9 +5,11 @@ from flask import Blueprint, jsonify, request from app.dao.date_util import get_financial_year_for_datetime from app.dao.fact_billing_dao import ( fetch_billing_details_for_all_services, + fetch_daily_volumes_for_platform, fetch_letter_costs_and_totals_for_all_services, fetch_letter_line_items_for_all_services, fetch_sms_billing_for_all_services, + fetch_volumes_by_service, ) from app.dao.fact_notification_status_dao import ( fetch_notification_status_totals_for_all_services, @@ -40,12 +42,17 @@ def get_platform_stats(): return jsonify(stats) -def validate_date_range_is_within_a_financial_year(start_date, end_date): +def validate_date_format(date_to_validate): try: - start_date = datetime.strptime(start_date, "%Y-%m-%d").date() - end_date = datetime.strptime(end_date, "%Y-%m-%d").date() + validated_date = datetime.strptime(date_to_validate, "%Y-%m-%d").date() except ValueError: raise InvalidRequest(message="Input must be a date in the format: YYYY-MM-DD", status_code=400) + return validated_date + + +def validate_date_range_is_within_a_financial_year(start_date, end_date): + start_date = validate_date_format(start_date) + end_date = validate_date_format(end_date) if end_date < start_date: raise InvalidRequest(message="Start date must be before end date", status_code=400) @@ -133,6 +140,53 @@ def get_data_for_billing_report(): return jsonify(result) +@platform_stats_blueprint.route('daily-volumes-report') +def daily_volumes_report(): + start_date = validate_date_format(request.args.get('start_date')) + end_date = validate_date_format(request.args.get('end_date')) + + daily_volumes = fetch_daily_volumes_for_platform(start_date, end_date) + report = [] + + for row in daily_volumes: + report.append({ + "day": row.bst_date, + "sms_totals": row.sms_totals, + "sms_fragment_totals": row.sms_fragment_totals, + "sms_chargeable_units": row.sms_chargeable_units, + "email_totals": row.email_totals, + "letter_totals": row.letter_totals, + "letter_sheet_totals": row.letter_sheet_totals + }) + return jsonify(report) + + +@platform_stats_blueprint.route('volumes-by-service') +def volumes_by_service_report(): + start_date = validate_date_format(request.args.get('start_date')) + end_date = validate_date_format(request.args.get('end_date')) + + volumes_by_service = fetch_volumes_by_service(start_date, end_date) + report = [] + + for row in volumes_by_service: + report.append({ + "service_name": row.service_name, + "service_id": str(row.service_id), + "organisation_name": row.organisation_name if row.organisation_name else '', + "organisation_id": str(row.organisation_id) if row.organisation_id else '', + "free_allowance": row.free_allowance, + "sms_notifications": row.sms_notifications, + "sms_chargeable_units": row.sms_chargeable_units, + "email_totals": row.email_totals, + "letter_totals": row.letter_totals, + "letter_sheet_totals": row.letter_sheet_totals, + "letter_cost": float(row.letter_cost), + }) + + return jsonify(report) + + def postage_description(postage): if postage in UK_POSTAGE_TYPES: return f'{postage} class' diff --git a/tests/app/dao/test_ft_billing_dao.py b/tests/app/dao/test_ft_billing_dao.py index 6aff387d8..a1945cdfa 100644 --- a/tests/app/dao/test_ft_billing_dao.py +++ b/tests/app/dao/test_ft_billing_dao.py @@ -10,12 +10,14 @@ from app.dao.fact_billing_dao import ( delete_billing_data_for_service_for_day, fetch_billing_data_for_day, fetch_billing_totals_for_year, + fetch_daily_volumes_for_platform, fetch_letter_costs_and_totals_for_all_services, fetch_letter_line_items_for_all_services, fetch_monthly_billing_for_year, fetch_sms_billing_for_all_services, fetch_sms_free_allowance_remainder_until_date, fetch_usage_year_for_organisation, + fetch_volumes_by_service, get_rate, get_rates_for_billing, ) @@ -812,3 +814,90 @@ def test_fetch_usage_year_for_organisation_only_returns_data_for_live_services(n assert len(results) == 1 assert results[str(live_service.id)]['sms_billable_units'] == 19 assert results[str(live_service.id)]['emails_sent'] == 0 + + +def test_fetch_daily_volumes_for_platform( + notify_db_session, sample_template, sample_email_template, sample_letter_template +): + create_ft_billing(bst_date='2022-02-03', template=sample_template, + notifications_sent=10, billable_unit=10) + create_ft_billing(bst_date='2022-02-03', template=sample_template, + notifications_sent=10, billable_unit=30, international=True) + create_ft_billing(bst_date='2022-02-03', template=sample_email_template, notifications_sent=10) + create_ft_billing(bst_date='2022-02-03', template=sample_letter_template, notifications_sent=5, + billable_unit=5, rate=0.39) + create_ft_billing(bst_date='2022-02-03', template=sample_letter_template, notifications_sent=5, + billable_unit=10, rate=0.44) + + create_ft_billing(bst_date='2022-02-04', template=sample_template, + notifications_sent=20, billable_unit=40) + create_ft_billing(bst_date='2022-02-04', template=sample_template, + notifications_sent=10, billable_unit=20, rate_multiplier=3) + create_ft_billing(bst_date='2022-02-04', template=sample_email_template, notifications_sent=50) + create_ft_billing(bst_date='2022-02-04', template=sample_letter_template, notifications_sent=20, billable_unit=40) + + results = fetch_daily_volumes_for_platform(start_date='2022-02-03', end_date='2022-02-04') + + assert len(results) == 2 + assert results[0].bst_date == '2022-02-03' + assert results[0].sms_totals == 20 + assert results[0].sms_fragment_totals == 40 + assert results[0].sms_chargeable_units == 40 + assert results[0].email_totals == 10 + assert results[0].letter_totals == 10 + assert results[0].letter_sheet_totals == 15 + + assert results[1].bst_date == '2022-02-04' + assert results[1].sms_totals == 30 + assert results[1].sms_fragment_totals == 60 + assert results[1].sms_chargeable_units == 100 + assert results[1].email_totals == 50 + assert results[1].letter_totals == 20 + assert results[1].letter_sheet_totals == 40 + + +def test_fetch_volumes_by_service(notify_db_session): + set_up_usage_data(datetime(2022, 2, 1)) + + results = fetch_volumes_by_service(start_date=datetime(2022, 2, 1), end_date=datetime(2022, 2, 28)) + + assert len(results) == 4 + assert results[0].service_name == 'a - with sms and letter' + assert results[0].organisation_name == 'Org for a - with sms and letter' + assert results[0].free_allowance == 10 + assert results[0].sms_notifications == 2 + assert results[0].sms_chargeable_units == 3 + assert results[0].email_totals == 0 + assert results[0].letter_totals == 4 + assert results[0].letter_sheet_totals == 6 + assert float(results[0].letter_cost) == 1.6 + + assert results[1].service_name == 'f - without ft_billing' + assert results[1].organisation_name == 'Org for a - with sms and letter' + assert results[1].free_allowance == 10 + assert results[1].sms_notifications == 0 + assert results[1].sms_chargeable_units == 0 + assert results[1].email_totals == 0 + assert results[1].letter_totals == 0 + assert results[1].letter_sheet_totals == 0 + assert float(results[1].letter_cost) == 0 + + assert results[2].service_name == 'b - chargeable sms' + assert not results[2].organisation_name + assert results[2].free_allowance == 10 + assert results[2].sms_notifications == 2 + assert results[2].sms_chargeable_units == 3 + assert results[2].email_totals == 0 + assert results[2].letter_totals == 0 + assert results[2].letter_sheet_totals == 0 + assert float(results[2].letter_cost) == 0 + + assert results[3].service_name == 'e - sms within allowance' + assert not results[3].organisation_name + assert results[3].free_allowance == 10 + assert results[3].sms_notifications == 1 + assert results[3].sms_chargeable_units == 2 + assert results[3].email_totals == 0 + assert results[3].letter_totals == 0 + assert results[3].letter_sheet_totals == 0 + assert float(results[3].letter_cost) == 0 diff --git a/tests/app/db.py b/tests/app/db.py index a7e412806..f5b91f7d7 100644 --- a/tests/app/db.py +++ b/tests/app/db.py @@ -979,11 +979,11 @@ def set_up_usage_data(start_date): create_ft_billing(bst_date=two_days_later, template=sms_template_1, billable_unit=1, rate=0.11) create_ft_billing(bst_date=one_week_later, template=letter_template_1, - notifications_sent=2, billable_unit=1, rate=.35, postage='first') + notifications_sent=2, billable_unit=2, rate=.35, postage='first') create_ft_billing(bst_date=one_month_later, template=letter_template_1, - notifications_sent=4, billable_unit=2, rate=.45, postage='second') + notifications_sent=4, billable_unit=8, rate=.45, postage='second') create_ft_billing(bst_date=one_week_later, template=letter_template_1, - notifications_sent=2, billable_unit=2, rate=.45, postage='second') + notifications_sent=2, billable_unit=4, rate=.45, postage='second') # service with emails only: service_with_emails = create_service(service_name='b - emails') diff --git a/tests/app/platform_stats/test_rest.py b/tests/app/platform_stats/test_rest.py index 7be13bfe7..f8bbf19ea 100644 --- a/tests/app/platform_stats/test_rest.py +++ b/tests/app/platform_stats/test_rest.py @@ -183,3 +183,58 @@ def test_get_data_for_billing_report(notify_db_session, admin_request): "2 second class letters at 35p\n1 first class letters at 50p\n15 international letters at £1.55\n" ) assert response[3]["purchase_order_number"] is None + + +def test_daily_volumes_report( + notify_db_session, sample_template, sample_email_template, sample_letter_template, admin_request +): + set_up_usage_data(datetime(2022, 3, 1)) + response = admin_request.get( + "platform_stats.daily_volumes_report", + start_date='2022-03-01', + end_date='2022-03-31' + ) + + assert len(response) == 3 + assert response[0] == {'day': '2022-03-01', 'email_totals': 10, 'letter_sheet_totals': 3, + 'letter_totals': 2, 'sms_chargeable_units': 2, 'sms_fragment_totals': 2, 'sms_totals': 1} + assert response[1] == {'day': '2022-03-03', 'email_totals': 0, 'letter_sheet_totals': 10, 'letter_totals': 18, + 'sms_chargeable_units': 2, 'sms_fragment_totals': 2, 'sms_totals': 2} + assert response[2] == {'day': '2022-03-08', 'email_totals': 0, 'letter_sheet_totals': 11, 'letter_totals': 12, + 'sms_chargeable_units': 4, 'sms_fragment_totals': 4, 'sms_totals': 2} + + +def test_volumes_by_service_report( + notify_db_session, sample_template, sample_email_template, sample_letter_template, admin_request +): + fixture = set_up_usage_data(datetime(2022, 3, 1)) + response = admin_request.get( + "platform_stats.volumes_by_service_report", + start_date='2022-03-01', + end_date='2022-03-01' + ) + + assert len(response) == 4 + assert response[0] == {'email_totals': 0, 'free_allowance': 10, 'letter_cost': 0.0, + 'letter_sheet_totals': 0, 'letter_totals': 0, + 'organisation_id': str(fixture['org_1'].id), + 'organisation_name': fixture['org_1'].name, + 'service_id': str(fixture['service_1_sms_and_letter'].id), + 'service_name': fixture['service_1_sms_and_letter'].name, + 'sms_chargeable_units': 2, 'sms_notifications': 1} + assert response[1] == {'email_totals': 0, 'free_allowance': 10, 'letter_cost': 0.0, 'letter_sheet_totals': 0, + 'letter_totals': 0, 'organisation_id': str(fixture['org_1'].id), + 'organisation_name': fixture['org_1'].name, + 'service_id': str(fixture['service_with_out_ft_billing_this_year'].id), + 'service_name': fixture['service_with_out_ft_billing_this_year'].name, + 'sms_chargeable_units': 0, 'sms_notifications': 0} + assert response[2] == {'email_totals': 0, 'free_allowance': 10, 'letter_cost': 0.0, 'letter_sheet_totals': 0, + 'letter_totals': 0, 'organisation_id': '', 'organisation_name': '', + 'service_id': str(fixture['service_with_sms_without_org'].id), + 'service_name': fixture['service_with_sms_without_org'].name, + 'sms_chargeable_units': 0, 'sms_notifications': 0} + assert response[3] == {'email_totals': 0, 'free_allowance': 10, 'letter_cost': 0.0, 'letter_sheet_totals': 0, + 'letter_totals': 0, 'organisation_id': '', 'organisation_name': '', + 'service_id': str(fixture['service_with_sms_within_allowance'].id), + 'service_name': fixture['service_with_sms_within_allowance'].name, + 'sms_chargeable_units': 0, 'sms_notifications': 0}