diff --git a/app/assets/stylesheets/app.scss b/app/assets/stylesheets/app.scss index f28724c93..a6538fc3a 100644 --- a/app/assets/stylesheets/app.scss +++ b/app/assets/stylesheets/app.scss @@ -179,3 +179,16 @@ details summary { border: 0; margin-bottom: -$gutter + 2px; } + +.body-copy-table { + + table th, + table td { + font-size: 19px; + } + +} + +.tabular-numbers { + @include core-19($tabular-numbers: true); +} diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss index a66baeff4..c748bdbcc 100644 --- a/app/assets/stylesheets/components/table.scss +++ b/app/assets/stylesheets/components/table.scss @@ -31,7 +31,7 @@ } .table-field-heading-first { - width: 52.5% + width: 52.5%; } .table-row { diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index b2787e8a4..def963624 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -8,7 +8,9 @@ from flask import ( url_for, session, jsonify, - current_app + current_app, + request, + abort ) from flask_login import login_required @@ -80,8 +82,16 @@ def template_history(service_id): @login_required @user_has_permissions('manage_settings', admin_override=True) def usage(service_id): + try: + year = int(request.args.get('year', 2016)) + except ValueError: + abort(404) return render_template( 'views/usage.html', + months=get_free_paid_breakdown_for_billable_units( + year, service_api_client.get_billable_units(service_id, year) + ), + year=year, **calculate_usage(service_api_client.get_service_usage(service_id)['data']) ) @@ -195,3 +205,58 @@ def format_weekly_stats_to_list(historical_stats): out.append(weekly_stats) return sorted(out, key=lambda x: x['week_start'], reverse=True) + + +def get_months_for_financial_year(year): + return [ + month.strftime('%B') + for month in ( + get_months_for_year(4, 13, year) + + get_months_for_year(1, 4, year + 1) + ) + if month < datetime.now() + ] + + +def get_months_for_year(start, end, year): + return [datetime(year, month, 1) for month in range(start, end)] + + +def get_free_paid_breakdown_for_billable_units(year, billable_units): + cumulative = 0 + for month in get_months_for_financial_year(year): + previous_cumulative = cumulative + monthly_usage = billable_units.get(month, 0) + cumulative += monthly_usage + breakdown = get_free_paid_breakdown_for_month( + cumulative, previous_cumulative, monthly_usage + ) + yield { + 'name': month, + 'paid': breakdown['paid'], + 'free': breakdown['free'] + } + + +def get_free_paid_breakdown_for_month( + cumulative, + previous_cumulative, + monthly_usage +): + allowance = 250000 + + if cumulative < allowance: + return { + 'paid': 0, + 'free': monthly_usage, + } + elif previous_cumulative < allowance: + return { + 'paid': monthly_usage - (allowance - previous_cumulative), + 'free': allowance - previous_cumulative + } + else: + return { + 'paid': monthly_usage, + 'free': 0 + } diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index ed14a803c..dfa64de51 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -211,6 +211,9 @@ class ServiceAPIClient(NotificationsAPIClient): def update_whitelist(self, service_id, data): return self.put(url='/service/{}/whitelist'.format(service_id), data=data) + def get_billable_units(self, service_id, year): + return self.get(url='/service/{}/billable-units?year={}'.format(service_id, year)) + class ServicesBrowsableItem(BrowsableItem): @property diff --git a/app/templates/views/usage.html b/app/templates/views/usage.html index 1f97fcd50..37794f1b4 100644 --- a/app/templates/views/usage.html +++ b/app/templates/views/usage.html @@ -1,4 +1,5 @@ {% from "components/big-number.html" import big_number %} +{% from "components/table.html" import list_table, field, hidden_field_heading, row_heading, text_field %} {% extends "withnav_template.html" %} @@ -13,7 +14,9 @@

Usage

- 1 April 2016 to date +
+ Financial year {{ year }} to {{ year + 1 }} +
@@ -49,7 +52,7 @@
-
+
{{ big_number( (sms_chargeable * sms_rate), 'spent', @@ -57,7 +60,50 @@ smaller=True ) }}
-

+

+
+ +
+ {% call(month, row_index) list_table( + months, + caption="Total spend", + caption_visible=False, + empty_message='', + field_headings=[ + 'By month', + hidden_field_heading('Cost'), + ], + field_headings_visible=True + ) %} + {% call row_heading() %} + {{ month.name }} + {% endcall %} + {% call field(align='left') %} + {{ big_number( + sms_rate * month.paid, + currency="£", + smallest=True + ) }} + + {% endcall %} + {% endcall %} +
+ +
+
 
+
+

What counts as 1 text message?
See pricing.

diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index 740af554c..8b3a008a9 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -6,7 +6,11 @@ import pytest from bs4 import BeautifulSoup from freezegun import freeze_time -from app.main.views.dashboard import get_dashboard_totals, format_weekly_stats_to_list +from app.main.views.dashboard import ( + get_dashboard_totals, + format_weekly_stats_to_list, + get_free_paid_breakdown_for_billable_units +) from tests import validate_route_permission from tests.conftest import SERVICE_ONE_ID @@ -235,6 +239,57 @@ def test_should_show_recent_jobs_on_dashboard( assert table_rows[index].find_all('td')[column_index].text.strip() == str(count) +@freeze_time("2016-12-31 11:09:00.061258") +def test_usage_page( + client, + api_user_active, + mock_get_service, + mock_get_user, + mock_has_permissions, + mock_get_usage, + mock_get_billable_units +): + client.login(api_user_active) + response = client.get(url_for('main.usage', service_id=SERVICE_ONE_ID, year=2000)) + + assert response.status_code == 200 + + mock_get_billable_units.assert_called_once_with(SERVICE_ONE_ID, 2000) + + page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser') + + cols = page.find_all('div', {'class': 'column-half'}) + + assert cols[1].text.strip() == 'Financial year 2000 to 2001' + + assert '123' in cols[2].text + assert 'Emails' in cols[2].text + + assert '456,123' in cols[3].text + assert 'Text messages' in cols[3].text + + table = page.find('table').text.strip() + + assert 'April' in table + assert 'March' in table + assert '123 free text messages' in table + assert '£3,403.06' in table + assert '249,877 free text messages' in table + assert '206,246 text messages at 1.65p' in table + + +@freeze_time("2016-12-31 11:09:00.061258") +def test_usage_page_for_invalid_year( + client, + api_user_active, + mock_get_service, + mock_get_user, + mock_has_permissions +): + client.login(api_user_active) + assert client.get(url_for('main.usage', service_id=SERVICE_ONE_ID, year='abcd')).status_code == 404 + + def _test_dashboard_menu(mocker, app_, usr, service, permissions): with app_.test_request_context(): with app_.test_client() as client: @@ -520,3 +575,34 @@ def test_format_weekly_stats_to_list_has_stats_with_failure_rate(): def _stats(requested, delivered, failed): return {'requested': requested, 'delivered': delivered, 'failed': failed} + + +@pytest.mark.parametrize( + 'now, expected_number_of_months', [ + (freeze_time("2017-12-31 11:09:00.061258"), 12), + (freeze_time("2017-01-01 11:09:00.061258"), 10) + ] +) +def test_get_free_paid_breakdown_for_billable_units(now, expected_number_of_months): + with now: + assert list(get_free_paid_breakdown_for_billable_units( + 2016, { + 'April': 100000, + 'May': 100000, + 'June': 100000, + 'February': 1234 + } + )) == [ + {'name': 'April', 'free': 100000, 'paid': 0}, + {'name': 'May', 'free': 100000, 'paid': 0}, + {'name': 'June', 'free': 50000, 'paid': 50000}, + {'name': 'July', 'free': 0, 'paid': 0}, + {'name': 'August', 'free': 0, 'paid': 0}, + {'name': 'September', 'free': 0, 'paid': 0}, + {'name': 'October', 'free': 0, 'paid': 0}, + {'name': 'November', 'free': 0, 'paid': 0}, + {'name': 'December', 'free': 0, 'paid': 0}, + {'name': 'January', 'free': 0, 'paid': 0}, + {'name': 'February', 'free': 0, 'paid': 1234}, + {'name': 'March', 'free': 0, 'paid': 0} + ][:expected_number_of_months] diff --git a/tests/app/main/views/test_jobs.py b/tests/app/main/views/test_jobs.py index ab2813a57..876073533 100644 --- a/tests/app/main/views/test_jobs.py +++ b/tests/app/main/views/test_jobs.py @@ -335,6 +335,17 @@ def test_can_show_notifications( page=expected_page_argument ) == page.find("div", {'data-key': 'notifications'})['data-resource'] + path_to_json = page.find("div", {'data-key': 'notifications'})['data-resource'] + + assert ( + '/services/{}/notifications/{}.json?status={}&page={}'.format( + service_one['id'], message_type, status_argument, expected_page_argument + ) in path_to_json or + '/services/{}/notifications/{}.json?page={}&status={}'.format( + service_one['id'], message_type, expected_page_argument, status_argument + ) in path_to_json + ) + mock_get_notifications.assert_called_with( limit_days=7, page=expected_page_argument, diff --git a/tests/conftest.py b/tests/conftest.py index 312a2a14b..7ec1ba9f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1172,14 +1172,26 @@ def mock_get_template_statistics_for_template(mocker, service_one): def mock_get_usage(mocker, service_one, fake_uuid): def _get_usage(service_id): return {'data': { - "sms_count": 123, - "email_count": 456 + "sms_count": 456123, + "email_count": 123 }} return mocker.patch( 'app.service_api_client.get_service_usage', side_effect=_get_usage) +@pytest.fixture(scope='function') +def mock_get_billable_units(mocker): + def _get_usage(service_id, year): + return { + "April": 123, + "March": 456123 + } + + return mocker.patch( + 'app.service_api_client.get_billable_units', side_effect=_get_usage) + + @pytest.fixture(scope='function') def mock_events(mocker): def _create_event(event_type, event_data):