diff --git a/.ds.baseline b/.ds.baseline index 859f30b4d..ec87d9c30 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -423,7 +423,7 @@ "filename": "app/templates/new/components/head.html", "hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327", "is_verified": false, - "line_number": 35, + "line_number": 34, "is_secret": false } ], @@ -710,5 +710,5 @@ } ] }, - "generated_at": "2024-05-29T21:18:03Z" + "generated_at": "2024-06-05T22:01:56Z" } 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/chartDashboard.js b/app/assets/javascripts/chartDashboard.js deleted file mode 100644 index fc40936f8..000000000 --- a/app/assets/javascripts/chartDashboard.js +++ /dev/null @@ -1,24 +0,0 @@ -(function (window) { - - const ctx = document.getElementById('myChart'); - - new Chart(ctx, { - type: 'bar', - data: { - labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], - datasets: [{ - label: '# of Votes', - data: [12, 19, 3, 5, 2, 3], - borderWidth: 1 - }] - }, - options: { - scales: { - y: { - beginAtZero: true - } - } - } - }); - -})(window); 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 3bbf432b3..a64444dc2 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): @@ -84,6 +98,7 @@ def service_dashboard(service_id): partials=get_dashboard_partials(service_id), job_and_notifications=job_and_notifications, service_data_retention_days=service_data_retention_days, + service_id=service_id, ) @@ -434,6 +449,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/new/components/head.html b/app/templates/new/components/head.html index 51f3c4da3..dd7519cf4 100644 --- a/app/templates/new/components/head.html +++ b/app/templates/new/components/head.html @@ -31,7 +31,6 @@ {# google #} - {% if g.hide_from_search_engines %} diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index d46d94b95..c1ba46caf 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 98afbbacf..0e2c1eaac 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -96,7 +96,9 @@ const javascripts = () => { paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js', paths.npm + 'timeago/jquery.timeago.js', paths.npm + 'textarea-caret/index.js', - paths.npm + 'cbor-js/cbor.js' + paths.npm + 'cbor-js/cbor.js', + paths.npm + 'socket.io-client/dist/socket.io.min.js', + paths.npm + 'chart.js/dist/chart.umd.js' ])); // JS local to this application @@ -125,7 +127,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/package-lock.json b/package-lock.json index c96f68f8a..30688bfde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "python": "^0.0.4", "query-command-supported": "1.0.0", "sass-embedded": "^1.69.5", + "socket.io-client": "^4.7.5", "textarea-caret": "3.1.0", "timeago": "1.6.7" }, @@ -2592,6 +2593,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4644,7 +4650,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -5039,6 +5044,46 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -10779,8 +10824,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multipipe": { "version": "0.1.2", @@ -13127,6 +13171,32 @@ "urix": "^0.1.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14511,6 +14581,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/package.json b/package.json index b60893b57..38cc83797 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "python": "^0.0.4", "query-command-supported": "1.0.0", "sass-embedded": "^1.69.5", + "socket.io-client": "^4.7.5", "textarea-caret": "3.1.0", "timeago": "1.6.7" }, 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 926163dbd..285444b1b 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, @@ -1875,3 +1878,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"