diff --git a/.github/ISSUE_TEMPLATE/issue_template.yml b/.github/ISSUE_TEMPLATE/issue_template.yml index 576af0095..f9cabaa72 100644 --- a/.github/ISSUE_TEMPLATE/issue_template.yml +++ b/.github/ISSUE_TEMPLATE/issue_template.yml @@ -64,6 +64,17 @@ body: validations: required: false + - type: markdown + attributes: + value: '**Accessibility:**' + - type: textarea + id: accessibility + attributes: + label: "List any specific accessibility guidance or tests that need to be considered for this user story." + description: "List what type of accessibility tests need to pass." + validations: + required: false + - type: markdown attributes: value: '**Notes:**' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7ffbbc290..a659829b2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,5 +23,7 @@ Please enter a detailed description here. ## A11y Checks (if applicable) -* Conduct automated tests through [AxeDevTools](https://www.deque.com/axe/devtools/) and [WAVE](https://wave.webaim.org/) +* Double check work is getting picked up by the automated E2E tests +* Conduct browser-based tests through [AxeDevTools](https://www.deque.com/axe/devtools/) and [WAVE](https://wave.webaim.org/) * Review the [Manual Checklist](https://docs.google.com/document/d/192bBXStebdXWtYhZQ73qaWMJhGcuSB1W6c9YBXhWZvc/edit?usp=sharing) +* Make sure there are no linting errors in VSCode or other IDE of choice diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c3ef5dcbb..e2343f415 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -165,8 +165,9 @@ jobs: run: make run-flask & env: NOTIFY_ENVIRONMENT: scanning + FEATURE_ABOUT_PAGE_ENABLED: true - name: Run OWASP Baseline Scan - uses: zaproxy/action-baseline@v0.9.0 + uses: zaproxy/action-baseline@v0.14.0 with: docker_name: "ghcr.io/zaproxy/zaproxy:weekly" target: "http://localhost:6012" diff --git a/.github/workflows/daily_checks.yml b/.github/workflows/daily_checks.yml index a5e81a137..b24a71738 100644 --- a/.github/workflows/daily_checks.yml +++ b/.github/workflows/daily_checks.yml @@ -50,7 +50,7 @@ jobs: env: NOTIFY_ENVIRONMENT: scanning - name: Run OWASP Full Scan - uses: zaproxy/action-full-scan@v0.7.0 + uses: zaproxy/action-full-scan@v0.12.0 with: docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' target: 'http://localhost:6012' diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js index bf5e8073d..139a4f518 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -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') @@ -152,7 +159,7 @@ }; // 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 +171,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 +194,10 @@ cellFailed.textContent = failedData[index]; row.appendChild(cellFailed); + const cellPending = document.createElement('td'); + cellPending.textContent = pendingData[index]; + row.appendChild(cellPending); + tbody.appendChild(row); }); @@ -196,6 +207,7 @@ }; const fetchData = function(type) { + var ctx = document.getElementById('weeklyChart'); if (!ctx) { return; @@ -203,9 +215,11 @@ // get local timezone var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - var url = type === 'service' - ? `/daily_stats.json?timezone=${encodeURIComponent(userTimezone)}` - : `/daily_stats_by_user.json`; + + // build the URL depending on "type" + var url = (type === 'service') + ? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}` + : `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`; return fetch(url) .then(response => { @@ -220,7 +234,7 @@ labels = []; deliveredData = []; failedData = []; - + pendingData = []; let totalMessages = 0; for (var dateString in data) { @@ -231,6 +245,8 @@ labels.push(formattedDate); deliveredData.push(data[dateString].sms.delivered); failedData.push(data[dateString].sms.failure); + pendingData.push(data[dateString].sms.pending || 0); + totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure + data[dateString].sms.pending; // Calculate the total number of messages totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure; @@ -259,17 +275,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; @@ -277,36 +294,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); } }); diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index 0bb1aab7e..ebce95061 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -1024,3 +1024,7 @@ nav.nav { font-size: units(3); font-weight: bold; } + +.form-control-error { + border: 4px solid #b10e1e +} diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 3c20dd3e0..0557a2111 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -14,7 +14,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 @@ -62,41 +62,23 @@ def service_dashboard(service_id): 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" - ] + filtered_jobs = [job for job in job_response if job["job_status"] != "cancelled"] + sorted_jobs = sorted(filtered_jobs, key=lambda job: job["created_at"], reverse=True) + 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=sorted_jobs, service_data_retention_days=service_data_retention_days, sms_sent=sms_sent, sms_allowance_remaining=sms_allowance_remaining, ) -@main.route("/daily_stats.json") -def get_daily_stats(): - service_id = session.get("service_id") +@main.route("/services//daily-stats.json") +@user_has_permissions() +def get_daily_stats(service_id): date_range = get_stats_date_range() # Get timezone from request (default to UTC if not provided) @@ -109,14 +91,14 @@ def get_daily_stats(): return jsonify(stats) -@main.route("/daily_stats_by_user.json") -def get_daily_stats_by_user(): +@main.route("/services//daily-stats-by-user.json") +@user_has_permissions() +def get_daily_stats_by_user(service_id): service_id = session.get("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"], ) diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index dddf838a1..164ab737f 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -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 diff --git a/app/notify_client/billing_api_client.py b/app/notify_client/billing_api_client.py index b1ffc19f0..363764907 100644 --- a/app/notify_client/billing_api_client.py +++ b/app/notify_client/billing_api_client.py @@ -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( diff --git a/app/notify_client/notification_api_client.py b/app/notify_client/notification_api_client.py index 95ac96a04..89e786079 100644 --- a/app/notify_client/notification_api_client.py +++ b/app/notify_client/notification_api_client.py @@ -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() diff --git a/app/templates/components/components/input/template.njk b/app/templates/components/components/input/template.njk index 7f5634651..4ea649dce 100644 --- a/app/templates/components/components/input/template.njk +++ b/app/templates/components/components/input/template.njk @@ -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 %} + {%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%} + {%- if params.required %} required{% endif %} + /> diff --git a/app/templates/components/textbox.html b/app/templates/components/textbox.html index 3e479cbce..fa92d0cf8 100644 --- a/app/templates/components/textbox.html +++ b/app/templates/components/textbox.html @@ -16,19 +16,9 @@ placeholder='' ) %}
- {% if field.errors %} - - {% endif %}
{% endif %} + {% if field.errors %} + + Error: + {% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %} + + {% 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 %} diff --git a/app/templates/partials/jobs/status.html b/app/templates/partials/jobs/status.html index fb0a745f8..0f3288813 100644 --- a/app/templates/partials/jobs/status.html +++ b/app/templates/partials/jobs/status.html @@ -21,11 +21,11 @@

