Merge branch 'main' of https://github.com/GSA/notifications-admin into 2214-client-side-validation

This commit is contained in:
Jonathan Bobel
2025-03-03 16:11:05 -05:00
15 changed files with 522 additions and 305 deletions
+2 -2
View File
@@ -527,7 +527,7 @@
"filename": "tests/app/main/views/test_accept_invite.py", "filename": "tests/app/main/views/test_accept_invite.py",
"hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71", "hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71",
"is_verified": false, "is_verified": false,
"line_number": 643, "line_number": 631,
"is_secret": false "is_secret": false
} }
], ],
@@ -684,5 +684,5 @@
} }
] ]
}, },
"generated_at": "2025-02-03T17:01:06Z" "generated_at": "2025-02-26T18:19:37Z"
} }
+5
View File
@@ -162,3 +162,8 @@ upload-static:
# @cf map-route notify-admin ${DNS_NAME} --hostname www # @cf map-route notify-admin ${DNS_NAME} --hostname www
# @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www # @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www
# @echo "Failwhale is disabled" # @echo "Failwhale is disabled"
.PHONY: test-single
test-single: export NEW_RELIC_ENVIRONMENT=test
test-single: ## Run a single test file
poetry run pytest $(TEST_FILE)
+7 -1
View File
@@ -216,7 +216,13 @@
return; return;
} }
var url = type === 'service' ? `/services/${currentServiceId}/daily-stats.json` : `/services/${currentServiceId}/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) return fetch(url)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
+10 -10
View File
@@ -6,17 +6,17 @@
var chartTitle = document.getElementById('chartTitle').textContent; var chartTitle = document.getElementById('chartTitle').textContent;
// Access data attributes from the HTML // Access data attributes from the HTML
var sms_sent = parseInt(chartContainer.getAttribute('data-sms-sent')); var messagesSent = parseInt(chartContainer.getAttribute('data-messages-sent'));
var sms_remaining_messages = parseInt(chartContainer.getAttribute('data-sms-allowance-remaining')); var messagesRemaining = parseInt(chartContainer.getAttribute('data-messages-remaining'));
var totalMessages = sms_sent + sms_remaining_messages; var totalMessages = messagesSent + messagesRemaining;
// Update the message below the chart // 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 // 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 minSentValue = totalMessages * minSentPercentage;
var displaySent = Math.max(sms_sent, minSentValue); var displaySent = Math.max(messagesSent, minSentValue);
var displayRemaining = totalMessages - displaySent; var displayRemaining = totalMessages - displaySent;
var svg = d3.select("#totalMessageChart"); var svg = d3.select("#totalMessageChart");
@@ -48,7 +48,7 @@
.attr("width", 0) // Start with width 0 for animation .attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) { .on('mouseover', function(event) {
tooltip.style('display', 'block') tooltip.style('display', 'block')
.html(`Messages Sent: ${sms_sent.toLocaleString()}`); .html(`Messages Sent: ${messagesSent.toLocaleString()}`);
}) })
.on('mousemove', function(event) { .on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`) tooltip.style('left', `${event.pageX + 10}px`)
@@ -66,7 +66,7 @@
.attr("width", 0) // Start with width 0 for animation .attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) { .on('mouseover', function(event) {
tooltip.style('display', 'block') tooltip.style('display', 'block')
.html(`Remaining: ${sms_remaining_messages.toLocaleString()}`); .html(`Remaining: ${messagesRemaining.toLocaleString()}`);
}) })
.on('mousemove', function(event) { .on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`) tooltip.style('left', `${event.pageX + 10}px`)
@@ -115,9 +115,9 @@
var tbodyRow = document.createElement('tr'); var tbodyRow = document.createElement('tr');
var tdMessagesSent = document.createElement('td'); 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'); 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(tdMessagesSent);
tbodyRow.appendChild(tdRemaining); tbodyRow.appendChild(tdRemaining);
+47 -17
View File
@@ -1,7 +1,8 @@
import calendar import calendar
from datetime import datetime from datetime import datetime, timedelta
from functools import partial from functools import partial
from itertools import groupby from itertools import groupby
from zoneinfo import ZoneInfo
from flask import Response, abort, jsonify, render_template, request, session, url_for from flask import Response, abort, jsonify, render_template, request, session, url_for
from flask_login import current_user from flask_login import current_user
@@ -48,17 +49,6 @@ def service_dashboard(service_id):
if not current_user.has_permissions("view_activity"): if not current_user.has_permissions("view_activity"):
return redirect(url_for("main.choose_template", service_id=service_id)) 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"] job_response = job_api_client.get_jobs(service_id)["data"]
service_data_retention_days = 7 service_data_retention_days = 7
@@ -69,14 +59,17 @@ def service_dashboard(service_id):
for job_dict in sorted_jobs 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( return render_template(
"views/dashboard/dashboard.html", "views/dashboard/dashboard.html",
updates_url=url_for(".service_dashboard_updates", service_id=service_id), updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id), partials=get_dashboard_partials(service_id),
jobs=job_lists, jobs=job_lists,
service_data_retention_days=service_data_retention_days, service_data_retention_days=service_data_retention_days,
sms_sent=sms_sent, messages_remaining=messages_remaining,
sms_allowance_remaining=sms_allowance_remaining, messages_sent=messages_sent,
) )
@@ -103,10 +96,47 @@ def job_is_finished(job_dict):
@user_has_permissions() @user_has_permissions()
def get_daily_stats(service_id): def get_daily_stats(service_id):
date_range = get_stats_date_range() date_range = get_stats_date_range()
stats = service_api_client.get_service_notification_statistics_by_day( days = date_range["days"]
service_id, start_date=date_range["start_date"], 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,
) )
return jsonify(stats)
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") @main.route("/services/<uuid:service_id>/daily-stats-by-user.json")
+5
View File
@@ -537,6 +537,11 @@ class ServiceAPIClient(NotifyAdminAPIClient):
""" """
return self.get("/service/invite/redis/{0}".format(redis_key)) 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() service_api_client = ServiceAPIClient()
+1 -1
View File
@@ -26,7 +26,7 @@
{{ 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 id="totalMessageChartContainer" data-messages-sent="{{ messages_sent }}" data-messages-remaining="{{ messages_remaining }}">
<h2 id="chartTitle">Total messages</h2> <h2 id="chartTitle">Total messages</h2>
<svg id="totalMessageChart"></svg> <svg id="totalMessageChart"></svg>
<div id="message"></div> <div id="message"></div>
+136
View File
@@ -0,0 +1,136 @@
# Manual Accessibility Testing Checklist
## 1. Structure and Semantics
### Headings:
- Verify that headings are used logically (`<h1>` to `<h6>`) to create a clear content hierarchy.
- Ensure there is only one `<h1>` per page (unless its a valid use case, like within `<section>` landmarks).
### Landmarks:
- Confirm the presence of ARIA landmarks (e.g., `<header>`, `<main>`, `<footer>`) for navigation.
- Use tools (e.g., Accessibility Insights, Axe) to ensure proper landmark roles.
### HTML Validity:
- Check for semantic HTML usage (e.g., `<button>` for buttons, `<a>` for links).
- Avoid using `<div>` or `<span>` for interactive elements.
## 2. Navigation and Focus
### Keyboard Navigation:
- Ensure all functionality is accessible via the keyboard (e.g., Tab, Enter, Space, Arrow Keys).
- Test for logical tab order that follows the visual reading order.
### Focus States:
- Ensure focus is visible and distinct on all interactive elements.
- Check that focus is not trapped in modal dialogs or components.
### Skip Links:
- Verify that “Skip to content” links are present and functional.
## 3. Forms and Inputs
### Labels:
- Confirm all form fields have accessible, descriptive labels (`<label>` or `aria-label`/`aria-labelledby`).
- Ensure placeholder text is not used as a label substitute.
### Error Messages:
- Check that error messages are programmatically associated with inputs and conveyed to assistive technologies.
- Verify that error messages are specific and provide actionable guidance.
### Fieldset and Legend:
- Group related inputs with `<fieldset>` and `<legend>` where appropriate.
## 4. Media and Non-Text Content
### Images:
- Ensure all meaningful images have appropriate alt text.
- Decorative images should have `alt=""` or be hidden with `role="presentation"`.
### Videos:
- Verify captions are available and accurate for all video content.
- Provide audio descriptions for videos with critical visual information.
### Audio:
- Confirm there is a way to stop, pause, or adjust the volume of any audio that plays automatically.
## 5. Color and Contrast
### Color Contrast:
- Use a contrast checker to ensure text and interactive elements meet WCAG AA requirements (4.5:1 for text, 3:1 for large text).
### Color Independence:
- Verify that color is not the sole means of conveying information (e.g., “errors in red”).
### High Contrast Modes:
- Test with browser or OS high-contrast modes to ensure proper readability.
---
## 6. Dynamic Content and Interactions
### ARIA Live Regions:
- Verify that dynamic updates are announced using `aria-live` (e.g., success messages).
### Modals and Dialogs:
- Ensure modals have proper focus management (focus should move to the modal on open and back to the trigger on close).
- Test that modals are announced properly by screen readers.
### Tooltips and Popovers:
- Ensure tooltips are accessible via keyboard and announced by assistive technologies.
## 7. Assistive Technology Compatibility
### Screen Reader Testing:
- Test the site with a screen reader (e.g., NVDA, JAWS, VoiceOver) to ensure content is announced logically.
- Verify that dynamic content (e.g., dropdowns, modals) is announced appropriately.
### Zoom and Magnification:
- Ensure the site is usable at 200% zoom without loss of functionality or content.
### Responsive Design:
- Test on different devices and orientations to verify responsive behavior is accessible.
## 8. Performance and Usability
### Loading Indicators:
- Confirm that loading indicators are announced (e.g., using `aria-live`).
### Page Titles:
- Ensure page titles are unique and descriptive of the pages content.
### Time Limits:
- Check if users can extend or disable time limits where applicable.
## 9. WCAG Success Criteria Coverage
### Review WCAG 2.1 (or 2.2 if applicable) Success Criteria:
- **Level A**: Must-have minimum requirements.
- **Level AA**: Commonly required for legal compliance.
- **Level AAA**: Optional for enhanced accessibility.
## Tools to Assist Manual Testing
### Browser Extensions:
- [Axe DevTools](https://www.deque.com/axe/)
- [WAVE](https://wave.webaim.org/)
- [Lighthouse](https://developers.google.com/web/tools/lighthouse)
### Screen Readers:
- **NVDA** (Windows)
- **VoiceOver** (macOS/iOS)
- **TalkBack** (Android)
### Contrast Checkers:
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [Color Contrast Analyzer](https://developer.paciellogroup.com/resources/contrastanalyser/)
### Keyboard Navigation:
- Test tabbing through the site manually.
+84 -84
View File
@@ -24,7 +24,7 @@
"playwright": "^1.50.1", "playwright": "^1.50.1",
"python": "^0.0.4", "python": "^0.0.4",
"query-command-supported": "1.0.0", "query-command-supported": "1.0.0",
"sass-embedded": "^1.85.0", "sass-embedded": "^1.85.1",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"timeago": "1.6.7", "timeago": "1.6.7",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
@@ -11708,9 +11708,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"node_modules/sass-embedded": { "node_modules/sass-embedded": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.85.1.tgz",
"integrity": "sha512-x3Vv54g0jv1aPSW8OTA/0GzQCs/HMQOjIkLtZJ3Xsn/I4vnyjKbVTQmFTax9bQjldqLEEkdbvy6ES/cOOnYNwA==", "integrity": "sha512-0i+3h2Df/c71afluxC1SXqyyMmJlnKWfu9ZGdzwuKRM1OftEa2XM2myt5tR36CF3PanYrMjFKtRIj8PfSf838w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.0.0", "@bufbuild/protobuf": "^2.0.0",
@@ -11729,32 +11729,32 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"sass-embedded-android-arm": "1.85.0", "sass-embedded-android-arm": "1.85.1",
"sass-embedded-android-arm64": "1.85.0", "sass-embedded-android-arm64": "1.85.1",
"sass-embedded-android-ia32": "1.85.0", "sass-embedded-android-ia32": "1.85.1",
"sass-embedded-android-riscv64": "1.85.0", "sass-embedded-android-riscv64": "1.85.1",
"sass-embedded-android-x64": "1.85.0", "sass-embedded-android-x64": "1.85.1",
"sass-embedded-darwin-arm64": "1.85.0", "sass-embedded-darwin-arm64": "1.85.1",
"sass-embedded-darwin-x64": "1.85.0", "sass-embedded-darwin-x64": "1.85.1",
"sass-embedded-linux-arm": "1.85.0", "sass-embedded-linux-arm": "1.85.1",
"sass-embedded-linux-arm64": "1.85.0", "sass-embedded-linux-arm64": "1.85.1",
"sass-embedded-linux-ia32": "1.85.0", "sass-embedded-linux-ia32": "1.85.1",
"sass-embedded-linux-musl-arm": "1.85.0", "sass-embedded-linux-musl-arm": "1.85.1",
"sass-embedded-linux-musl-arm64": "1.85.0", "sass-embedded-linux-musl-arm64": "1.85.1",
"sass-embedded-linux-musl-ia32": "1.85.0", "sass-embedded-linux-musl-ia32": "1.85.1",
"sass-embedded-linux-musl-riscv64": "1.85.0", "sass-embedded-linux-musl-riscv64": "1.85.1",
"sass-embedded-linux-musl-x64": "1.85.0", "sass-embedded-linux-musl-x64": "1.85.1",
"sass-embedded-linux-riscv64": "1.85.0", "sass-embedded-linux-riscv64": "1.85.1",
"sass-embedded-linux-x64": "1.85.0", "sass-embedded-linux-x64": "1.85.1",
"sass-embedded-win32-arm64": "1.85.0", "sass-embedded-win32-arm64": "1.85.1",
"sass-embedded-win32-ia32": "1.85.0", "sass-embedded-win32-ia32": "1.85.1",
"sass-embedded-win32-x64": "1.85.0" "sass-embedded-win32-x64": "1.85.1"
} }
}, },
"node_modules/sass-embedded-android-arm": { "node_modules/sass-embedded-android-arm": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.85.1.tgz",
"integrity": "sha512-pPBT7Ad6G8Mlao8ypVNXW2ya7I/Bhcny+RYZ/EmrunEXfhzCNp4PWV2VAweitPO9RnPIJwvUTkLc8Fu6K3nVmw==", "integrity": "sha512-GkcgUGMZtEF9gheuE1dxCU0ZSAifuaFXi/aX7ZXvjtdwmTl9Zc/OHR9oiUJkc8IW9UI7H8TuwlTAA8+SwgwIeQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -11768,9 +11768,9 @@
} }
}, },
"node_modules/sass-embedded-android-arm64": { "node_modules/sass-embedded-android-arm64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.85.1.tgz",
"integrity": "sha512-4itDzRwezwrW8+YzMLIwHtMeH+qrBNdBsRn9lTVI15K+cNLC8z5JWJi6UCZ8TNNZr9LDBfsh5jUdjSub0yF7jg==", "integrity": "sha512-27oRheqNA3SJM2hAxpVbs7mCKUwKPWmEEhyiNFpBINb5ELVLg+Ck5RsGg+SJmo130ul5YX0vinmVB5uPWc8X5w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -11784,9 +11784,9 @@
} }
}, },
"node_modules/sass-embedded-android-ia32": { "node_modules/sass-embedded-android-ia32": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.85.1.tgz",
"integrity": "sha512-bwqKq95hzbGbMTeXCMQhH7yEdc2xJVwIXj7rGdD3McvyFWbED6362XRFFPI5YyjfD2wRJd9yWLh/hn+6VyjcYA==", "integrity": "sha512-f3x16NyRgtXFksIaO/xXKrUhttUBv8V0XsAR2Dhdb/yz4yrDrhzw9Wh8fmw7PlQqECcQvFaoDr3XIIM6lKzasw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -11800,9 +11800,9 @@
} }
}, },
"node_modules/sass-embedded-android-riscv64": { "node_modules/sass-embedded-android-riscv64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.85.1.tgz",
"integrity": "sha512-Fgkgay+5EePJXZFHR5Vlkutnsmox2V6nX4U3mfGbSN1xjLRm8F5ST72V2s5Z0mnIFpGvEu/v7hfptgViqMvaxg==", "integrity": "sha512-IP6OijpJ8Mqo7XqCe0LsuZVbAxEFVboa0kXqqR5K55LebEplsTIA2GnmRyMay3Yr/2FVGsZbCb6Wlgkw23eCiA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -11816,9 +11816,9 @@
} }
}, },
"node_modules/sass-embedded-android-x64": { "node_modules/sass-embedded-android-x64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.85.1.tgz",
"integrity": "sha512-/bG3JgTn3eoIDHCiJNVkLeJgUesat4ghxqYmKMZUJx++4e6iKCDj8XwQTJAgm+QDrsPKXHBacHEANJ9LEAuTqg==", "integrity": "sha512-Mh7CA53wR3ADvXAYipFc/R3vV4PVOzoKwWzPxmq+7i8UZrtsVjKONxGtqWe9JG1mna0C9CRZAx0sv/BzbOJxWg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -11832,9 +11832,9 @@
} }
}, },
"node_modules/sass-embedded-darwin-arm64": { "node_modules/sass-embedded-darwin-arm64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.85.1.tgz",
"integrity": "sha512-plp8TyMz97YFBCB3ndftEvoW29vyfsSBJILM5U84cGzr06SvLh/Npjj8psfUeRw+upEk1zkFtw5u61sRCdgwIw==", "integrity": "sha512-msWxzhvcP9hqGVegxVePVEfv9mVNTlUgGr6k7O7Ihji702mbtrH/lKwF4aRkkt4g1j7tv10+JtQXmTNi/pi9kA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -11848,9 +11848,9 @@
} }
}, },
"node_modules/sass-embedded-darwin-x64": { "node_modules/sass-embedded-darwin-x64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.85.1.tgz",
"integrity": "sha512-LP8Zv8DG57Gn6PmSwWzC0gEZUsGdg36Ps3m0i1fVTOelql7N3HZIrlPYRjJvidL8ZlB3ISxNANebTREUHn/wkQ==", "integrity": "sha512-J4UFHUiyI9Z+mwYMwz11Ky9TYr3hY1fCxeQddjNGL/+ovldtb0yAIHvoVM0BGprQDm5JqhtUk8KyJ3RMJqpaAA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -11864,9 +11864,9 @@
} }
}, },
"node_modules/sass-embedded-linux-arm": { "node_modules/sass-embedded-linux-arm": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.85.1.tgz",
"integrity": "sha512-18xOAEfazJt1MMVS2TRHV94n81VyMnywOoJ7/S7I79qno/zx26OoqqP4XvH107xu8+mZ9Gg54LrUH6ZcgHk08g==", "integrity": "sha512-X0fDh95nNSw1wfRlnkE4oscoEA5Au4nnk785s9jghPFkTBg+A+5uB6trCjf0fM22+Iw6kiP4YYmDdw3BqxAKLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -11880,9 +11880,9 @@
} }
}, },
"node_modules/sass-embedded-linux-arm64": { "node_modules/sass-embedded-linux-arm64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.85.1.tgz",
"integrity": "sha512-JRIRKVOY5Y8M1zlUOv9AQGju4P6lj8i5vLJZsVYVN/uY8Cd2dDJZPC8EOhjntp+IpF8AOGIHqCeCkHBceIyIjA==", "integrity": "sha512-jGadetB03BMFG2rq3OXub/uvC/lGpbQOiLGEz3NLb2nRZWyauRhzDtvZqkr6BEhxgIWtMtz2020yD8ZJSw/r2w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -11896,9 +11896,9 @@
} }
}, },
"node_modules/sass-embedded-linux-ia32": { "node_modules/sass-embedded-linux-ia32": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.85.1.tgz",
"integrity": "sha512-4JH+h+gLt9So22nNPQtsKojEsLzjld9ol3zWcOtMGclv+HojZGbCuhJUrLUcK72F8adXYsULmWhJPKROLIwYMA==", "integrity": "sha512-7HlYY90d9mitDtNi5s+S+5wYZrTVbkBH2/kf7ixrzh2BFfT0YM81UHLJRnGX93y9aOMBL6DSZAIfkt1RsV9bkQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -11912,9 +11912,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-arm": { "node_modules/sass-embedded-linux-musl-arm": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.85.1.tgz",
"integrity": "sha512-Z1j4ageDVFihqNUBnm89fxY46pY0zD/Clp1D3ZdI7S+D280+AEpbm5vMoH8LLhBQfQLf2w7H++SZGpQwrisudQ==", "integrity": "sha512-5vcdEqE8QZnu6i6shZo7x2N36V7YUoFotWj2rGekII5ty7Nkaj+VtZhUEOp9tAzEOlaFuDp5CyO1kUCvweT64A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -11928,9 +11928,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-arm64": { "node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.85.1.tgz",
"integrity": "sha512-aoQjUjK28bvdw9XKTjQeayn8oWQ2QqvoTD11myklGd3IHH7Jj0nwXUstI4NxDueCKt3wghuZoIQkjOheReQxlg==", "integrity": "sha512-FLkIT0p18XOkR6wryJ13LqGBDsrYev2dRk9dtiU18NCpNXruKsdBQ1ZnWHVKB3h1dA9lFyEEisC0sooKdNfeOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -11944,9 +11944,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-ia32": { "node_modules/sass-embedded-linux-musl-ia32": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.85.1.tgz",
"integrity": "sha512-/cJCSXOfXmQFH8deE+3U9x+BSz8i0d1Tt9gKV/Gat1Xm43Oumw8pmZgno+cDuGjYQInr9ryW5121pTMlj/PBXQ==", "integrity": "sha512-N1093T84zQJor1yyIAdYScB5eAuQarGK1tKgZ4uTnxVlgA7Xi1lXV8Eh7ox9sDqKCaWkVQ3MjqU26vYRBeRWyw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -11960,9 +11960,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-riscv64": { "node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.85.1.tgz",
"integrity": "sha512-l+FJxMXkmg42RZq5RFKXg4InX0IA7yEiPHe4kVSdrczP7z3NLxk+W9wVkPnoRKYIMe1qZPPQ25y0TgI4HNWouA==", "integrity": "sha512-WRsZS/7qlfYXsa93FBpSruieuURIu7ySfFhzYfF1IbKrNAGwmbduutkHZh2ddm5/vQMvQ0Rdosgv+CslaQHMcw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -11976,9 +11976,9 @@
} }
}, },
"node_modules/sass-embedded-linux-musl-x64": { "node_modules/sass-embedded-linux-musl-x64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.85.1.tgz",
"integrity": "sha512-M9ffjcYfFcRvkFA6V3DpOS955AyvmpvPAhL/xNK45d/ma1n1ehTWpd24tVeKiNK5CZkNjjMEfyw2fHa6MpqmEA==", "integrity": "sha512-+OlLIilA5TnP0YEqTQ8yZtkW+bJIQYvzoGoNLUEskeyeGuOiIyn2CwL6G4JQB4xZQFaxPHb7JD3EueFkQbH0Pw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -11992,9 +11992,9 @@
} }
}, },
"node_modules/sass-embedded-linux-riscv64": { "node_modules/sass-embedded-linux-riscv64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.85.1.tgz",
"integrity": "sha512-yqPXQWfM+qiIPkfn++48GOlbmSvUZIyL9nwFstBk0k4x40UhbhilfknqeTUpxoHfQzylTGVhrm5JE7MjM+LNZA==", "integrity": "sha512-mKKlOwMGLN7yP1p0gB5yG/HX4fYLnpWaqstNuOOXH+fOzTaNg0+1hALg0H0CDIqypPO74M5MS9T6FAJZGdT6dQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -12008,9 +12008,9 @@
} }
}, },
"node_modules/sass-embedded-linux-x64": { "node_modules/sass-embedded-linux-x64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.85.1.tgz",
"integrity": "sha512-NTDeQFZcuVR7COoaRy8pZD6/+QznwBR8kVFsj7NpmvX9aJ7TX/q+OQZHX7Bfb3tsfKXhf1YZozegPuYxRnMKAQ==", "integrity": "sha512-uKRTv0z8NgtHV7xSren78+yoWB79sNi7TMqI7Bxd8fcRNIgHQSA8QBdF8led2ETC004hr8h71BrY60RPO+SSvA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -12024,9 +12024,9 @@
} }
}, },
"node_modules/sass-embedded-win32-arm64": { "node_modules/sass-embedded-win32-arm64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.85.1.tgz",
"integrity": "sha512-gO0VAuxC4AdV+uZYJESRWVVHQWCGzNs0C3OKCAdH4r1vGRugooMi7J/5wbwUdXDA1MV9ICfhlKsph2n3GiPdqA==", "integrity": "sha512-/GMiZXBOc6AEMBC3g25Rp+x8fq9Z6Ql7037l5rajBPhZ+DdFwtdHY0Ou3oIU6XuWUwD06U3ii4XufXVFhsP6PA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -12040,9 +12040,9 @@
} }
}, },
"node_modules/sass-embedded-win32-ia32": { "node_modules/sass-embedded-win32-ia32": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.85.1.tgz",
"integrity": "sha512-PCyn6xeFIBUgBceNypuf73/5DWF2VWPlPqPuBprPsTvpZOMUJeBtP+Lf4mnu3dNy1z76mYVnpaCnQmzZ0zHZaA==", "integrity": "sha512-L+4BWkKKBGFOKVQ2PQ5HwFfkM5FvTf1Xx2VSRvEWt9HxPXp6SPDho6zC8fqNQ3hSjoaoASEIJcSvgfdQYO0gdg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -12056,9 +12056,9 @@
} }
}, },
"node_modules/sass-embedded-win32-x64": { "node_modules/sass-embedded-win32-x64": {
"version": "1.85.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.85.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.85.1.tgz",
"integrity": "sha512-AknE2jLp6OBwrR5hQ8pDsG94KhJCeSheFJ2xgbnk8RUjZX909JiNbgh2sNt9LG+RXf4xZa55dDL537gZoCx/iw==", "integrity": "sha512-/FO0AGKWxVfCk4GKsC0yXWBpUZdySe3YAAbQQL0lL6xUd1OiUY8Kow6g4Kc1TB/+z0iuQKKTqI/acJMEYl4iTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
+1 -1
View File
@@ -40,7 +40,7 @@
"playwright": "^1.50.1", "playwright": "^1.50.1",
"python": "^0.0.4", "python": "^0.0.4",
"query-command-supported": "1.0.0", "query-command-supported": "1.0.0",
"sass-embedded": "^1.85.0", "sass-embedded": "^1.85.1",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"timeago": "1.6.7", "timeago": "1.6.7",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
+4 -16
View File
@@ -300,25 +300,13 @@ def test_accepting_invite_removes_invite_from_session(
client_request.login(user) client_request.login(user)
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
date_range = {"start_date": "2024-01-01", "days": 7}
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", "app.service_api_client.get_service_message_ratio",
return_value={ return_value={
date_range["start_date"]: { "messages_remaining": 71919,
"email": {"delivered": 0, "failure": 0, "requested": 0}, "messages_sent": 28081,
"sms": {"delivered": 0, "failure": 1, "requested": 1}, "total_message_limit": 100000,
},
},
)
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value={
date_range["start_date"]: {
"email": {"delivered": 1, "failure": 0, "requested": 1},
"sms": {"delivered": 1, "failure": 0, "requested": 1},
},
}, },
) )
page = client_request.get( page = client_request.get(
+211 -151
View File
@@ -1,6 +1,8 @@
import copy import copy
import json import json
from datetime import datetime from datetime import datetime
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytest import pytest
from flask import url_for from flask import url_for
@@ -12,6 +14,7 @@ from app.main.views.dashboard import (
aggregate_template_usage, aggregate_template_usage,
format_monthly_stats_to_list, format_monthly_stats_to_list,
get_dashboard_totals, get_dashboard_totals,
get_local_daily_stats_for_last_x_days,
get_tuples_of_financial_years, get_tuples_of_financial_years,
) )
from tests import ( from tests import (
@@ -193,6 +196,12 @@ mock_daily_stats_by_user = {
}, },
} }
mock_service_message_ratio = {
"messages_remaining": 71919,
"messages_sent": 28081,
"total_message_limit": 100000,
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user", "user",
@@ -252,12 +261,8 @@ def test_get_started(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -286,12 +291,8 @@ def test_get_started_is_hidden_once_templates_exist(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -317,12 +318,8 @@ def test_inbound_messages_not_visible_to_service_without_permissions(
service_one["permissions"] = [] service_one["permissions"] = []
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -348,12 +345,8 @@ def test_inbound_messages_shows_count_of_messages_when_there_are_messages(
): ):
service_one["permissions"] = ["inbound_sms"] service_one["permissions"] = ["inbound_sms"]
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -384,12 +377,8 @@ def test_inbound_messages_shows_count_of_messages_when_there_are_no_messages(
): ):
service_one["permissions"] = ["inbound_sms"] service_one["permissions"] = ["inbound_sms"]
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -638,12 +627,8 @@ def test_should_show_recent_templates_on_dashboard(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -690,12 +675,8 @@ def test_should_not_show_recent_templates_on_dashboard_if_only_one_template_used
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
main = page.select_one("main").text main = page.select_one("main").text
@@ -849,12 +830,8 @@ def test_should_show_upcoming_jobs_on_dashboard(
mock_get_inbound_sms_summary, mock_get_inbound_sms_summary,
): ):
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -896,12 +873,8 @@ def test_should_not_show_upcoming_jobs_on_dashboard_if_count_is_0(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -926,12 +899,8 @@ def test_should_not_show_upcoming_jobs_on_dashboard_if_service_has_no_jobs(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -1016,12 +985,8 @@ def test_should_not_show_jobs_on_dashboard_for_users_with_uploads_page(
mock_get_inbound_sms_summary, mock_get_inbound_sms_summary,
): ):
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get( page = client_request.get(
"main.service_dashboard", "main.service_dashboard",
@@ -1284,12 +1249,8 @@ def test_menu_send_messages(
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = _test_dashboard_menu( page = _test_dashboard_menu(
client_request, client_request,
@@ -1325,12 +1286,8 @@ def test_menu_manage_service(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = _test_dashboard_menu( page = _test_dashboard_menu(
client_request, client_request,
@@ -1366,12 +1323,8 @@ def test_menu_main_settings(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = _test_settings_menu( page = _test_settings_menu(
client_request, client_request,
@@ -1406,12 +1359,8 @@ def test_menu_manage_api_keys(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = _test_dashboard_menu( page = _test_dashboard_menu(
client_request, client_request,
@@ -1447,12 +1396,8 @@ def test_menu_all_services_for_platform_admin_user(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = _test_dashboard_menu( page = _test_dashboard_menu(
client_request, mocker, platform_admin_user, service_one, [] client_request, mocker, platform_admin_user, service_one, []
@@ -1490,12 +1435,8 @@ def test_route_for_service_permissions(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
validate_route_permission( validate_route_permission(
mocker, mocker,
@@ -1639,12 +1580,8 @@ def test_org_breadcrumbs_do_not_show_if_service_has_no_org(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
@@ -1710,12 +1647,8 @@ def test_org_breadcrumbs_show_if_user_is_a_member_of_the_services_org(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
@@ -1750,12 +1683,8 @@ def test_org_breadcrumbs_do_not_show_if_user_is_a_member_of_the_services_org_but
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
@@ -1792,12 +1721,8 @@ def test_org_breadcrumbs_show_if_user_is_platform_admin(
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
client_request.login(platform_admin_user, service_one_json) client_request.login(platform_admin_user, service_one_json)
@@ -1829,14 +1754,9 @@ def test_breadcrumb_shows_if_service_is_suspended(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
@@ -1869,12 +1789,8 @@ def test_service_dashboard_shows_usage(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
service_one["permissions"] = permissions service_one["permissions"] = permissions
@@ -1909,12 +1825,8 @@ def test_service_dashboard_shows_free_allowance(
) )
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
@@ -1930,12 +1842,8 @@ def test_service_dashboard_shows_batched_jobs(
): ):
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", return_value=mock_daily_stats "app.service_api_client.get_service_message_ratio",
) return_value=mock_service_message_ratio,
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value=mock_daily_stats_by_user,
) )
page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID) page = client_request.get("main.service_dashboard", service_id=SERVICE_ONE_ID)
@@ -1945,3 +1853,155 @@ def test_service_dashboard_shows_batched_jobs(
assert len(rows) == 1 assert len(rows) == 1
assert job_table_body is not None assert job_table_body is not None
@patch("app.main.views.dashboard.datetime", wraps=datetime)
def test_simple_local_conversion(mock_dt):
stats_utc = {
"2025-02-24T15:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T07:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
}
# Mock today's date in local time: 2025-02-25 at 10:00
mock_dt.now.return_value = datetime(2025, 2, 25, 10, 0, 0, tzinfo=ZoneInfo("America/New_York"))
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc,
"America/New_York",
days=2
)
assert len(local_stats) == 2
assert "2025-02-24" in local_stats
assert "2025-02-25" in local_stats
assert local_stats["2025-02-24"]["sms"]["delivered"] == 1
assert local_stats["2025-02-24"]["sms"]["requested"] == 1
assert local_stats["2025-02-25"]["sms"]["delivered"] == 2
assert local_stats["2025-02-25"]["sms"]["requested"] == 2
def test_no_timestamps_returns_zeroed_days():
stats_utc = {}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 26, 8, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "America/New_York", days=3
)
assert list(local_stats.keys()) == ["2025-02-24", "2025-02-25", "2025-02-26"]
for day in local_stats:
for msg_type in ["sms", "email"]:
for status in ["delivered", "failure", "pending", "requested"]:
assert local_stats[day][msg_type][status] == 0
def test_timestamp_in_future_time_zone():
stats_utc = {
"2025-02-25T01:00:00Z": {
"sms": {"delivered": 5, "failure": 0, "pending": 0, "requested": 5},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
}
}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 25, 10, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "Asia/Shanghai", days=1
)
assert list(local_stats.keys()) == ["2025-02-25"]
assert local_stats["2025-02-25"]["sms"]["delivered"] == 5
def test_many_timestamps_one_local_day():
stats_utc = {
"2025-02-24T05:00:00Z": {
"sms": {"delivered": 2, "failure": 1, "pending": 0, "requested": 3},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-24T09:30:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
},
"2025-02-24T16:59:59Z": {
"sms": {"delivered": 4, "failure": 0, "pending": 0, "requested": 4},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
}
class MockDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2025, 2, 24, 20, 0, 0, tzinfo=tz)
with patch("app.main.views.dashboard.datetime", MockDateTime):
local_stats = get_local_daily_stats_for_last_x_days(
stats_utc, "America/New_York", days=1
)
assert list(local_stats.keys()) == ["2025-02-24"]
assert local_stats["2025-02-24"]["sms"]["delivered"] == 7
assert local_stats["2025-02-24"]["sms"]["requested"] == 8
assert local_stats["2025-02-24"]["email"]["delivered"] == 2
assert local_stats["2025-02-24"]["email"]["requested"] == 2
def test_local_conversion_phoenix():
"""Test aggregator logic in Mountain Time, no DST (America/Phoenix)."""
stats_utc = {
"2025-02-25T01:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T12:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
},
}
with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("America/Phoenix"))
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "America/Phoenix", days=1)
assert len(local_stats) == 1
(day_key,) = local_stats.keys()
sms_delivered = local_stats[day_key]["sms"]["delivered"]
assert sms_delivered in (2, 3, 4)
def test_local_conversion_honolulu():
"""Test aggregator logic in Hawaii (Pacific/Honolulu)."""
stats_utc = {
"2025-02-25T03:00:00Z": {
"sms": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
},
"2025-02-25T21:00:00Z": {
"sms": {"delivered": 1, "failure": 0, "pending": 0, "requested": 1},
"email": {"delivered": 2, "failure": 0, "pending": 0, "requested": 2},
},
}
with patch("app.main.views.dashboard.datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = datetime(2025, 2, 25, 12, 0, 0, tzinfo=ZoneInfo("Pacific/Honolulu"))
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, "Pacific/Honolulu", days=1)
assert len(local_stats) == 1
(day_key,) = local_stats.keys()
total_requested = local_stats[day_key]["sms"]["requested"]
assert total_requested in (3, 1, 4)
+4 -17
View File
@@ -128,25 +128,12 @@ def test_sign_out_user(
# Check we are logged in # Check we are logged in
mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS) mocker.patch("app.job_api_client.get_jobs", return_value=MOCK_JOBS)
date_range = {"start_date": "2024-01-01", "days": 7}
mocker.patch( mocker.patch(
"app.main.views.dashboard.get_daily_stats", "app.service_api_client.get_service_message_ratio",
return_value={ return_value={
date_range["start_date"]: { "messages_remaining": 71919,
"email": {"delivered": 0, "failure": 0, "requested": 0}, "messages_sent": 28081,
"sms": {"delivered": 0, "failure": 1, "requested": 1}, "total_message_limit": 100000,
},
},
)
mocker.patch(
"app.main.views.dashboard.get_daily_stats_by_user",
return_value={
date_range["start_date"]: {
"email": {"delivered": 1, "failure": 0, "requested": 1},
"sms": {"delivered": 1, "failure": 0, "requested": 1},
},
}, },
) )
+1 -1
View File
@@ -199,7 +199,7 @@ test('Fetches data and creates chart and table correctly', async () => {
const data = await fetchData('service'); const data = await fetchData('service');
expect(global.fetch).toHaveBeenCalledWith(`/services/${currentServiceId}/daily-stats.json`); expect(global.fetch).toHaveBeenCalledWith(`/services/${currentServiceId}/daily-stats.json?timezone=UTC`);
expect(data).toEqual(mockResponse); expect(data).toEqual(mockResponse);
const labels = Object.keys(mockResponse).map(dateString => { const labels = Object.keys(mockResponse).map(dateString => {
+4 -4
View File
@@ -22,7 +22,7 @@ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
beforeEach(() => { beforeEach(() => {
// Set up the DOM with the D3 script included // Set up the DOM with the D3 script included
document.body.innerHTML = ` document.body.innerHTML = `
<div id="totalMessageChartContainer" data-sms-sent="100" data-sms-allowance-remaining="249900" style="width: 600px;"> <div id="totalMessageChartContainer" data-messages-sent="28081" data-messages-remaining="71919" style="width: 600px;">
<h1 id="chartTitle">Total Messages</h1> <h1 id="chartTitle">Total Messages</h1>
<svg id="totalMessageChart"></svg> <svg id="totalMessageChart"></svg>
</div> </div>
@@ -76,8 +76,8 @@ test('Populates the accessible table correctly', () => {
expect(headers[1].textContent).toBe('Remaining'); expect(headers[1].textContent).toBe('Remaining');
const firstRowCells = rows[1].getElementsByTagName('td'); const firstRowCells = rows[1].getElementsByTagName('td');
expect(firstRowCells[0].textContent).toBe('100'); expect(firstRowCells[0].textContent).toBe('28,081');
expect(firstRowCells[1].textContent).toBe('249,900'); expect(firstRowCells[1].textContent).toBe('71,919');
}); });
// Test to check if the chart title is correctly set // Test to check if the chart title is correctly set
@@ -114,7 +114,7 @@ test('Chart resizes correctly on window resize', done => {
// Testing the tooltip // Testing the tooltip
test('Tooltip displays on hover', () => { test('Tooltip displays on hover', () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="totalMessageChartContainer" data-sms-sent="100" data-sms-allowance-remaining="249900" style="width: 600px;"> <div id="totalMessageChartContainer" data-messages-sent="100" data-messages-remaining="249900" style="width: 600px;">
<h1 id="chartTitle">Total Messages</h1> <h1 id="chartTitle">Total Messages</h1>
<svg id="totalMessageChart"></svg> <svg id="totalMessageChart"></svg>
</div> </div>