diff --git a/.ds.baseline b/.ds.baseline index 999298014..f0d0e6d2c 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -527,7 +527,7 @@ "filename": "tests/app/main/views/test_accept_invite.py", "hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71", "is_verified": false, - "line_number": 643, + "line_number": 631, "is_secret": false } ], @@ -684,5 +684,5 @@ } ] }, - "generated_at": "2025-02-03T17:01:06Z" + "generated_at": "2025-02-26T18:19:37Z" } diff --git a/Makefile b/Makefile index 66ce809bb..8fa5364a0 100644 --- a/Makefile +++ b/Makefile @@ -162,3 +162,8 @@ upload-static: # @cf map-route notify-admin ${DNS_NAME} --hostname www # @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www # @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) diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js index 9ba1e4338..a9e75debd 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -216,7 +216,13 @@ 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) .then(response => { if (!response.ok) { diff --git a/app/assets/javascripts/totalMessagesChart.js b/app/assets/javascripts/totalMessagesChart.js index 68c9fd8f9..2c4003987 100644 --- a/app/assets/javascripts/totalMessagesChart.js +++ b/app/assets/javascripts/totalMessagesChart.js @@ -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); diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 25e00c1cc..52a30b5de 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -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 @@ -48,17 +49,6 @@ 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 @@ -69,14 +59,17 @@ def service_dashboard(service_id): 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=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, ) @@ -103,10 +96,47 @@ def job_is_finished(job_dict): @user_has_permissions() def get_daily_stats(service_id): date_range = get_stats_date_range() - stats = service_api_client.get_service_notification_statistics_by_day( - service_id, start_date=date_range["start_date"], days=date_range["days"] + 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//daily-stats-by-user.json") diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 0229fee3d..6ccc2747e 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -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() diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index bd19585c3..15df829f3 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -26,7 +26,7 @@ {{ ajax_block(partials, updates_url, 'inbox') }} -
+

Total messages

diff --git a/docs/manual-a11y-checklist.md b/docs/manual-a11y-checklist.md new file mode 100644 index 000000000..acb26bb16 --- /dev/null +++ b/docs/manual-a11y-checklist.md @@ -0,0 +1,136 @@ +# Manual Accessibility Testing Checklist + +## 1. Structure and Semantics + +### Headings: +- Verify that headings are used logically (`

` to `

`) to create a clear content hierarchy. +- Ensure there is only one `

` per page (unless it’s a valid use case, like within `
` landmarks). + +### Landmarks: +- Confirm the presence of ARIA landmarks (e.g., `
`, `
`, `