Merge branch 'main' of https://github.com/GSA/notifications-admin into 1674-match-recent-batches-to-wireframe

# Conflicts:
#	tests/app/main/views/test_dashboard.py
#	tests/javascripts/activityChart.test.js
This commit is contained in:
Jonathan Bobel
2024-08-13 14:57:01 -04:00
21 changed files with 1596 additions and 1338 deletions

View File

@@ -18,7 +18,6 @@ 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
@@ -119,8 +118,6 @@ 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.
current_service = LocalProxy(partial(getattr, request_ctx, "service"))
@@ -177,7 +174,6 @@ def create_app(application):
init_govuk_frontend(application)
init_jinja(application)
socketio.init_app(application, cors_allowed_origins=['http://localhost:6012'])
for client in (
csrf,

View File

@@ -185,41 +185,41 @@
return;
}
var socket = io("/services");
var eventType = type === 'service' ? 'fetch_daily_stats' : 'fetch_daily_stats_by_user';
var socketConnect = type === 'service' ? 'daily_stats_update' : 'daily_stats_by_user_update';
var daily_stats = activityChartContainer.getAttribute('data-daily-stats');
var daily_stats_by_user = activityChartContainer.getAttribute('data-daily_stats_by_user');
socket.on('connect', function () {
socket.emit(eventType);
});
try {
// Choose the correct JSON string based on the type ('service' or 'user'),
// replace single quotes with double quotes to ensure valid JSON format,
// then parse the JSON string into a JavaScript object.
var statsJson = type === 'service' ? daily_stats : daily_stats_by_user;
statsJson = statsJson.replace(/'/g, '"');
data = JSON.parse(statsJson);
} catch (error) {
console.error('Error parsing JSON data:', error);
return;
}
var labels = [];
var deliveredData = [];
var failedData = [];
socket.on('connect_error', function(error) {
console.error('WebSocket connection error:', error);
});
socket.on(socketConnect, function(data) {
var labels = [];
var deliveredData = [];
var failedData = [];
for (var dateString in data) {
// Parse the date string (assuming format YYYY-MM-DD)
for (var dateString in data) {
if (data.hasOwnProperty(dateString)) {
const dateParts = dateString.split('-');
const formattedDate = `${dateParts[1]}/${dateParts[2]}/${dateParts[0].slice(2)}`; // Format to MM/DD/YY
const formattedDate = `${dateParts[1]}/${dateParts[2]}/${dateParts[0].slice(2)}`;
labels.push(formattedDate);
deliveredData.push(data[dateString].sms.delivered);
failedData.push(data[dateString].sms.failure);
}
}
try {
createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
});
socket.on('error', function(data) {
console.log('Error:', data);
});
} catch (error) {
console.error('Error creating chart or table:', error);
}
};
const handleDropdownChange = function(event) {

View File

@@ -1,70 +0,0 @@
(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: {
animation: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
var socket = io();
socket.on('connect', function() {
socket.emit('fetch_daily_stats_by_user');
});
socket.on('daily_stats_by_user_update', function(data) {
// console.log('Data received:', data);
var labels = [];
var deliveredData = [];
var failedData = [];
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_by_user');
// console.log('clicked');
});
}
}
document.addEventListener('DOMContentLoaded', initializeChartAndSocket);
})(window);

View File

