mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-24 16:24:08 -04:00
Merge branch 'main' of https://github.com/GSA/notifications-admin into 2200-contextual-tooltips
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
(function (window) {
|
||||
|
||||
if (document.getElementById('activityChartContainer')) {
|
||||
|
||||
let currentType = 'service';
|
||||
const tableContainer = document.getElementById('activityContainer');
|
||||
const currentUserName = tableContainer.getAttribute('data-currentUserName');
|
||||
const currentServiceId = tableContainer.getAttribute('data-currentServiceId');
|
||||
const COLORS = {
|
||||
delivered: '#0076d6',
|
||||
failed: '#fa9441',
|
||||
pending: '#C7CACE',
|
||||
text: '#666'
|
||||
};
|
||||
|
||||
@@ -12,7 +16,7 @@
|
||||
const FONT_WEIGHT = 'bold';
|
||||
const MAX_Y = 120;
|
||||
|
||||
const createChart = function(containerId, labels, deliveredData, failedData) {
|
||||
const createChart = function(containerId, labels, deliveredData, failedData, pendingData) {
|
||||
const container = d3.select(containerId);
|
||||
container.selectAll('*').remove(); // Clear any existing content
|
||||
|
||||
@@ -35,7 +39,7 @@
|
||||
}
|
||||
|
||||
// Calculate total messages
|
||||
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData);
|
||||
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData) + d3.sum(pendingData);
|
||||
|
||||
// Create legend only if there are messages
|
||||
const legendContainer = d3.select('.chart-legend');
|
||||
@@ -45,7 +49,8 @@
|
||||
// Show legend if there are messages
|
||||
const legendData = [
|
||||
{ label: 'Delivered', color: COLORS.delivered },
|
||||
{ label: 'Failed', color: COLORS.failed }
|
||||
{ label: 'Failed', color: COLORS.failed },
|
||||
{ label: 'Pending', color: COLORS.pending }
|
||||
];
|
||||
|
||||
const legendItem = legendContainer.selectAll('.legend-item')
|
||||
@@ -76,8 +81,9 @@
|
||||
.range([0, width])
|
||||
.padding(0.1);
|
||||
// Adjust the y-axis domain to add some space above the tallest bar
|
||||
const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0)));
|
||||
const y = d3.scaleSqrt()
|
||||
const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0) + (pendingData[i] || 0)));
|
||||
|
||||
const y = d3.scaleSymlog()
|
||||
.domain([0, maxY + 2]) // Add 2 units of space at the top
|
||||
.nice()
|
||||
.range([height, 0]);
|
||||
@@ -89,7 +95,7 @@
|
||||
|
||||
// Generate the y-axis with whole numbers
|
||||
const yAxis = d3.axisLeft(y)
|
||||
.ticks(Math.min(maxY + 2, 10)) // Generate up to 10 ticks based on the data
|
||||
.ticks(Math.min(maxY + 2, 3))
|
||||
.tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis
|
||||
|
||||
svg.append('g')
|
||||
@@ -100,12 +106,13 @@
|
||||
const stackData = labels.map((label, i) => ({
|
||||
label: label,
|
||||
delivered: deliveredData[i],
|
||||
failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0
|
||||
failed: failedData[i] || 0,
|
||||
pending: pendingData[i] || 0
|
||||
}));
|
||||
|
||||
// Stack the data
|
||||
const stack = d3.stack()
|
||||
.keys(['delivered', 'failed'])
|
||||
.keys(['delivered', 'failed', 'pending'])
|
||||
.order(d3.stackOrderNone)
|
||||
.offset(d3.stackOffsetNone);
|
||||
|
||||
@@ -113,8 +120,8 @@
|
||||
|
||||
// Color scale
|
||||
const color = d3.scaleOrdinal()
|
||||
.domain(['delivered', 'failed'])
|
||||
.range([COLORS.delivered, COLORS.failed]);
|
||||
.domain(['delivered', 'failed', 'pending'])
|
||||
.range([COLORS.delivered, COLORS.failed, COLORS.pending]);
|
||||
|
||||
// Create bars with animation
|
||||
const barGroups = svg.selectAll('.bar-group')
|
||||
@@ -123,11 +130,12 @@
|
||||
.append('g')
|
||||
.attr('class', 'bar-group')
|
||||
.attr('fill', d => color(d.key));
|
||||
|
||||
const minBarHeight = 5;
|
||||
barGroups.selectAll('rect')
|
||||
.data(d => d)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.filter(d => d[1] - d[0] > 0)
|
||||
.attr('x', d => x(d.data.label))
|
||||
.attr('y', height)
|
||||
.attr('height', 0)
|
||||
@@ -148,11 +156,13 @@
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.attr('y', d => y(d[1]))
|
||||
.attr('height', d => y(d[0]) - y(d[1]));
|
||||
};
|
||||
.attr('height', d => {
|
||||
const calculatedHeight = y(d[0]) - y(d[1]);
|
||||
return calculatedHeight < minBarHeight ? minBarHeight : calculatedHeight;
|
||||
}); };
|
||||
|
||||
// Function to create an accessible table
|
||||
const createTable = function(tableId, chartType, labels, deliveredData, failedData) {
|
||||
const createTable = function(tableId, chartType, labels, deliveredData, failedData, pendingData) {
|
||||
const table = document.getElementById(tableId);
|
||||
table.innerHTML = ""; // Clear previous data
|
||||
|
||||
@@ -164,7 +174,7 @@
|
||||
|
||||
// Create table header
|
||||
const headerRow = document.createElement('tr');
|
||||
const headers = ['Day', 'Delivered', 'Failed'];
|
||||
const headers = ['Day', 'Delivered', 'Failed', 'Pending'];
|
||||
headers.forEach(headerText => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = headerText;
|
||||
@@ -187,6 +197,10 @@
|
||||
cellFailed.textContent = failedData[index];
|
||||
row.appendChild(cellFailed);
|
||||
|
||||
const cellPending = document.createElement('td');
|
||||
cellPending.textContent = pendingData[index];
|
||||
row.appendChild(cellPending);
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -196,12 +210,19 @@
|
||||
};
|
||||
|
||||
const fetchData = function(type) {
|
||||
|
||||
var ctx = document.getElementById('weeklyChart');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`;
|
||||
var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
var url = type === 'service'
|
||||
? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}`
|
||||
: `/services/${currentServiceId}/daily-stats-by-user.json`;
|
||||
|
||||
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
@@ -213,7 +234,7 @@
|
||||
labels = [];
|
||||
deliveredData = [];
|
||||
failedData = [];
|
||||
|
||||
pendingData = [];
|
||||
let totalMessages = 0;
|
||||
|
||||
for (var dateString in data) {
|
||||
@@ -224,9 +245,8 @@
|
||||
labels.push(formattedDate);
|
||||
deliveredData.push(data[dateString].sms.delivered);
|
||||
failedData.push(data[dateString].sms.failure);
|
||||
|
||||
// Calculate the total number of messages
|
||||
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure;
|
||||
pendingData.push(data[dateString].sms.pending || 0);
|
||||
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure + data[dateString].sms.pending;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,17 +272,18 @@
|
||||
}
|
||||
} else {
|
||||
// If there are messages, create the chart and table
|
||||
createChart('#weeklyChart', labels, deliveredData, failedData);
|
||||
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(error => console.error('Error fetching daily stats:', error));
|
||||
};
|
||||
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
|
||||
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(error => console.error('Error fetching daily stats:', error));
|
||||
};
|
||||
setInterval(() => fetchData(currentType), 25000);
|
||||
const handleDropdownChange = function(event) {
|
||||
const selectedValue = event.target.value;
|
||||
currentType = selectedValue;
|
||||
const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`);
|
||||
const selectElement = document.getElementById('options');
|
||||
const selectedText = selectElement.options[selectElement.selectedIndex].text;
|
||||
@@ -270,36 +291,67 @@
|
||||
subTitle.textContent = `${selectedText} - last 7 days`;
|
||||
fetchData(selectedValue);
|
||||
|
||||
// Update ARIA live region
|
||||
const liveRegion = document.getElementById('aria-live-account');
|
||||
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
|
||||
|
||||
// Switch tables based on dropdown selection
|
||||
const selectedTable = selectedValue === "individual" ? "table1" : "table2";
|
||||
const tables = document.querySelectorAll('.table-overflow-x-auto');
|
||||
tables.forEach(function(table) {
|
||||
table.classList.add('hidden'); // Hide all tables by adding the hidden class
|
||||
table.classList.remove('visible'); // Ensure they are not visible
|
||||
});
|
||||
const tableToShow = document.getElementById(selectedTable);
|
||||
tableToShow.classList.remove('hidden'); // Remove hidden class
|
||||
tableToShow.classList.add('visible'); // Add visible class
|
||||
const tableHeading = document.querySelector('#tableActivity h2');
|
||||
const senderColumns = document.querySelectorAll('.sender-column');
|
||||
const allRows = document.querySelectorAll('#activity-table tbody tr');
|
||||
const caption = document.querySelector('#activity-table caption');
|
||||
|
||||
if (selectedValue === 'individual') {
|
||||
|
||||
tableHeading.textContent = 'My activity';
|
||||
caption.textContent = `Table showing the sent jobs for ${currentUserName}`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = 'none';
|
||||
});
|
||||
|
||||
allRows.forEach(row => row.style.display = 'none');
|
||||
|
||||
const userRows = Array.from(allRows).filter(row => {
|
||||
const senderCell = row.querySelector('.sender-column');
|
||||
const rowSender = senderCell ? senderCell.textContent.trim() : '';
|
||||
return rowSender === currentUserName;
|
||||
});
|
||||
|
||||
userRows.slice(0, 5).forEach(row => {
|
||||
row.style.display = '';
|
||||
});
|
||||
} else {
|
||||
|
||||
tableHeading.textContent = 'Service activity';
|
||||
caption.textContent = `Table showing the sent jobs for service`;
|
||||
|
||||
senderColumns.forEach(col => {
|
||||
col.style.display = '';
|
||||
});
|
||||
|
||||
allRows.forEach((row, index) => {
|
||||
row.style.display = (index < 5) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize activityChart chart and table with service data by default
|
||||
fetchData('service');
|
||||
fetchData(currentType);
|
||||
|
||||
const allRows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
|
||||
allRows.forEach((row, index) => {
|
||||
row.style.display = (index < 5) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Add event listener to the dropdown
|
||||
const dropdown = document.getElementById('options');
|
||||
dropdown.addEventListener('change', handleDropdownChange);
|
||||
});
|
||||
|
||||
// Resize chart on window resize
|
||||
window.addEventListener('resize', function() {
|
||||
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) {
|
||||
createChart('#weeklyChart', labels, deliveredData, failedData);
|
||||
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
|
||||
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0 && pendingData.length > 0) {
|
||||
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
|
||||
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
var chartTitle = document.getElementById('chartTitle').textContent;
|
||||
|
||||
// Access data attributes from the HTML
|
||||
var sms_sent = parseInt(chartContainer.getAttribute('data-sms-sent'));
|
||||
var sms_remaining_messages = parseInt(chartContainer.getAttribute('data-sms-allowance-remaining'));
|
||||
var totalMessages = sms_sent + sms_remaining_messages;
|
||||
var messagesSent = parseInt(chartContainer.getAttribute('data-messages-sent'));
|
||||
var messagesRemaining = parseInt(chartContainer.getAttribute('data-messages-remaining'));
|
||||
var totalMessages = messagesSent + messagesRemaining;
|
||||
|
||||
// Update the message below the chart
|
||||
document.getElementById('message').innerText = `${sms_sent.toLocaleString()} sent / ${sms_remaining_messages.toLocaleString()} remaining`;
|
||||
document.getElementById('message').innerText = `${messagesSent.toLocaleString()} sent / ${messagesRemaining.toLocaleString()} remaining`;
|
||||
|
||||
// Calculate minimum width for "Messages Sent" as 1% of the total chart width
|
||||
var minSentPercentage = (sms_sent === 0) ? 0 : 0.02;
|
||||
var minSentPercentage = (messagesSent === 0) ? 0 : 0.02;
|
||||
var minSentValue = totalMessages * minSentPercentage;
|
||||
var displaySent = Math.max(sms_sent, minSentValue);
|
||||
var displaySent = Math.max(messagesSent, minSentValue);
|
||||
var displayRemaining = totalMessages - displaySent;
|
||||
|
||||
var svg = d3.select("#totalMessageChart");
|
||||
@@ -48,7 +48,7 @@
|
||||
.attr("width", 0) // Start with width 0 for animation
|
||||
.on('mouseover', function(event) {
|
||||
tooltip.style('display', 'block')
|
||||
.html(`Messages Sent: ${sms_sent.toLocaleString()}`);
|
||||
.html(`Messages Sent: ${messagesSent.toLocaleString()}`);
|
||||
})
|
||||
.on('mousemove', function(event) {
|
||||
tooltip.style('left', `${event.pageX + 10}px`)
|
||||
@@ -66,7 +66,7 @@
|
||||
.attr("width", 0) // Start with width 0 for animation
|
||||
.on('mouseover', function(event) {
|
||||
tooltip.style('display', 'block')
|
||||
.html(`Remaining: ${sms_remaining_messages.toLocaleString()}`);
|
||||
.html(`Remaining: ${messagesRemaining.toLocaleString()}`);
|
||||
})
|
||||
.on('mousemove', function(event) {
|
||||
tooltip.style('left', `${event.pageX + 10}px`)
|
||||
@@ -115,9 +115,9 @@
|
||||
var tbodyRow = document.createElement('tr');
|
||||
|
||||
var tdMessagesSent = document.createElement('td');
|
||||
tdMessagesSent.textContent = sms_sent.toLocaleString(); // Value for Messages Sent
|
||||
tdMessagesSent.textContent = messagesSent.toLocaleString(); // Value for Messages Sent
|
||||
var tdRemaining = document.createElement('td');
|
||||
tdRemaining.textContent = sms_remaining_messages.toLocaleString(); // Value for Remaining
|
||||
tdRemaining.textContent = messagesRemaining.toLocaleString(); // Value for Remaining
|
||||
|
||||
tbodyRow.appendChild(tdMessagesSent);
|
||||
tbodyRow.appendChild(tdRemaining);
|
||||
|
||||
BIN
app/assets/pdf/TCPA-Overview.pdf
Normal file
BIN
app/assets/pdf/TCPA-Overview.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
app/assets/pdf/out-of-pilot-announcement.pdf
Normal file
BIN
app/assets/pdf/out-of-pilot-announcement.pdf
Normal file
Binary file not shown.
BIN
app/assets/pdf/studio-research-snapshot-2022-07-external.pdf
Normal file
BIN
app/assets/pdf/studio-research-snapshot-2022-07-external.pdf
Normal file
Binary file not shown.
@@ -1042,3 +1042,7 @@ nav.nav {
|
||||
font-size: units(3);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-control-error {
|
||||
border: 4px solid #b10e1e
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Explore Notify, add team members, and practice [sending messages to teammates](/using-notify/trial-mode).
|
||||
|
||||
2. ## Personalize content
|
||||
Learn how to [personalize messages](/using-notify/guidance) to increase response.
|
||||
Learn how to [personalize messages](/using-notify/how-to) to increase response.
|
||||
|
||||
3. ## Check delivery status
|
||||
[Analyze the delivery](/using-notify/delivery-status) of your messages and download reports
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import calendar
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from itertools import groupby
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Response, abort, jsonify, render_template, request, session, url_for
|
||||
from flask_login import current_user
|
||||
@@ -14,7 +15,7 @@ from app import (
|
||||
service_api_client,
|
||||
template_statistics_client,
|
||||
)
|
||||
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
|
||||
from app.formatters import format_date_numeric, format_datetime_numeric
|
||||
from app.main import main
|
||||
from app.main.views.user_profile import set_timezone
|
||||
from app.statistics_utils import get_formatted_percentage
|
||||
@@ -48,71 +49,105 @@ def service_dashboard(service_id):
|
||||
if not current_user.has_permissions("view_activity"):
|
||||
return redirect(url_for("main.choose_template", service_id=service_id))
|
||||
|
||||
yearly_usage = billing_api_client.get_annual_usage_for_service(
|
||||
service_id,
|
||||
get_current_financial_year(),
|
||||
)
|
||||
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
|
||||
current_service.id,
|
||||
)
|
||||
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
|
||||
|
||||
jobs = [
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"time_left": get_time_left(job["created_at"]),
|
||||
"download_link": url_for(
|
||||
".view_job_csv", service_id=current_service.id, job_id=job["id"]
|
||||
),
|
||||
"view_job_link": url_for(
|
||||
".view_job", service_id=current_service.id, job_id=job["id"]
|
||||
),
|
||||
"created_at": job["created_at"],
|
||||
"processing_finished": job.get("processing_finished"),
|
||||
"processing_started": job.get("processing_started"),
|
||||
"notification_count": job["notification_count"],
|
||||
"created_by": job["created_by"],
|
||||
"template_name": job["template_name"],
|
||||
"original_file_name": job["original_file_name"],
|
||||
}
|
||||
for job in job_response
|
||||
if job["job_status"] != "cancelled"
|
||||
active_jobs = [job for job in job_response if job["job_status"] != "cancelled"]
|
||||
sorted_jobs = sorted(active_jobs, key=lambda job: job["created_at"], reverse=True)
|
||||
job_lists = [
|
||||
{**job_dict, "finished_processing": job_is_finished(job_dict)}
|
||||
for job_dict in sorted_jobs
|
||||
]
|
||||
|
||||
total_messages = service_api_client.get_service_message_ratio(service_id)
|
||||
messages_remaining = total_messages.get("messages_remaining", 0)
|
||||
messages_sent = total_messages.get("messages_sent", 0)
|
||||
return render_template(
|
||||
"views/dashboard/dashboard.html",
|
||||
updates_url=url_for(".service_dashboard_updates", service_id=service_id),
|
||||
partials=get_dashboard_partials(service_id),
|
||||
jobs=jobs,
|
||||
jobs=job_lists,
|
||||
service_data_retention_days=service_data_retention_days,
|
||||
sms_sent=sms_sent,
|
||||
sms_allowance_remaining=sms_allowance_remaining,
|
||||
messages_remaining=messages_remaining,
|
||||
messages_sent=messages_sent,
|
||||
)
|
||||
|
||||
|
||||
@main.route("/daily_stats.json")
|
||||
def get_daily_stats():
|
||||
service_id = session.get("service_id")
|
||||
date_range = get_stats_date_range()
|
||||
def job_is_finished(job_dict):
|
||||
done_statuses = [
|
||||
"delivered",
|
||||
"sent",
|
||||
"failed",
|
||||
"technical-failure",
|
||||
"temporary-failure",
|
||||
"permanent-failure",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
stats = service_api_client.get_service_notification_statistics_by_day(
|
||||
service_id, start_date=date_range["start_date"], days=date_range["days"]
|
||||
processed_count = sum(
|
||||
stat["count"]
|
||||
for stat in job_dict["statistics"]
|
||||
if stat["status"] in done_statuses
|
||||
)
|
||||
return jsonify(stats)
|
||||
return job_dict["notification_count"] == processed_count
|
||||
|
||||
|
||||
@main.route("/daily_stats_by_user.json")
|
||||
def get_daily_stats_by_user():
|
||||
service_id = session.get("service_id")
|
||||
@main.route("/services/<uuid:service_id>/daily-stats.json")
|
||||
@user_has_permissions()
|
||||
def get_daily_stats(service_id):
|
||||
date_range = get_stats_date_range()
|
||||
days = date_range["days"]
|
||||
user_timezone = request.args.get("timezone", "UTC")
|
||||
|
||||
stats_utc = service_api_client.get_service_notification_statistics_by_day(
|
||||
service_id,
|
||||
start_date=date_range["start_date"],
|
||||
days=days,
|
||||
)
|
||||
|
||||
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days)
|
||||
return jsonify(local_stats)
|
||||
|
||||
|
||||
def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days):
|
||||
tz = ZoneInfo(user_timezone)
|
||||
today_local = datetime.now(tz).date()
|
||||
start_local = today_local - timedelta(days=days - 1)
|
||||
|
||||
# Generate exactly days local dates, each with zeroed stats
|
||||
days_list = [
|
||||
(start_local + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(days)
|
||||
]
|
||||
aggregator = {
|
||||
d: {
|
||||
"sms": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
|
||||
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
|
||||
}
|
||||
for d in days_list
|
||||
}
|
||||
|
||||
# Convert each UTC timestamp to local date and iterate
|
||||
for utc_ts, data in stats_utc.items():
|
||||
utc_dt = datetime.strptime(utc_ts, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
local_day = utc_dt.astimezone(tz).strftime("%Y-%m-%d")
|
||||
|
||||
if local_day in aggregator:
|
||||
for msg_type in ["sms", "email"]:
|
||||
for status in ["delivered", "failure", "pending", "requested"]:
|
||||
aggregator[local_day][msg_type][status] += data[msg_type][status]
|
||||
|
||||
return aggregator
|
||||
|
||||
|
||||
@main.route("/services/<uuid:service_id>/daily-stats-by-user.json")
|
||||
@user_has_permissions()
|
||||
def get_daily_stats_by_user(service_id):
|
||||
date_range = get_stats_date_range()
|
||||
user_id = current_user.id
|
||||
stats = service_api_client.get_user_service_notification_statistics_by_day(
|
||||
service_id,
|
||||
user_id,
|
||||
user_id=current_user.id,
|
||||
start_date=date_range["start_date"],
|
||||
days=date_range["days"],
|
||||
)
|
||||
|
||||
@@ -217,11 +217,11 @@ def benchmark_performance():
|
||||
)
|
||||
|
||||
|
||||
@main.route("/using-notify/guidance")
|
||||
@main.route("/using-notify/how-to")
|
||||
@user_is_logged_in
|
||||
def guidance_index():
|
||||
def how_to():
|
||||
return render_template(
|
||||
"views/guidance/index.html",
|
||||
"views/how-to/index.html",
|
||||
navigation_links=using_notify_nav(),
|
||||
)
|
||||
|
||||
@@ -266,29 +266,29 @@ def join_notify():
|
||||
)
|
||||
|
||||
|
||||
@main.route("/using-notify/guidance/create-and-send-messages")
|
||||
@main.route("/using-notify/how-to/create-and-send-messages")
|
||||
@user_is_logged_in
|
||||
def create_and_send_messages():
|
||||
return render_template(
|
||||
"views/guidance/create-and-send-messages.html",
|
||||
"views/how-to/create-and-send-messages.html",
|
||||
navigation_links=using_notify_nav(),
|
||||
)
|
||||
|
||||
|
||||
@main.route("/using-notify/guidance/edit-and-format-messages")
|
||||
@main.route("/using-notify/how-to/edit-and-format-messages")
|
||||
@user_is_logged_in
|
||||
def edit_and_format_messages():
|
||||
return render_template(
|
||||
"views/guidance/edit-and-format-messages.html",
|
||||
"views/how-to/edit-and-format-messages.html",
|
||||
navigation_links=using_notify_nav(),
|
||||
)
|
||||
|
||||
|
||||
@main.route("/using-notify/guidance/send-files-by-email")
|
||||
@main.route("/using-notify/how-to/send-files-by-email")
|
||||
@user_is_logged_in
|
||||
def send_files_by_email():
|
||||
return render_template(
|
||||
"views/guidance/send-files-by-email.html",
|
||||
"views/how-to/send-files-by-email.html",
|
||||
navigation_links=using_notify_nav(),
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ def view_job(service_id, job_id):
|
||||
|
||||
filter_args = parse_filter_args(request.args)
|
||||
filter_args["status"] = set_status_filters(filter_args)
|
||||
|
||||
return render_template(
|
||||
"views/jobs/job.html",
|
||||
job=job,
|
||||
@@ -402,7 +401,9 @@ def get_job_partials(job):
|
||||
)
|
||||
|
||||
if request.referrer is not None:
|
||||
session["arrived_from_preview_page"] = "check" in request.referrer
|
||||
session["arrived_from_preview_page"] = ("check" in request.referrer) or (
|
||||
"help=0" in request.referrer
|
||||
)
|
||||
else:
|
||||
session["arrived_from_preview_page"] = False
|
||||
|
||||
|
||||
@@ -116,6 +116,70 @@ def download_all_users():
|
||||
return response
|
||||
|
||||
|
||||
@main.route("/platform-admin/get-redis-report")
|
||||
@user_is_platform_admin
|
||||
def get_redis_report():
|
||||
|
||||
memory_info = redis_client.info("memory")
|
||||
memory_used = memory_info.get("used_memory_human", "N/A")
|
||||
max_memory = memory_info.get("maxmemory_human", "N/A")
|
||||
if max_memory == "0B":
|
||||
max_memory = "No set limit"
|
||||
mem_fragmentation = memory_info.get("mem_fragmentation_ratio", "N/A")
|
||||
frag_quality = "Swapping (bad)"
|
||||
if mem_fragmentation >= 1.0:
|
||||
frag_quality = "Healthy"
|
||||
if mem_fragmentation > 1.5:
|
||||
frag_quality = "Problematic"
|
||||
if mem_fragmentation > 2.0:
|
||||
frag_quality = "Severe fragmentation"
|
||||
|
||||
frag_note = ""
|
||||
if mem_fragmentation > 2.0:
|
||||
frag_note = "Use MEMORY PURGE.\nReplace multiple small keys with hashes.\nAvoid long keys.\nSet max_memory."
|
||||
elif mem_fragmentation < 1.0:
|
||||
frag_note = "Allocate more RAM.\nSet max_memory."
|
||||
|
||||
keys = redis_client.keys("*")
|
||||
key_details = []
|
||||
|
||||
for key in keys:
|
||||
key_type = redis_client.type(key).decode("utf-8")
|
||||
ttl = redis_client.ttl(key)
|
||||
ttl_str = "No Expiry" if ttl == -1 else f"{ttl} seconds"
|
||||
key_details.append(
|
||||
{"Key": key.decode("utf-8"), "Type": key_type, "TTL": ttl_str}
|
||||
)
|
||||
output = StringIO()
|
||||
writer = csv.writer(
|
||||
output,
|
||||
)
|
||||
writer.writerow(["Redis Report"])
|
||||
writer.writerow([])
|
||||
|
||||
writer.writerow(["Memory"])
|
||||
writer.writerow(["", "Memory Used", memory_used])
|
||||
writer.writerow(["", "Max Memory", max_memory])
|
||||
writer.writerow(["", "Memory Fragmentation Ratio", mem_fragmentation])
|
||||
writer.writerow(["", "Memory Fragmentation Quality", frag_quality, frag_note])
|
||||
writer.writerow([])
|
||||
|
||||
writer.writerow(["Keys Overview"])
|
||||
writer.writerow(["", "TTL", "Type", "Key"])
|
||||
for key_detail in key_details:
|
||||
writer.writerow(
|
||||
["", key_detail["TTL"], key_detail["Type"], key_detail["Key"][0:50]]
|
||||
)
|
||||
|
||||
csv_data = output.getvalue()
|
||||
|
||||
# Create a direct download response with the CSV data and appropriate headers
|
||||
response = Response(csv_data, content_type="text/csv; charset=utf-8")
|
||||
response.headers["Content-Disposition"] = "attachment; filename=redis.csv"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def is_over_threshold(number, total, threshold):
|
||||
percentage = number / total * 100 if total else 0
|
||||
return percentage > threshold
|
||||
|
||||
@@ -68,11 +68,12 @@ def _get_access_token(code): # pragma: no cover
|
||||
id_token = get_id_token(response_json)
|
||||
nonce = id_token["nonce"]
|
||||
nonce_key = f"login-nonce-{unquote(nonce)}"
|
||||
stored_nonce = redis_client.get(nonce_key).decode("utf8")
|
||||
if not os.getenv("NOTIFY_ENVIRONMENT") == "development":
|
||||
stored_nonce = redis_client.get(nonce_key).decode("utf8")
|
||||
|
||||
if nonce != stored_nonce:
|
||||
current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}")
|
||||
abort(403)
|
||||
if nonce != stored_nonce:
|
||||
current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}")
|
||||
abort(403)
|
||||
|
||||
try:
|
||||
access_token = response_json["access_token"]
|
||||
@@ -112,7 +113,7 @@ def _do_login_dot_gov(): # $ pragma: no cover
|
||||
verify_key = f"login-verify_email-{unquote(state)}"
|
||||
verify_path = bool(redis_client.get(verify_key))
|
||||
|
||||
if not verify_path:
|
||||
if not verify_path and not os.getenv("NOTIFY_ENVIRONMENT") == "development":
|
||||
state_key = f"login-state-{unquote(state)}"
|
||||
stored_state = unquote(redis_client.get(state_key).decode("utf8"))
|
||||
if state != stored_state:
|
||||
|
||||
@@ -2,7 +2,7 @@ def using_notify_nav():
|
||||
nav_items = [
|
||||
{"name": "Get started", "link": "main.get_started"},
|
||||
{
|
||||
"name": "Best Practices",
|
||||
"name": "Best practices",
|
||||
"link": "main.best_practices",
|
||||
"sub_navigation_items": [
|
||||
{
|
||||
@@ -33,8 +33,8 @@ def using_notify_nav():
|
||||
},
|
||||
{"name": "Trial mode", "link": "main.trial_mode_new"},
|
||||
{"name": "Tracking usage", "link": "main.pricing"},
|
||||
{"name": "Delivery Status", "link": "main.message_status"},
|
||||
{"name": "Guidance", "link": "main.guidance_index"},
|
||||
{"name": "Delivery status", "link": "main.message_status"},
|
||||
{"name": "How to", "link": "main.how_to"},
|
||||
]
|
||||
|
||||
return nav_items
|
||||
|
||||
@@ -54,7 +54,7 @@ class HeaderNavigation(Navigation):
|
||||
"pricing",
|
||||
"trial_mode_new",
|
||||
"message_status",
|
||||
"guidance_index",
|
||||
"how_to",
|
||||
},
|
||||
"accounts-or-dashboard": {
|
||||
"conversation",
|
||||
|
||||
@@ -6,17 +6,36 @@ from app.notify_client import NotifyAdminAPIClient
|
||||
|
||||
class BillingAPIClient(NotifyAdminAPIClient):
|
||||
def get_monthly_usage_for_service(self, service_id, year):
|
||||
return self.get(
|
||||
monthly_usage = redis_client.get(f"monthly-usage-summary-{service_id}-{year}")
|
||||
if monthly_usage is not None:
|
||||
return json.loads(monthly_usage.decode("utf-8"))
|
||||
result = self.get(
|
||||
"/service/{0}/billing/monthly-usage".format(service_id),
|
||||
params=dict(year=year),
|
||||
)
|
||||
redis_client.set(
|
||||
f"monthly-usage-summary-{service_id}-{year}",
|
||||
json.dumps(result),
|
||||
ex=30,
|
||||
)
|
||||
return result
|
||||
|
||||
def get_annual_usage_for_service(self, service_id, year=None):
|
||||
return self.get(
|
||||
annual_usage = redis_client.get(f"yearly-usage-summary-{service_id}-{year}")
|
||||
if annual_usage is not None:
|
||||
return json.loads(annual_usage.decode("utf-8"))
|
||||
result = self.get(
|
||||
"/service/{0}/billing/yearly-usage-summary".format(service_id),
|
||||
params=dict(year=year),
|
||||
)
|
||||
|
||||
redis_client.set(
|
||||
f"yearly-usage-summary-{service_id}-{year}",
|
||||
json.dumps(result),
|
||||
ex=30,
|
||||
)
|
||||
return result
|
||||
|
||||
def get_free_sms_fragment_limit_for_year(self, service_id, year=None):
|
||||
frag_limit = redis_client.get(f"free-sms-fragment-limit-{service_id}-{year}")
|
||||
if frag_limit is not None:
|
||||
@@ -48,13 +67,28 @@ class BillingAPIClient(NotifyAdminAPIClient):
|
||||
)
|
||||
|
||||
def get_data_for_billing_report(self, start_date, end_date):
|
||||
return self.get(
|
||||
x_start_date = str(start_date)
|
||||
x_start_date = x_start_date.replace(" ", "_")
|
||||
x_end_date = str(end_date)
|
||||
x_end_date = x_end_date.replace(" ", "_")
|
||||
billing_data = redis_client.get(
|
||||
f"get-data-for-billing-report-{x_start_date}-{x_end_date}"
|
||||
)
|
||||
if billing_data is not None:
|
||||
return json.loads(billing_data.decode("utf-8"))
|
||||
result = self.get(
|
||||
url="/platform-stats/data-for-billing-report",
|
||||
params={
|
||||
"start_date": str(start_date),
|
||||
"end_date": str(end_date),
|
||||
},
|
||||
)
|
||||
redis_client.set(
|
||||
f"get-data-for-billing-report-{x_start_date}-{x_end_date}",
|
||||
json.dumps(result),
|
||||
ex=30,
|
||||
)
|
||||
return result
|
||||
|
||||
def get_data_for_volumes_by_service_report(self, start_date, end_date):
|
||||
return self.get(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
|
||||
from app.extensions import redis_client
|
||||
from app.notify_client import NotifyAdminAPIClient, _attach_current_user
|
||||
|
||||
|
||||
@@ -41,7 +44,7 @@ class NotificationApiClient(NotifyAdminAPIClient):
|
||||
if job_id:
|
||||
return method(
|
||||
url="/service/{}/job/{}/notifications".format(service_id, job_id),
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
if limit_days is not None:
|
||||
@@ -96,9 +99,20 @@ class NotificationApiClient(NotifyAdminAPIClient):
|
||||
)
|
||||
|
||||
def get_notification_count_for_job_id(self, *, service_id, job_id):
|
||||
return self.get(
|
||||
counts = redis_client.get(
|
||||
f"notification-count-for-job-id-{service_id}-{job_id}"
|
||||
)
|
||||
if counts is not None:
|
||||
return json.loads(counts.decode("utf-8"))
|
||||
result = self.get(
|
||||
url="/service/{}/job/{}/notification_count".format(service_id, job_id)
|
||||
)["count"]
|
||||
)
|
||||
redis_client.set(
|
||||
f"notification-count-for-job-id-{service_id}-{job_id}",
|
||||
json.dumps(result["count"]),
|
||||
ex=30,
|
||||
)
|
||||
return result["count"]
|
||||
|
||||
|
||||
notification_api_client = NotificationApiClient()
|
||||
|
||||
@@ -537,6 +537,11 @@ class ServiceAPIClient(NotifyAdminAPIClient):
|
||||
"""
|
||||
return self.get("/service/invite/redis/{0}".format(redis_key))
|
||||
|
||||
def get_service_message_ratio(self, service_id):
|
||||
return self.get(
|
||||
url="service/get-service-message-ratio?service_id={0}".format(service_id),
|
||||
)
|
||||
|
||||
|
||||
service_api_client = ServiceAPIClient()
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class UserApiClient(NotifyAdminAPIClient):
|
||||
data["next"] = next_string
|
||||
if code_type == "email":
|
||||
data["email_auth_link_host"] = self.admin_url
|
||||
endpoint = f"/user/{user_id}/{code_type}-code"
|
||||
endpoint = f"/user/{user_id}/{code_type}-code"
|
||||
current_app.logger.warn(hilite(f"Sending verify_code {code_type} to {user_id}"))
|
||||
self.post(endpoint, data=data)
|
||||
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
<script nonce="{{ csp_nonce() }}">document.body.className = ((document.body.className) ? document.body.className + ' js-enabled' : 'js-enabled');</script>
|
||||
{% block bodyStart %}
|
||||
{% block extra_javascripts_before_body %}
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WX5NGWF"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -145,10 +143,8 @@
|
||||
{% block bodyEnd %}
|
||||
{% block extra_javascripts %}
|
||||
{% endblock %}
|
||||
<!--[if gt IE 8]><!-->
|
||||
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
|
||||
<script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script>
|
||||
<!--<![endif]-->
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
attributes: params.errorMessage.attributes,
|
||||
html: params.errorMessage.html,
|
||||
text: params.errorMessage.text,
|
||||
visuallyHiddenText: params.errorMessage.visuallyHiddenText
|
||||
visuallyHiddenText: params.errorMessage.visuallyHiddenText,
|
||||
}) | indent(2) | trim }}
|
||||
{% endif %}
|
||||
<input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}"
|
||||
@@ -42,5 +42,7 @@
|
||||
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
|
||||
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
|
||||
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
|
||||
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}>
|
||||
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
|
||||
{%- if params.required %} required{% endif %}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,19 +16,9 @@
|
||||
placeholder=''
|
||||
) %}
|
||||
<div
|
||||
class="form-group{% if field.errors %} form-group-error{% endif %} {{ extra_form_group_classes }}"
|
||||
class="usa-form-group{% if field.errors %} usa-form-group--error{% endif %} {{ extra_form_group_classes }}"
|
||||
data-module="{% if autofocus %}autofocus{% elif colour_preview %}colour-preview{% endif %}"
|
||||
>
|
||||
{% if field.errors %}
|
||||
<div class="usa-alert usa-alert--error edit-textbox-error-mt" role="alert">
|
||||
<div class="usa-alert__body">
|
||||
<h4 class="usa-alert__heading">Error message</h4>
|
||||
<p class="usa-alert__text" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
|
||||
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<label class="usa-label" for="{{ field.name }}">
|
||||
{% if label %}
|
||||
{{ label }}
|
||||
@@ -41,6 +31,12 @@
|
||||
{{ hint }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<span id="{{ field.name}}-error" class="usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
|
||||
<span class="usa-sr-only">Error:</span>
|
||||
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{%
|
||||
if highlight_placeholders or autosize
|
||||
%}
|
||||
@@ -59,6 +55,8 @@
|
||||
data_highlight_placeholders='true' if highlight_placeholders else 'false',
|
||||
rows=rows|string,
|
||||
placeholder=placeholder,
|
||||
aria_describedby=field.name+"-error",
|
||||
required='required' if required else None,
|
||||
**kwargs
|
||||
) }}
|
||||
{% if suffix %}
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
|
||||
<div class="ajax-block-container">
|
||||
<p class='bottom-gutter'>
|
||||
{% if job.still_processing or arrived_from_preview_page_url %}
|
||||
{% if not job.finished_processing %}
|
||||
{% if job.scheduled_for %}
|
||||
<div class="usa-alert usa-alert--info">
|
||||
<div class="usa-alert__body">
|
||||
<h2 class="usa-alert__heading">Your text has been scheduled</h2>
|
||||
<h2 class="usa-alert__heading">Your {{ 'message has' if job.notification_count == 1 else 'messages have' }} been scheduled</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }} was scheduled on {{ job.scheduled_for|format_datetime_normal }} by {{ job.created_by.name }}
|
||||
</p>
|
||||
@@ -33,18 +33,46 @@
|
||||
</div>
|
||||
{{display_message_status}}
|
||||
{% else %}
|
||||
<div class="usa-alert usa-alert--success">
|
||||
<div class="usa-alert__body">
|
||||
<h2 class="usa-alert__heading">Your text has been sent</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }} was sent on {% if job.processing_started %}
|
||||
{{ job.processing_started|format_datetime_table }} {% else %}
|
||||
{{ job.created_at|format_datetime_table }} {% endif %} by {{ job.created_by.name }}
|
||||
</p>
|
||||
{% if job.processing_started %}
|
||||
<div class="usa-alert usa-alert--success">
|
||||
<div class="usa-alert__body">
|
||||
<h2 class="usa-alert__heading">
|
||||
Your {{ 'message is' if job.notification_count == 1 else 'messages are' }} sending
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
has been sending since {{job.processing_started| format_datetime_normal}} by {{ job.created_by.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="usa-alert usa-alert--info">
|
||||
<div class="usa-alert__body">
|
||||
<h2 class="usa-alert__heading">
|
||||
Your {{ 'message is' if job.notification_count == 1 else 'messages are' }} pending
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
has been pending since {{job.created_at|format_datetime_normal}} by {{ job.created_by.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{display_message_status}}
|
||||
{% endif %}
|
||||
{% elif arrived_from_preview_page_url %}
|
||||
<div class="usa-alert usa-alert--success">
|
||||
<div class="usa-alert__body">
|
||||
<h2 class="usa-alert__heading">
|
||||
Your {{ 'message has' if job.notification_count == 1 else 'messages have' }} been sent
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
was sent on {{job.processing_started|format_datetime_normal}} by {{ job.created_by.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{display_message_status}}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if job.status == 'sending limits exceeded'%}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set page_title = "About Notify" %}
|
||||
|
||||
{% block per_page_title %}
|
||||
{{page_title}}
|
||||
{% endblock %}
|
||||
@@ -27,7 +26,7 @@
|
||||
{% set product_highlights = [
|
||||
{
|
||||
"svg_src": "#send",
|
||||
"card_heading": "Send customized one-way customized messages",
|
||||
"card_heading": "Send customized one-way messages",
|
||||
"p_text": "Upload a file with recipient phone numbers and Notify.gov sends customized messages",
|
||||
},
|
||||
{
|
||||
|
||||
74
app/templates/views/dashboard/activity-table.html
Normal file
74
app/templates/views/dashboard/activity-table.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2>
|
||||
<div id="activityChartContainer">
|
||||
<form class="usa-form">
|
||||
<label class="usa-label" for="options">Account</label>
|
||||
<select class="usa-select margin-bottom-2" name="options" id="options">
|
||||
<option value disabled>- Select -</option>
|
||||
<option value="service" selected>{{ current_service.name }}</option>
|
||||
<option value="individual">{{ current_user.name }}</option>
|
||||
</select>
|
||||
</form>
|
||||
<div id="activityChart">
|
||||
<div class="chart-header">
|
||||
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
|
||||
<div class="chart-legend" role="region" aria-label="Legend"></div>
|
||||
</div>
|
||||
<div class="chart-container" id="weeklyChart"></div>
|
||||
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
|
||||
<div class="table-container" id="activityContainer" data-currentUserName="{{ current_user.name }}" data-currentServiceId="{{current_service.id}}">
|
||||
<div id="tableActivity" class="table-overflow-x-auto">
|
||||
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
|
||||
|
||||
<table class="usa-table job-table" id="activity-table">
|
||||
<caption class="usa-sr-only">Table showing the sent jobs for {{current_service.name}}</caption>
|
||||
|
||||
<thead class="table-field-headings">
|
||||
<tr>
|
||||
<th scope="col" class="table-field-heading-first" id="jobId">Job ID#</th>
|
||||
<th data-sortable scope="col" class="table-field-heading" scope="col">Template</th>
|
||||
<th data-sortable scope="col" class="table-field-heading">Job status</th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading sender-column">Sender
|
||||
</th>
|
||||
<th data-sortable scope="col" class="table-field-heading"># of Recipients</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if jobs %}
|
||||
{% for job in jobs %}
|
||||
<tr id="{{ job.id }}">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<a class="usa-link" href="{{ url_for('.view_job', service_id=current_service.id, job_id=job.id )}}">
|
||||
{{ job.id[:8] if job.id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td class="table-field time-sent">
|
||||
{% if not job.finished_processing %}
|
||||
{% if job.scheduled_for%}
|
||||
Scheduled for {{ job.scheduled_for|format_datetime_table }}
|
||||
{% elif job.processing_started %}
|
||||
Sending since {{ job.processing_started|format_datetime_table }}
|
||||
{% else %}
|
||||
Pending since {{ job.created_at|format_datetime_table }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Sent on {{ job.processing_started|format_datetime_table }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
|
||||
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr class="table-row">
|
||||
<td class="table-empty-message" colspan="10">No batched job messages found  (messages are
|
||||
kept for {{ service_data_retention_days }} days).</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,151 +6,49 @@
|
||||
{% from "components/ajax-block.html" import ajax_block %}
|
||||
|
||||
{% block service_page_title %}
|
||||
Dashboard
|
||||
Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block maincolumn_content %}
|
||||
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
|
||||
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
|
||||
|
||||
|
||||
<div class="dashboard margin-top-0 margin-bottom-2">
|
||||
<div class="dashboard margin-top-0 margin-bottom-2">
|
||||
|
||||
<h1 class="usa-sr-only">Dashboard</h1>
|
||||
{% if current_user.has_permissions('manage_templates') and not current_service.all_templates %}
|
||||
{% include 'views/dashboard/write-first-messages.html' %}
|
||||
{% endif %}
|
||||
<h1 class="usa-sr-only">Dashboard</h1>
|
||||
{% if current_user.has_permissions('manage_templates') and not current_service.all_templates %}
|
||||
{% include 'views/dashboard/write-first-messages.html' %}
|
||||
{% endif %}
|
||||
|
||||
{{ ajax_block(partials, updates_url, 'upcoming') }}
|
||||
{{ ajax_block(partials, updates_url, 'upcoming') }}
|
||||
|
||||
<h2 class="font-body-2xl line-height-sans-2 margin-top-0">{{ current_service.name }} Dashboard</h2>
|
||||
<h2 class="font-body-2xl line-height-sans-2 margin-top-0">{{ current_service.name }} Dashboard</h2>
|
||||
|
||||
{{ ajax_block(partials, updates_url, 'inbox') }}
|
||||
{{ ajax_block(partials, updates_url, 'inbox') }}
|
||||
|
||||
<div id="totalMessageChartContainer" data-sms-sent="{{ sms_sent }}" data-sms-allowance-remaining="{{ sms_allowance_remaining }}">
|
||||
<div class="grid-row flex-align-center">
|
||||
<h2 id="chartTitle" class="margin-right-1">Total messages</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-tooltip usa-tooltip__information margin-right-0"
|
||||
data-position="top"
|
||||
title="Total messages track the sum of messages for the service: pending, failed, or delivered"
|
||||
>
|
||||
<span class="usa-sr-only">More information</span>
|
||||
i
|
||||
</button>
|
||||
</div>
|
||||
<svg id="totalMessageChart"></svg>
|
||||
<div id="message"></div>
|
||||
</div>
|
||||
<div id="totalMessageTable" class="margin-0"></div>
|
||||
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2>
|
||||
<div id="activityChartContainer">
|
||||
<form class="usa-form">
|
||||
<label class="usa-label" for="options">Account</label>
|
||||
<select class="usa-select margin-bottom-2" name="options" id="options">
|
||||
<option value disabled>- Select -</option>
|
||||
<option value="service" selected>{{ current_service.name }}</option>
|
||||
<option value="individual">{{ current_user.name }}</option>
|
||||
</select>
|
||||
</form>
|
||||
<div id="activityChart">
|
||||
<div class="chart-header">
|
||||
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
|
||||
<div class="chart-legend" role="region" aria-label="Legend"></div>
|
||||
</div>
|
||||
<div class="chart-container" id="weeklyChart"></div>
|
||||
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
|
||||
|
||||
{% if current_user.has_permissions('manage_service') %}{% endif %}
|
||||
|
||||
<div class="table-container">
|
||||
<div id="table1" class="table-overflow-x-auto hidden">
|
||||
<h2 class="margin-top-4 margin-bottom-1">My activity</h2>
|
||||
<table class="usa-table job-table">
|
||||
<caption class="usa-sr-only">Table showing the sent jobs for {{current_user.name}}</caption>
|
||||
<thead class="table-field-headings">
|
||||
<tr>
|
||||
<th scope="col" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
|
||||
<th data-sortable scope="col" class="table-field-heading"><span>Template</span></th>
|
||||
<th data-sortable scope="col" class="table-field-heading"><span>Job status</span></th>
|
||||
<th data-sortable scope="col" class="table-field-heading"><span># of Recipients</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if jobs %}
|
||||
{% for job in jobs[:5] %}
|
||||
{% if job.created_by.name == current_user.name %}
|
||||
{% set notification = job.notifications[0] %}
|
||||
<tr id="{{ job.job_id }}">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<a class="usa-link" href="{{ job.view_job_link }}">
|
||||
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td class="table-field time-sent">Sent on
|
||||
{{ (job.processing_finished if job.processing_finished else job.processing_started
|
||||
if job.processing_started else job.created_at)|format_datetime_table }}
|
||||
</td>
|
||||
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr class="table-row">
|
||||
<td class="table-empty-message" colspan="10">No batched job messages found  (messages are kept for {{ service_data_retention_days }} days).</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="table2" class="table-overflow-x-auto visible">
|
||||
<h2 class="margin-top-4 margin-bottom-1">Service activity</h2>
|
||||
<table class="usa-table job-table">
|
||||
<caption class="usa-sr-only">Table showing the sent jobs for this service</caption>
|
||||
<thead class="table-field-headings">
|
||||
<tr>
|
||||
<th scope="col" role="columnheader" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Template</span></th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Job status</span></th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Sender</span></th>
|
||||
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span># of Recipients</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if jobs %}
|
||||
{% for job in jobs[:5] %}
|
||||
{% set notification = job.notifications[0] %}
|
||||
<tr id="{{ job.job_id }}">
|
||||
<td class="table-field jobid" role="rowheader">
|
||||
<a class="usa-link" href="{{ job.view_job_link }}">
|
||||
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-field template">{{ job.template_name }}</td>
|
||||
<td class="table-field time-sent">Sent on
|
||||
{{ (job.processing_finished if job.processing_finished else job.processing_started
|
||||
if job.processing_started else job.created_at)|format_datetime_table }}
|
||||
</td>
|
||||
<td class="table-field sender">{{ job.created_by.name }}</td>
|
||||
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr class="table-row">
|
||||
<td class="table-empty-message" colspan="10">No batched job messages found  (messages are kept for {{ service_data_retention_days }} days).</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{ ajax_block(partials, updates_url, 'template-statistics') }}
|
||||
<div id="totalMessageChartContainer" data-messages-sent="{{ messages_sent }}" data-messages-remaining="{{ messages_remaining }}">
|
||||
<div class="grid-row flex-align-center">
|
||||
<h2 id="chartTitle" class="margin-right-1">Total messages</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-tooltip usa-tooltip__information margin-right-0"
|
||||
data-position="top"
|
||||
title="Total messages track the sum of messages for the service: pending, failed, or delivered"
|
||||
>
|
||||
<span class="usa-sr-only">More information</span>
|
||||
i
|
||||
</button>
|
||||
</div>
|
||||
<svg id="totalMessageChart"></svg>
|
||||
<div id="message"></div>
|
||||
</div>
|
||||
<div id="totalMessageTable" class="margin-0"></div>
|
||||
|
||||
{% include 'views/dashboard/activity-table.html' %}
|
||||
|
||||
{% if current_user.has_permissions('manage_service') %}{% endif %}
|
||||
|
||||
{{ ajax_block(partials, updates_url, 'template-statistics') }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<div class="tablet:grid-col-9 mobile-lg:grid-col-12">
|
||||
{{ form.name(param_extensions={
|
||||
"extra_form_group_classes": "margin-bottom-2",
|
||||
"id": "name",
|
||||
"required": True,
|
||||
"hint": {"text": "Your recipients will not see this"}
|
||||
}) }}
|
||||
{{ textbox(
|
||||
@@ -41,7 +43,8 @@
|
||||
hint=content_hint,
|
||||
rows=5,
|
||||
extra_form_group_classes='margin-bottom-1',
|
||||
placeholder='Edit me! Check out the Personalization section below for details on cool ((stuff)) you can do with your messages!'
|
||||
placeholder='Edit me! Check out the Personalization section below for details on cool ((stuff)) you can do with your messages!',
|
||||
required=True
|
||||
) }}
|
||||
{% if current_user.platform_admin %}
|
||||
{{ form.process_type }}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<h2 class="heading-medium" id="personalised-messages">Personalized content</h2>
|
||||
<p class="usa-body">Notify makes it easy to send personalized messages from a single template.</p>
|
||||
<p class="usa-body">See <a class="usa-link" href="{{ url_for('.guidance_index', _anchor='personalized-content') }}">how to personalize your content</a>.</p>
|
||||
<p class="usa-body">See <a class="usa-link" href="{{ url_for('.how_to', _anchor='personalized-content') }}">how to personalize your content</a>.</p>
|
||||
|
||||
<h2 class="heading-medium" id="bulk-sending">Bulk sending</h2>
|
||||
<p class="usa-body">To send a batch of messages at once, upload a list of contact details to Notify. You can also schedule the date and time you want them to be sent.</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set page_title = "Best Practices" %}
|
||||
{% set page_title = "Best practices" %}
|
||||
|
||||
{% block per_page_title %}
|
||||
{{page_title}}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{% block content_column_content %}
|
||||
<section class="usa-prose">
|
||||
<h1>Best Practices</h1>
|
||||
<h1>Best practices</h1>
|
||||
<p class="font-sans-lg text-base">For texting the public</p>
|
||||
<p>Effectively reaching your audience and supporting your program’s goals starts with strategically planning out what
|
||||
text messages can help you achieve and how to approach a thoughtful rollout.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
|
||||
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
|
||||
|
||||
<section class="usa-prose">
|
||||
<h1>{{page_title}}</h1>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
{% from "components/service-link.html" import service_link %}
|
||||
|
||||
{% block per_page_title %}
|
||||
Guidance
|
||||
How to
|
||||
{% endblock %}
|
||||
|
||||
{% block content_column_content %}
|
||||
<h1 class="font-body-2xl margin-bottom-3">Guidance</h1>
|
||||
<h1 class="font-body-2xl margin-bottom-3">How to</h1>
|
||||
|
||||
<p>Notify allows you to easily create templates for messages for your recipients. You can customize messages to encourage
|
||||
your recipient to manage their benefits and increase follow-through.</p>
|
||||
@@ -61,7 +61,7 @@ your recipient to manage their benefits and increase follow-through.</p>
|
||||
<ol class="list">
|
||||
<li>Add a placeholder to your content by placing two brackets around the personalized elements.</li>
|
||||
<li>You can manually enter the personalized content or you can upload a spreadsheet with the details and let Notify do the
|
||||
work for you. See <a href="#prepare-data">data preparation</a>.</li>
|
||||
work for you.</li>
|
||||
</ol>
|
||||
|
||||
<h4>Example</h4>
|
||||
@@ -80,7 +80,7 @@ all or part of the message contingent upon specific criteria associated with the
|
||||
<ol class="list">
|
||||
<li>Use two brackets and ?? to define the conditional content.</li>
|
||||
<li>You can manually enter the conditional content or you can upload a spreadsheet with the personal details and let Notify
|
||||
do the work for you. See <a href="#prepare-data">data preparation</a>.</li>
|
||||
do the work for you.</li>
|
||||
</ol>
|
||||
|
||||
<h4>Examples</h4>
|
||||
@@ -11,7 +11,18 @@
|
||||
{% block maincolumn_content %}
|
||||
|
||||
{{ page_header("Message status") }}
|
||||
{{ partials['status']|safe }}
|
||||
{% if not job.finished_processing %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
data-resource="{{ updates_url }}"
|
||||
data-key="status"
|
||||
data-form=""
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials['status']|safe }}
|
||||
{% if not job.finished_processing %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not finished %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
|
||||
@@ -34,5 +34,8 @@
|
||||
<p>
|
||||
<a class="usa-link" href="{{ url_for('main.download_all_users') }}">Download All Users</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="usa-link" href="{{ url_for('main.get_redis_report') }}">Get Redis Report</a>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
data_kwargs={'force-focus': True}
|
||||
) %}
|
||||
<div class="grid-row">
|
||||
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}" aria-live="polite" role="alert">
|
||||
{{ form.placeholder_value(param_extensions={"classes": ""}) }}
|
||||
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
|
||||
{{ form.placeholder_value(param_extensions={"classes": "", "id": "phone-number"}) }}
|
||||
</div>
|
||||
{% if skip_link or link_to_upload %}
|
||||
<div class="grid-col-12 margin-top-1">
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
<h1 class="font-body-2xl margin-bottom-3">Contact us</h1>
|
||||
<p>Notify is designed to be easy to use.</p>
|
||||
<ul class="list list-bullet">
|
||||
<li>For information on personalization and data preparation, see <a href={{ url_for("main.guidance_index") }}>Guidance</a>.</li>
|
||||
<li>For help interpreting delivery reports, see <a href={{ url_for("main.message_status") }}>Delivery Status</a>.</li>
|
||||
<li>For details on pricing and what counts as a message part, see <a href={{ url_for("main.pricing") }}>Pricing</a>.</li>
|
||||
<li>For information on personalization, see <a href={{ url_for("main.how_to") }}>How to</a>.</li>
|
||||
<li>For help interpreting delivery reports, see <a href={{ url_for("main.message_status") }}>Delivery status</a>.</li>
|
||||
</ul>
|
||||
|
||||
<p>If you have other questions, we are available at <a class="usa-link" href="mailto:notify-support@gsa.gov">notify-support@gsa.gov</a>.</p>
|
||||
|
||||
@@ -90,8 +90,4 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!--<div class="">
|
||||
{{ copy_to_clipboard(template.id, name="Template ID", thing='template ID') }}
|
||||
</div>-->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
<h2 class="font-body-lg">Limits while in trial mode</h2>
|
||||
<p>While your service is in trial mode you can only:</p>
|
||||
<ul class="list list-bullet">
|
||||
<li>send 50 text message parts per day</li>
|
||||
<li>send 50 text messages per day</li>
|
||||
<li>send messages to yourself and other people in your team</li>
|
||||
</ul>
|
||||
<p>Each text message is made up of one or more parts.</p>
|
||||
<ul class="list list-bullet">
|
||||
<li>Generally, 160 characters is one part (and one text message).</li>
|
||||
<li>A text message of 160-306 characters is two parts.</li>
|
||||
</ul>
|
||||
<p>For more information on how message parts are calculated, see
|
||||
<a href="/using-notify/pricing">Tracking usage</a>.</p>
|
||||
|
||||
<h2 class="font-body-lg">Before going Live</h2>
|
||||
<p>Before you request to make your service live so you can send messages to clients:</p>
|
||||
|
||||
@@ -128,6 +128,13 @@ def generate_notifications_csv(**kwargs):
|
||||
notifications_resp = notification_api_client.get_notifications_for_service(
|
||||
**kwargs
|
||||
)
|
||||
# Stop if we are finished
|
||||
if (
|
||||
notifications_resp.get("notifications") is None
|
||||
or len(notifications_resp["notifications"]) == 0
|
||||
):
|
||||
return
|
||||
|
||||
for notification in notifications_resp["notifications"]:
|
||||
preferred_tz_created_at = convert_report_date_to_preferred_timezone(
|
||||
notification["created_at"]
|
||||
|
||||
Reference in New Issue
Block a user