diff --git a/app/__init__.py b/app/__init__.py index 6b0584a20..e99a5ae51 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,6 +18,7 @@ from flask import ( ) from flask.globals import request_ctx from flask_login import LoginManager, current_user +from flask_socketio import SocketIO from flask_talisman import Talisman from flask_wtf import CSRFProtect from flask_wtf.csrf import CSRFError @@ -118,6 +119,7 @@ from notifications_utils.recipients import format_phone_number_human_readable login_manager = LoginManager() csrf = CSRFProtect() talisman = Talisman() +socketio = SocketIO() # The current service attached to the request stack. @@ -175,6 +177,7 @@ def create_app(application): init_govuk_frontend(application) init_jinja(application) + socketio.init_app(application) for client in ( csrf, diff --git a/app/assets/javascripts/sampleChartDashboard.js b/app/assets/javascripts/sampleChartDashboard.js new file mode 100644 index 000000000..d191abef1 --- /dev/null +++ b/app/assets/javascripts/sampleChartDashboard.js @@ -0,0 +1,66 @@ +(function (window) { + + function initializeChartAndSocket() { + var ctx = document.getElementById('myChart'); + if (!ctx) { + return; + } + + var myBarChart = new Chart(ctx.getContext('2d'), { + type: 'bar', + data: { + labels: [], + datasets: [ + { + label: 'Delivered', + data: [], + backgroundColor: '#0076d6', + stack: 'Stack 0' + }, + ] + }, + options: { + scales: { + y: { + beginAtZero: true + } + } + } + }); + + var socket = io(); + var serviceId = ctx.getAttribute('data-service-id'); + + socket.on('connect', function() { + socket.emit('fetch_daily_stats', serviceId); + }); + + socket.on('daily_stats_update', function(data) { + var labels = []; + var deliveredData = []; + + for (var date in data) { + labels.push(date); + deliveredData.push(data[date].sms.delivered); + } + + myBarChart.data.labels = labels; + myBarChart.data.datasets[0].data = deliveredData; + myBarChart.update(); + }); + + socket.on('error', function(data) { + console.log('Error:', data); + }); + + var sevenDaysButton = document.getElementById('sevenDaysButton'); + if (sevenDaysButton) { + sevenDaysButton.addEventListener('click', function() { + socket.emit('fetch_daily_stats', serviceId); + }); + } + } + + document.addEventListener('DOMContentLoaded', initializeChartAndSocket); + +})(window); diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index f1532d2d2..6561de402 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -6,6 +6,7 @@ from itertools import groupby from flask import Response, abort, jsonify, render_template, request, session, url_for from flask_login import current_user +from flask_socketio import emit from werkzeug.utils import redirect from app import ( @@ -14,6 +15,7 @@ from app import ( job_api_client, notification_api_client, service_api_client, + socketio, template_statistics_client, ) from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left @@ -32,6 +34,18 @@ from app.utils.user import user_has_permissions from notifications_utils.recipients import format_phone_number_human_readable +@socketio.on("fetch_daily_stats") +def handle_fetch_daily_stats(service_id): + if service_id: + date_range = get_stats_date_range() + daily_stats = service_api_client.get_service_notification_statistics_by_day( + service_id, start_date=date_range["start_date"], days=date_range["days"] + ) + emit("daily_stats_update", daily_stats) + else: + emit("error", {"error": "No service_id provided"}) + + @main.route("/services//dashboard") @user_has_permissions("view_activity", "send_messages") def old_service_dashboard(service_id): @@ -97,6 +111,7 @@ def service_dashboard(service_id): service_data_retention_days=service_data_retention_days, sms_sent=sms_sent[0], sms_allowance_remaining=sms_allowance_remaining[0], + service_id=service_id, ) @@ -447,6 +462,24 @@ def get_months_for_financial_year(year, time_format="%B"): return [month.strftime(time_format) for month in (get_months_for_year(1, 13, year))] +def get_current_month_for_financial_year(year): + current_month = datetime.now().month + return current_month + + +def get_stats_date_range(): + current_financial_year = get_current_financial_year() + current_month = get_current_month_for_financial_year(current_financial_year) + start_date = datetime.now().strftime("%Y-%m-%d") + days = 7 + return { + "current_financial_year": current_financial_year, + "current_month": current_month, + "start_date": start_date, + "days": days, + } + + def get_months_for_year(start, end, year): return [datetime(year, month, 1) for month in range(start, end)] diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index d34516b8b..42f54572f 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -43,6 +43,16 @@ class ServiceAPIClient(NotifyAdminAPIClient): params={"limit_days": limit_days}, )["data"] + def get_service_notification_statistics_by_day( + self, service_id, start_date=None, days=None + ): + if start_date is None: + start_date = datetime.now().strftime("%Y-%m-%d") + + return self.get( + "/service/{0}/statistics/{1}/{2}".format(service_id, start_date, days), + )["data"] + def get_services(self, params_dict=None): """ Retrieve a list of services. diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index 8f4310c60..0a03daa88 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -22,6 +22,8 @@ Messages sent + {{ ajax_block(partials, updates_url, 'inbox') }} {{ ajax_block(partials, updates_url, 'totals') }} diff --git a/gulpfile.js b/gulpfile.js index 35c92bd0c..d96e2e7da 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -131,7 +131,7 @@ const javascripts = () => { paths.src + 'javascripts/date.js', paths.src + 'javascripts/loginAlert.js', paths.src + 'javascripts/main.js', - paths.src + 'javascripts/chartDashboard.js', + paths.src + 'javascripts/sampleChartDashboard.js', ]) .pipe(plugins.prettyerror()) .pipe(plugins.babel({ diff --git a/pyproject.toml b/pyproject.toml index 5c7c88eaa..07c768f1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ requests = "^2.32.3" six = "^1.16.0" urllib3 = "^2.2.1" webencodings = "^0.5.1" +flask-socketio = "^5.3.6" [tool.poetry.group.dev.dependencies] diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index ee0e7a002..43a9e3ede 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -3,9 +3,11 @@ import json from datetime import datetime import pytest -from flask import url_for +from flask import Flask, url_for +from flask_socketio import SocketIOTestClient from freezegun import freeze_time +from app import create_app, socketio from app.main.views.dashboard import ( aggregate_notifications_stats, aggregate_status_types, @@ -23,6 +25,7 @@ from tests import ( from tests.conftest import ( ORGANISATION_ID, SERVICE_ONE_ID, + SERVICE_TWO_ID, create_active_caseworking_user, create_active_user_view_permissions, normalize_spaces, @@ -1869,3 +1872,76 @@ def test_service_dashboard_shows_batched_jobs( assert job_table_body is not None assert len(rows) == 1 + + +@pytest.fixture() +def app_with_socketio(): + app = Flask("app") + create_app(app) + return app, socketio + + +@pytest.mark.parametrize( + ("service_id", "date_range", "expected_call_args"), + [ + ( + SERVICE_ONE_ID, + {"start_date": "2024-01-01", "days": 7}, + {"service_id": SERVICE_ONE_ID, "start_date": "2024-01-01", "days": 7} + ), + ( + SERVICE_TWO_ID, + {"start_date": "2023-06-01", "days": 7}, + {"service_id": SERVICE_TWO_ID, "start_date": "2023-06-01", "days": 7} + ), + ] +) +def test_fetch_daily_stats( + app_with_socketio, mocker, + service_id, + date_range, + expected_call_args +): + app, socketio = app_with_socketio + + mocker.patch( + "app.main.views.dashboard.get_stats_date_range", + return_value=date_range + ) + + mock_service_api = mocker.patch( + "app.service_api_client.get_service_notification_statistics_by_day", + return_value={ + date_range["start_date"]: { + "email": {"delivered": 0, "failure": 0, "requested": 0}, + "sms": {"delivered": 0, "failure": 1, "requested": 1} + }, + } + ) + + client = SocketIOTestClient(app, socketio) + try: + connected = client.is_connected() + assert connected, "Client should be connected" + + client.emit('fetch_daily_stats', service_id) + + received = client.get_received() + assert received, "Should receive a response message" + assert received[0]['name'] == 'daily_stats_update' + assert received[0]['args'][0] == { + date_range["start_date"]: { + "email": {"delivered": 0, "failure": 0, "requested": 0}, + "sms": {"delivered": 0, "failure": 1, "requested": 1} + }, + } + + mock_service_api.assert_called_once_with( + service_id, + start_date=expected_call_args["start_date"], + days=expected_call_args["days"] + ) + finally: + client.disconnect() + disconnected = not client.is_connected() + assert disconnected, "Client should be disconnected"