- {% if job.still_processing or arrived_from_preview_page_url %} + {% if not job.finished_processing %} {% if job.scheduled_for %}

-

Your text has been scheduled

+

Your message has been scheduled

{{ job.template_name }} - {{ current_service.name }} was scheduled on {{ job.scheduled_for|format_datetime_normal }} by {{ job.created_by.name }}

@@ -33,18 +33,46 @@
{{display_message_status}} {% else %} -
-
-

Your text has been sent

-

- {{ 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 }} -

+ {% if job.processing_started %} +
+
+

+ Your message is sending +

+

+ {{ job.template_name }} - {{ current_service.name }} + has been sending since {{job.processing_started| format_datetime_normal}} by {{ job.created_by.name }} +

+
-
+ {% else %} +
+
+

+ Your message is pending +

+

+ {{ job.template_name }} - {{ current_service.name }} + has been pending since {{job.created_at|format_datetime_normal}} by {{ job.created_by.name }} +

+
+
+ {% endif %} {{display_message_status}} {% endif %} + {% elif arrived_from_preview_page_url %} +
+
+

+ Your message has been sent +

+

+ {{ job.template_name }} - {{ current_service.name }} + was sent on {{job.processing_started|format_datetime_normal}} by {{ job.created_by.name }} +

+
+
+ {{display_message_status}} {% endif %}