@@ -14,14 +14,14 @@
document.getElementById('message').innerText = `${sms_sent.toLocaleString()} sent / ${sms_remaining_messages.toLocaleString()} remaining`;
// Calculate minimum width for "Messages Sent" as 1% of the total chart width
var minSentPercentage = 0.01; // Minimum width as a percentage of total messages (1% in this case)
var minSentPercentage = 0.02; // Minimum width as a percentage of total messages (1% in this case)
var minSentValue = totalMessages * minSentPercentage;
var displaySent = Math.max(sms_sent, minSentValue);
var displayRemaining = totalMessages - displaySent;
var svg = d3.select("#totalMessageChart");
var width = chartContainer.clientWidth;
var height = 64;
var height = 48;
// Ensure the width is set correctly
if (width === 0) {
@@ -62,7 +62,7 @@
.attr("x", 0) // Initially set to 0, will be updated during animation
.attr("y", 0)
.attr("height", height)
.attr("fill", '#fa9441')
.attr("fill", '#C7CACE')
.attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) {
tooltip.style('display', 'block')

View File

@@ -2,7 +2,7 @@
$delivered: color('blue-50v');
$pending: color('green-cool-40v');
$failed: color('orange-30v');
$failed: color('gray-cool-20');
.chart-container {
display: flex;
@@ -11,6 +11,10 @@ $failed: color('orange-30v');
}
}
#totalMessageChartContainer {
max-width: 600px;
}
.bar {
border-radius: units(0.5);
&.delivered, &.usage {

View File

@@ -5,7 +5,6 @@ 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 (
@@ -13,7 +12,6 @@ from app import (
current_service,
job_api_client,
service_api_client,
socketio,
template_statistics_client,
)
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
@@ -32,38 +30,6 @@ from app.utils.user import user_has_permissions
from notifications_utils.recipients import format_phone_number_human_readable
@socketio.on("fetch_daily_stats", namespace="/services")
def handle_fetch_daily_stats():
service_id = session.get("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"})
@socketio.on("fetch_daily_stats_by_user", namespace="/services")
def handle_fetch_daily_stats_by_user():
service_id = session.get("service_id")
user_id = session.get("user_id")
if service_id and user_id:
date_range = get_stats_date_range()
daily_stats_by_user = (
service_api_client.get_user_service_notification_statistics_by_day(
service_id,
user_id,
start_date=date_range["start_date"],
days=date_range["days"],
)
)
emit("daily_stats_by_user_update", daily_stats_by_user)
else:
emit("error", {"error": "No service_id or user_id provided"})
@main.route("/services/<uuid:service_id>/dashboard")
@user_has_permissions("view_activity", "send_messages")
def old_service_dashboard(service_id):
@@ -87,12 +53,17 @@ def service_dashboard(service_id):
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
current_service.id,
)
date_range = get_stats_date_range()
usage_data = get_annual_usage_breakdown(yearly_usage, free_sms_allowance)
sms_sent = usage_data["sms_sent"]
sms_allowance_remaining = usage_data["sms_allowance_remaining"]
job_response = job_api_client.get_jobs(service_id)["data"]
service_data_retention_days = 7
daily_stats = get_daily_stats(service_id, date_range)
daily_stats_by_user = get_daily_stats_by_user(
service_id, current_user.id, date_range
)
jobs = [
{
@@ -123,6 +94,23 @@ def service_dashboard(service_id):
service_data_retention_days=service_data_retention_days,
sms_sent=sms_sent,
sms_allowance_remaining=sms_allowance_remaining,
daily_stats=daily_stats,
daily_stats_by_user=daily_stats_by_user,
)
def get_daily_stats(service_id, date_range):
return service_api_client.get_service_notification_statistics_by_day(
service_id, start_date=date_range["start_date"], days=date_range["days"]
)
def get_daily_stats_by_user(service_id, user_id, date_range):
return service_api_client.get_user_service_notification_statistics_by_day(
service_id,
user_id,
start_date=date_range["start_date"],
days=date_range["days"],
)

View File

@@ -56,6 +56,9 @@ class NotifyAdminAPIClient(BaseAPIClient):
):
abort(403)
def is_calling_signin_url(self, arg):
return arg.startswith("('/user")
def check_inactive_user(self, *args):
still_signing_in = False
@@ -64,14 +67,7 @@ class NotifyAdminAPIClient(BaseAPIClient):
# and we only want to check the first arg
for arg in args:
arg = str(arg)
if (
"get-login-gov-user" in arg
or "user/email" in arg
or "/activate" in arg
or "/email-code" in arg
or "/verify/code" in arg
or "/user" in arg
):
if self.is_calling_signin_url(arg):
still_signing_in = True
# This seems to be a weird edge case that happens intermittently with invites

View File

@@ -28,15 +28,11 @@
<div id="message"></div>
</div>
<div id="totalMessageTable" class="margin-0"></div>
<p class="align-with-heading-copy margin-bottom-4">
What counts as 1 text message part?<br />
See <a class="usa-link" href="{{ url_for('.pricing') }}">Tracking usage</a>.
</p>
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">
Activity snapshot
</h2>
<div id="activityChartContainer">
<div id="activityChartContainer" data-daily-stats="{{ daily_stats }}" data-daily_stats_by_user="{{ daily_stats_by_user }}">
<form class="usa-form">
<label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options">