{% if job.status == 'sending limits exceeded'%} diff --git a/app/templates/views/dashboard/activity-table.html b/app/templates/views/dashboard/activity-table.html new file mode 100644 index 000000000..7a8bd7736 --- /dev/null +++ b/app/templates/views/dashboard/activity-table.html @@ -0,0 +1,72 @@ +

Recent activity

+
+
+ + +
+
+
+
{{ current_service.name }} - last 7 days
+
+
+
+
+
+
+
+
+
+

Service activity

+ + + + + + + + + + + + + + + {% if jobs %} + {% for job in jobs %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
Table showing the sent jobs for {{current_service.name}}
Job ID#TemplateJob statusSender + # of Recipients
+ + {{ job.id[:8] if job.id else 'Manually entered number' }} + + {{ job.template_name }} + {% if job.scheduled_for and not job.processing_finished %} + Scheduled for {{ job.scheduled_for|format_datetime_table }} + {% elif job.processing_finished and not job.statistics|selectattr('status', 'equalto', 'sending')|list %} + Sent on {{ job.processing_finished|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 %} + {{ job.created_by.name }}{{ job.notification_count }}
No batched job messages found  (messages are + kept for {{ service_data_retention_days }} days).
+
+
diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index 04abe1afc..bd19585c3 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -6,140 +6,38 @@ {% from "components/ajax-block.html" import ajax_block %} {% block service_page_title %} - Dashboard + Dashboard {% endblock %} {% block maincolumn_content %} - + -
+
-

Dashboard

- {% if current_user.has_permissions('manage_templates') and not current_service.all_templates %} - {% include 'views/dashboard/write-first-messages.html' %} - {% endif %} +

Dashboard

+{% 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') }} -

{{ current_service.name }} Dashboard

+

{{ current_service.name }} Dashboard

- {{ ajax_block(partials, updates_url, 'inbox') }} +{{ ajax_block(partials, updates_url, 'inbox') }} -
-

Total messages

- -
-
-
+
+

Total messages

+ +
+
+
-

Recent activity

-
-
- - -
-
-
-
{{ current_service.name }} - last 7 days
-
-
-
-
-
-
-
+{% include 'views/dashboard/activity-table.html' %} - {% if current_user.has_permissions('manage_service') %}{% endif %} +{% if current_user.has_permissions('manage_service') %}{% endif %} -
- - -
-

Service activity

- - - - - - - - - - - - - {% if jobs %} - {% for job in jobs[:5] %} - {% set notification = job.notifications[0] %} - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
Table showing the sent jobs for this service
Job ID#TemplateJob statusSender# of Recipients
- - {{ job.job_id[:8] if job.job_id else 'Manually entered number' }} - - {{ job.template_name }}Sent on - {{ (job.processing_finished if job.processing_finished else job.processing_started - if job.processing_started else job.created_at)|format_datetime_table }} - {{ job.created_by.name }}{{ job.notification_count }}
No batched job messages found  (messages are kept for {{ service_data_retention_days }} days).
-
-
- {{ ajax_block(partials, updates_url, 'template-statistics') }} -
+{{ ajax_block(partials, updates_url, 'template-statistics') }} +
{% endblock %} diff --git a/app/templates/views/edit-sms-template.html b/app/templates/views/edit-sms-template.html index 97eac73dc..8ef41bdb2 100644 --- a/app/templates/views/edit-sms-template.html +++ b/app/templates/views/edit-sms-template.html @@ -32,6 +32,8 @@
{{ 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 }} diff --git a/app/templates/views/jobs/job.html b/app/templates/views/jobs/job.html index 7014e1987..1643715f4 100644 --- a/app/templates/views/jobs/job.html +++ b/app/templates/views/jobs/job.html @@ -11,7 +11,18 @@ {% block maincolumn_content %} {{ page_header("Message status") }} - {{ partials['status']|safe }} + {% if not job.processing_finished %} +
+ {% endif %} + {{ partials['status']|safe }} + {% if not job.processing_finished %} +
+ {% endif %} {% if not finished %}
-