diff --git a/.ds.baseline b/.ds.baseline index 0ded707f2..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-01-16T16:38:48Z" + "generated_at": "2025-02-26T18:19:37Z" } 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 a87db3fcd..a659829b2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,3 +20,10 @@ Please enter a detailed description here. * Consideration 1 * Consideration 2 * Consideration ... + +## A11y Checks (if applicable) + +* 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..97e081798 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -10,7 +10,7 @@ env: FLASK_APP: application.py WERKZEUG_DEBUG_PIN: off REDIS_ENABLED: 0 - NODE_VERSION: 16.15.1 + NODE_VERSION: 22.3.0 AWS_US_TOLL_FREE_NUMBER: "+18556438890" ADMIN_BASE_URL: http://localhost:6012 @@ -38,10 +38,10 @@ jobs: output: report-markdown annotations: failed-tests prnumber: ${{ steps.findPr.outputs.number }} - - name: Run style checks - run: poetry run flake8 . - name: Check imports alphabetized run: poetry run isort --check-only ./app ./tests + - name: Run style checks + run: poetry run flake8 . - name: Check dead code run: make dead-code - name: Run js tests @@ -139,7 +139,7 @@ jobs: - uses: ./.github/actions/setup-project - name: Create requirements.txt run: poetry export --without-hashes --format=requirements.txt > requirements.txt - - uses: pypa/gh-action-pip-audit@v1.0.8 + - uses: pypa/gh-action-pip-audit@v1.1.0 with: inputs: requirements.txt ignore-vulns: | @@ -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" @@ -176,7 +177,7 @@ jobs: cmd_options: "-I" a11y-scan: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-project diff --git a/.github/workflows/daily_checks.yml b/.github/workflows/daily_checks.yml index a5e81a137..c2d3082a3 100644 --- a/.github/workflows/daily_checks.yml +++ b/.github/workflows/daily_checks.yml @@ -16,7 +16,7 @@ env: FLASK_APP: application.py WERKZEUG_DEBUG_PIN: off REDIS_ENABLED: 0 - NODE_VERSION: 16.15.1 + NODE_VERSION: 22.3.0 jobs: dependency-audits: @@ -26,7 +26,7 @@ jobs: - uses: ./.github/actions/setup-project - name: Create requirements.txt run: poetry export --without-hashes --format=requirements.txt > requirements.txt - - uses: pypa/gh-action-pip-audit@v1.0.6 + - uses: pypa/gh-action-pip-audit@v1.1.0 with: inputs: requirements.txt - name: Run npm audit @@ -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/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 972240c9a..70eecf943 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -16,9 +16,17 @@ jobs: with: fetch-depth: 2 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for changes to Terraform id: changed-terraform-files - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | terraform/demo/** @@ -93,7 +101,7 @@ jobs: - name: Check for changes to egress config id: changed-egress-config - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | deploy-config/egress_proxy/notify-admin-demo.*.acl diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 334146485..45ae45244 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -16,9 +16,17 @@ jobs: with: fetch-depth: 2 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for changes to Terraform id: changed-terraform-files - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | terraform/production/** @@ -93,7 +101,7 @@ jobs: - name: Check for changes to egress config id: changed-egress-config - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | deploy-config/egress_proxy/notify-admin-production.*.acl diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 19cb15faa..645bcc44d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,9 +21,17 @@ jobs: with: fetch-depth: 2 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for changes to Terraform id: changed-terraform-files - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | terraform/staging/** @@ -100,7 +108,7 @@ jobs: - name: Check for changes to egress config id: changed-egress-config - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v45 with: files: | deploy-config/egress_proxy/notify-admin-staging.*.acl diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 5f495ceba..62fa1b3c7 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -15,6 +15,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for drift uses: dflook/terraform-check@v1 env: @@ -35,6 +43,14 @@ jobs: with: ref: 'production' + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for drift uses: dflook/terraform-check@v1 env: @@ -55,6 +71,14 @@ jobs: with: ref: 'production' + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Check for drift uses: dflook/terraform-check@v1 env: diff --git a/.github/workflows/terraform-demo.yml b/.github/workflows/terraform-demo.yml index 765f9e53b..1910de574 100644 --- a/.github/workflows/terraform-demo.yml +++ b/.github/workflows/terraform-demo.yml @@ -18,6 +18,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Terraform format id: format run: terraform fmt -check @@ -51,7 +59,7 @@ jobs: # inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow - name: Update PR - uses: actions/github-script@v6 + uses: actions/github-script@v7 # we would like to update the PR even when a prior step failed if: ${{ always() }} with: diff --git a/.github/workflows/terraform-production.yml b/.github/workflows/terraform-production.yml index 1988c5d95..91b3a48b9 100644 --- a/.github/workflows/terraform-production.yml +++ b/.github/workflows/terraform-production.yml @@ -18,6 +18,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Terraform format id: format run: terraform fmt -check @@ -51,7 +59,7 @@ jobs: # inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow - name: Update PR - uses: actions/github-script@v6 + uses: actions/github-script@v7 # we would like to update the PR even when a prior step failed if: ${{ always() }} with: diff --git a/.github/workflows/terraform-staging.yml b/.github/workflows/terraform-staging.yml index 1f86ee033..9fe174ee8 100644 --- a/.github/workflows/terraform-staging.yml +++ b/.github/workflows/terraform-staging.yml @@ -18,6 +18,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Looks like we need to install Terraform ourselves now! + # https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "^1.7.5" + terraform_wrapper: false + - name: Terraform format id: format run: terraform fmt -check @@ -51,7 +59,7 @@ jobs: # inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow - name: Update PR - uses: actions/github-script@v6 + uses: actions/github-script@v7 # we would like to update the PR even when a prior step failed if: ${{ always() }} with: diff --git a/.gitignore b/.gitignore index 59b996de8..62f46925c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ ## Non user files allowed to be commited !app/assets/pdf/tcpa_overview.pdf +!app/assets/pdf/investing-notifications-tts-public-benefits-memo.pdf +!app/assets/pdf/out-of-pilot-announcement.pdf +!app/assets/pdf/studio-research-snapshot-2022-07-external.pdf +!app/assets/pdf/TCPA-Overview.pdf !tests/test_pdf_files/no_eof_marker.pdf !tests/test_pdf_files/multi_page_pdf.pdf !tests/test_pdf_files/big.pdf diff --git a/.pa11yci b/.pa11yci index 1fde9f2d8..8a0718cb5 100644 --- a/.pa11yci +++ b/.pa11yci @@ -1,7 +1,10 @@ { "defaults": { "standard": "WCAG2AA", - "runners": ["htmlcs"], + "runners": ["htmlcs"], + "chromeLaunchConfig": { + "executablePath": "/usr/bin/google-chrome" + }, "concurrency": 1, "hideElements": [ "nav > ol a", diff --git a/Makefile b/Makefile index 9e0eeb46e..8fa5364a0 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,13 @@ py-lint: ## Run python linting scanners and black poetry run flake8 . poetry run isort --check-only ./app ./tests +.PHONY: tada +tada: ## Run python linting scanners and black + poetry run isort ./app ./tests + poetry run black . + poetry run flake8 . + + .PHONY: avg-complexity avg-complexity: echo "*** Shows average complexity in radon of all code ***" @@ -155,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 62c1e6e3e..a9e75debd 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') @@ -123,11 +130,12 @@ .append('g') .attr('class', 'bar-group') .attr('fill', d => color(d.key)); - + const minBarHeight = 5; barGroups.selectAll('rect') .data(d => d) .enter() .append('rect') + .filter(d => d[1] - d[0] > 0) .attr('x', d => x(d.data.label)) .attr('y', height) .attr('height', 0) @@ -148,11 +156,13 @@ .transition() .duration(1000) .attr('y', d => y(d[1])) - .attr('height', d => y(d[0]) - y(d[1])); - }; + .attr('height', d => { + const calculatedHeight = y(d[0]) - y(d[1]); + return calculatedHeight < minBarHeight ? minBarHeight : calculatedHeight; + }); }; // Function to create an accessible table - const createTable = function(tableId, chartType, labels, deliveredData, failedData) { + const createTable = function(tableId, chartType, labels, deliveredData, failedData, pendingData) { const table = document.getElementById(tableId); table.innerHTML = ""; // Clear previous data @@ -164,7 +174,7 @@ // Create table header const headerRow = document.createElement('tr'); - const headers = ['Day', 'Delivered', 'Failed']; + const headers = ['Day', 'Delivered', 'Failed', 'Pending']; headers.forEach(headerText => { const th = document.createElement('th'); th.textContent = headerText; @@ -187,6 +197,10 @@ cellFailed.textContent = failedData[index]; row.appendChild(cellFailed); + const cellPending = document.createElement('td'); + cellPending.textContent = pendingData[index]; + row.appendChild(cellPending); + tbody.appendChild(row); }); @@ -196,12 +210,19 @@ }; const fetchData = function(type) { + var ctx = document.getElementById('weeklyChart'); if (!ctx) { return; } - var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`; + var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + var url = type === 'service' + ? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}` + : `/services/${currentServiceId}/daily-stats-by-user.json`; + + return fetch(url) .then(response => { if (!response.ok) { @@ -213,7 +234,7 @@ labels = []; deliveredData = []; failedData = []; - + pendingData = []; let totalMessages = 0; for (var dateString in data) { @@ -224,9 +245,8 @@ labels.push(formattedDate); deliveredData.push(data[dateString].sms.delivered); failedData.push(data[dateString].sms.failure); - - // Calculate the total number of messages - totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure; + pendingData.push(data[dateString].sms.pending || 0); + totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure + data[dateString].sms.pending; } } @@ -252,17 +272,18 @@ } } else { // If there are messages, create the chart and table - createChart('#weeklyChart', labels, deliveredData, failedData); - createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); - } - - return data; - }) - .catch(error => console.error('Error fetching daily stats:', error)); - }; + createChart('#weeklyChart', labels, deliveredData, failedData, pendingData); + createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData); + } + return data; + }) + .catch(error => console.error('Error fetching daily stats:', error)); + }; + setInterval(() => fetchData(currentType), 25000); const handleDropdownChange = function(event) { const selectedValue = event.target.value; + currentType = selectedValue; const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`); const selectElement = document.getElementById('options'); const selectedText = selectElement.options[selectElement.selectedIndex].text; @@ -270,36 +291,67 @@ subTitle.textContent = `${selectedText} - last 7 days`; fetchData(selectedValue); - // Update ARIA live region const liveRegion = document.getElementById('aria-live-account'); liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`; - // Switch tables based on dropdown selection - const selectedTable = selectedValue === "individual" ? "table1" : "table2"; - const tables = document.querySelectorAll('.table-overflow-x-auto'); - tables.forEach(function(table) { - table.classList.add('hidden'); // Hide all tables by adding the hidden class - table.classList.remove('visible'); // Ensure they are not visible - }); - const tableToShow = document.getElementById(selectedTable); - tableToShow.classList.remove('hidden'); // Remove hidden class - tableToShow.classList.add('visible'); // Add visible class + const tableHeading = document.querySelector('#tableActivity h2'); + const senderColumns = document.querySelectorAll('.sender-column'); + const allRows = document.querySelectorAll('#activity-table tbody tr'); + const caption = document.querySelector('#activity-table caption'); + + if (selectedValue === 'individual') { + + tableHeading.textContent = 'My activity'; + caption.textContent = `Table showing the sent jobs for ${currentUserName}`; + + senderColumns.forEach(col => { + col.style.display = 'none'; + }); + + allRows.forEach(row => row.style.display = 'none'); + + const userRows = Array.from(allRows).filter(row => { + const senderCell = row.querySelector('.sender-column'); + const rowSender = senderCell ? senderCell.textContent.trim() : ''; + return rowSender === currentUserName; + }); + + userRows.slice(0, 5).forEach(row => { + row.style.display = ''; + }); + } else { + + tableHeading.textContent = 'Service activity'; + caption.textContent = `Table showing the sent jobs for service`; + + senderColumns.forEach(col => { + col.style.display = ''; + }); + + allRows.forEach((row, index) => { + row.style.display = (index < 5) ? '' : 'none'; + }); + } }; document.addEventListener('DOMContentLoaded', function() { // Initialize activityChart chart and table with service data by default - fetchData('service'); + fetchData(currentType); + + const allRows = Array.from(document.querySelectorAll('#activity-table tbody tr')); + allRows.forEach((row, index) => { + row.style.display = (index < 5) ? '' : 'none'; + }); - // Add event listener to the dropdown const dropdown = document.getElementById('options'); dropdown.addEventListener('change', handleDropdownChange); }); // Resize chart on window resize window.addEventListener('resize', function() { - if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) { - createChart('#weeklyChart', labels, deliveredData, failedData); - createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData); + if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0 && pendingData.length > 0) { + createChart('#weeklyChart', labels, deliveredData, failedData, pendingData); + createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData); } }); 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/assets/pdf/TCPA-Overview.pdf b/app/assets/pdf/TCPA-Overview.pdf new file mode 100644 index 000000000..92a558b75 Binary files /dev/null and b/app/assets/pdf/TCPA-Overview.pdf differ diff --git a/app/assets/pdf/investing-notifications-tts-public-benefits-memo.pdf b/app/assets/pdf/investing-notifications-tts-public-benefits-memo.pdf new file mode 100644 index 000000000..727d6a122 Binary files /dev/null and b/app/assets/pdf/investing-notifications-tts-public-benefits-memo.pdf differ diff --git a/app/assets/pdf/out-of-pilot-announcement.pdf b/app/assets/pdf/out-of-pilot-announcement.pdf new file mode 100644 index 000000000..fc763058f Binary files /dev/null and b/app/assets/pdf/out-of-pilot-announcement.pdf differ diff --git a/app/assets/pdf/studio-research-snapshot-2022-07-external.pdf b/app/assets/pdf/studio-research-snapshot-2022-07-external.pdf new file mode 100644 index 000000000..516c1bc07 Binary files /dev/null and b/app/assets/pdf/studio-research-snapshot-2022-07-external.pdf differ diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index c4e850839..3a10a6042 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -1042,3 +1042,7 @@ nav.nav { font-size: units(3); font-weight: bold; } + +.form-control-error { + border: 4px solid #b10e1e +} diff --git a/app/content/get-started.md b/app/content/get-started.md index 7d18aacbd..c97b9efc6 100644 --- a/app/content/get-started.md +++ b/app/content/get-started.md @@ -5,7 +5,7 @@ Explore Notify, add team members, and practice [sending messages to teammates](/using-notify/trial-mode). 2. ## Personalize content -Learn how to [personalize messages](/using-notify/guidance) to increase response. +Learn how to [personalize messages](/using-notify/how-to) to increase response. 3. ## Check delivery status [Analyze the delivery](/using-notify/delivery-status) of your messages and download reports diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 8013acb9d..b08967254 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 @@ -14,7 +15,7 @@ from app import ( service_api_client, template_statistics_client, ) -from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left +from app.formatters import format_date_numeric, format_datetime_numeric from app.main import main from app.main.views.user_profile import set_timezone from app.statistics_utils import get_formatted_percentage @@ -48,71 +49,105 @@ def service_dashboard(service_id): if not current_user.has_permissions("view_activity"): return redirect(url_for("main.choose_template", service_id=service_id)) - yearly_usage = billing_api_client.get_annual_usage_for_service( - service_id, - get_current_financial_year(), - ) - free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year( - current_service.id, - ) - usage_data = get_annual_usage_breakdown(yearly_usage, free_sms_allowance) - sms_sent = usage_data["sms_sent"] - sms_allowance_remaining = usage_data["sms_allowance_remaining"] - job_response = job_api_client.get_jobs(service_id)["data"] service_data_retention_days = 7 - jobs = [ - { - "job_id": job["id"], - "time_left": get_time_left(job["created_at"]), - "download_link": url_for( - ".view_job_csv", service_id=current_service.id, job_id=job["id"] - ), - "view_job_link": url_for( - ".view_job", service_id=current_service.id, job_id=job["id"] - ), - "created_at": job["created_at"], - "processing_finished": job.get("processing_finished"), - "processing_started": job.get("processing_started"), - "notification_count": job["notification_count"], - "created_by": job["created_by"], - "template_name": job["template_name"], - "original_file_name": job["original_file_name"], - } - for job in job_response - if job["job_status"] != "cancelled" + active_jobs = [job for job in job_response if job["job_status"] != "cancelled"] + sorted_jobs = sorted(active_jobs, key=lambda job: job["created_at"], reverse=True) + job_lists = [ + {**job_dict, "finished_processing": job_is_finished(job_dict)} + for job_dict in sorted_jobs ] + + total_messages = service_api_client.get_service_message_ratio(service_id) + messages_remaining = total_messages.get("messages_remaining", 0) + messages_sent = total_messages.get("messages_sent", 0) return render_template( "views/dashboard/dashboard.html", updates_url=url_for(".service_dashboard_updates", service_id=service_id), partials=get_dashboard_partials(service_id), - jobs=jobs, + jobs=job_lists, service_data_retention_days=service_data_retention_days, - sms_sent=sms_sent, - sms_allowance_remaining=sms_allowance_remaining, + messages_remaining=messages_remaining, + messages_sent=messages_sent, ) -@main.route("/daily_stats.json") -def get_daily_stats(): - service_id = session.get("service_id") - date_range = get_stats_date_range() +def job_is_finished(job_dict): + done_statuses = [ + "delivered", + "sent", + "failed", + "technical-failure", + "temporary-failure", + "permanent-failure", + "cancelled", + ] - stats = service_api_client.get_service_notification_statistics_by_day( - service_id, start_date=date_range["start_date"], days=date_range["days"] + processed_count = sum( + stat["count"] + for stat in job_dict["statistics"] + if stat["status"] in done_statuses ) - return jsonify(stats) + return job_dict["notification_count"] == processed_count -@main.route("/daily_stats_by_user.json") -def get_daily_stats_by_user(): - service_id = session.get("service_id") +@main.route("/services//daily-stats.json") +@user_has_permissions() +def get_daily_stats(service_id): + date_range = get_stats_date_range() + days = date_range["days"] + user_timezone = request.args.get("timezone", "UTC") + + stats_utc = service_api_client.get_service_notification_statistics_by_day( + service_id, + start_date=date_range["start_date"], + days=days, + ) + + local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days) + return jsonify(local_stats) + + +def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days): + tz = ZoneInfo(user_timezone) + today_local = datetime.now(tz).date() + start_local = today_local - timedelta(days=days - 1) + + # Generate exactly days local dates, each with zeroed stats + days_list = [ + (start_local + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(days) + ] + aggregator = { + d: { + "sms": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + "email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0}, + } + for d in days_list + } + + # Convert each UTC timestamp to local date and iterate + for utc_ts, data in stats_utc.items(): + utc_dt = datetime.strptime(utc_ts, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=ZoneInfo("UTC") + ) + local_day = utc_dt.astimezone(tz).strftime("%Y-%m-%d") + + if local_day in aggregator: + for msg_type in ["sms", "email"]: + for status in ["delivered", "failure", "pending", "requested"]: + aggregator[local_day][msg_type][status] += data[msg_type][status] + + return aggregator + + +@main.route("/services//daily-stats-by-user.json") +@user_has_permissions() +def get_daily_stats_by_user(service_id): date_range = get_stats_date_range() - user_id = current_user.id stats = service_api_client.get_user_service_notification_statistics_by_day( service_id, - user_id, + user_id=current_user.id, start_date=date_range["start_date"], days=date_range["days"], ) diff --git a/app/main/views/index.py b/app/main/views/index.py index 8b63d2bd8..5c0312afc 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -217,11 +217,11 @@ def benchmark_performance(): ) -@main.route("/using-notify/guidance") +@main.route("/using-notify/how-to") @user_is_logged_in -def guidance_index(): +def how_to(): return render_template( - "views/guidance/index.html", + "views/how-to/index.html", navigation_links=using_notify_nav(), ) @@ -266,29 +266,29 @@ def join_notify(): ) -@main.route("/using-notify/guidance/create-and-send-messages") +@main.route("/using-notify/how-to/create-and-send-messages") @user_is_logged_in def create_and_send_messages(): return render_template( - "views/guidance/create-and-send-messages.html", + "views/how-to/create-and-send-messages.html", navigation_links=using_notify_nav(), ) -@main.route("/using-notify/guidance/edit-and-format-messages") +@main.route("/using-notify/how-to/edit-and-format-messages") @user_is_logged_in def edit_and_format_messages(): return render_template( - "views/guidance/edit-and-format-messages.html", + "views/how-to/edit-and-format-messages.html", navigation_links=using_notify_nav(), ) -@main.route("/using-notify/guidance/send-files-by-email") +@main.route("/using-notify/how-to/send-files-by-email") @user_is_logged_in def send_files_by_email(): return render_template( - "views/guidance/send-files-by-email.html", + "views/how-to/send-files-by-email.html", navigation_links=using_notify_nav(), ) 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/main/views/platform_admin.py b/app/main/views/platform_admin.py index 6a121ecc2..bfd3749f3 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -116,6 +116,70 @@ def download_all_users(): return response +@main.route("/platform-admin/get-redis-report") +@user_is_platform_admin +def get_redis_report(): + + memory_info = redis_client.info("memory") + memory_used = memory_info.get("used_memory_human", "N/A") + max_memory = memory_info.get("maxmemory_human", "N/A") + if max_memory == "0B": + max_memory = "No set limit" + mem_fragmentation = memory_info.get("mem_fragmentation_ratio", "N/A") + frag_quality = "Swapping (bad)" + if mem_fragmentation >= 1.0: + frag_quality = "Healthy" + if mem_fragmentation > 1.5: + frag_quality = "Problematic" + if mem_fragmentation > 2.0: + frag_quality = "Severe fragmentation" + + frag_note = "" + if mem_fragmentation > 2.0: + frag_note = "Use MEMORY PURGE.\nReplace multiple small keys with hashes.\nAvoid long keys.\nSet max_memory." + elif mem_fragmentation < 1.0: + frag_note = "Allocate more RAM.\nSet max_memory." + + keys = redis_client.keys("*") + key_details = [] + + for key in keys: + key_type = redis_client.type(key).decode("utf-8") + ttl = redis_client.ttl(key) + ttl_str = "No Expiry" if ttl == -1 else f"{ttl} seconds" + key_details.append( + {"Key": key.decode("utf-8"), "Type": key_type, "TTL": ttl_str} + ) + output = StringIO() + writer = csv.writer( + output, + ) + writer.writerow(["Redis Report"]) + writer.writerow([]) + + writer.writerow(["Memory"]) + writer.writerow(["", "Memory Used", memory_used]) + writer.writerow(["", "Max Memory", max_memory]) + writer.writerow(["", "Memory Fragmentation Ratio", mem_fragmentation]) + writer.writerow(["", "Memory Fragmentation Quality", frag_quality, frag_note]) + writer.writerow([]) + + writer.writerow(["Keys Overview"]) + writer.writerow(["", "TTL", "Type", "Key"]) + for key_detail in key_details: + writer.writerow( + ["", key_detail["TTL"], key_detail["Type"], key_detail["Key"][0:50]] + ) + + csv_data = output.getvalue() + + # Create a direct download response with the CSV data and appropriate headers + response = Response(csv_data, content_type="text/csv; charset=utf-8") + response.headers["Content-Disposition"] = "attachment; filename=redis.csv" + + return response + + def is_over_threshold(number, total, threshold): percentage = number / total * 100 if total else 0 return percentage > threshold diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 004dce2ae..1cb163691 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -68,11 +68,12 @@ def _get_access_token(code): # pragma: no cover id_token = get_id_token(response_json) nonce = id_token["nonce"] nonce_key = f"login-nonce-{unquote(nonce)}" - stored_nonce = redis_client.get(nonce_key).decode("utf8") + if not os.getenv("NOTIFY_ENVIRONMENT") == "development": + stored_nonce = redis_client.get(nonce_key).decode("utf8") - if nonce != stored_nonce: - current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}") - abort(403) + if nonce != stored_nonce: + current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}") + abort(403) try: access_token = response_json["access_token"] @@ -112,7 +113,7 @@ def _do_login_dot_gov(): # $ pragma: no cover verify_key = f"login-verify_email-{unquote(state)}" verify_path = bool(redis_client.get(verify_key)) - if not verify_path: + if not verify_path and not os.getenv("NOTIFY_ENVIRONMENT") == "development": state_key = f"login-state-{unquote(state)}" stored_state = unquote(redis_client.get(state_key).decode("utf8")) if state != stored_state: diff --git a/app/main/views/sub_navigation_dictionaries.py b/app/main/views/sub_navigation_dictionaries.py index 3b2cf84c1..db086e511 100644 --- a/app/main/views/sub_navigation_dictionaries.py +++ b/app/main/views/sub_navigation_dictionaries.py @@ -2,7 +2,7 @@ def using_notify_nav(): nav_items = [ {"name": "Get started", "link": "main.get_started"}, { - "name": "Best Practices", + "name": "Best practices", "link": "main.best_practices", "sub_navigation_items": [ { @@ -33,8 +33,8 @@ def using_notify_nav(): }, {"name": "Trial mode", "link": "main.trial_mode_new"}, {"name": "Tracking usage", "link": "main.pricing"}, - {"name": "Delivery Status", "link": "main.message_status"}, - {"name": "Guidance", "link": "main.guidance_index"}, + {"name": "Delivery status", "link": "main.message_status"}, + {"name": "How to", "link": "main.how_to"}, ] return nav_items diff --git a/app/navigation.py b/app/navigation.py index 424d03ae3..f20df4e5f 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -54,7 +54,7 @@ class HeaderNavigation(Navigation): "pricing", "trial_mode_new", "message_status", - "guidance_index", + "how_to", }, "accounts-or-dashboard": { "conversation", 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/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/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 4a41ac28f..1aab20a90 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -116,7 +116,7 @@ class UserApiClient(NotifyAdminAPIClient): data["next"] = next_string if code_type == "email": data["email_auth_link_host"] = self.admin_url - endpoint = f"/user/{user_id}/{code_type}-code" + endpoint = f"/user/{user_id}/{code_type}-code" current_app.logger.warn(hilite(f"Sending verify_code {code_type} to {user_id}")) self.post(endpoint, data=data) diff --git a/app/templates/base.html b/app/templates/base.html index 1d2778a9e..3135e5cdb 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,10 +14,8 @@ {% block bodyStart %} {% block extra_javascripts_before_body %} - - {% endblock %} {% endblock %} @@ -145,10 +143,8 @@ {% block bodyEnd %} {% block extra_javascripts %} {% endblock %} - - {% endblock %} 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..d56d73201 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' if job.notification_count == 1 else 'messages have' }} 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' if job.notification_count == 1 else 'messages are' }} 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' if job.notification_count == 1 else 'messages are' }} 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' if job.notification_count == 1 else 'messages have' }} 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/about/about.html b/app/templates/views/about/about.html index db7be207a..bc8fec04d 100644 --- a/app/templates/views/about/about.html +++ b/app/templates/views/about/about.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% set page_title = "About Notify" %} - {% block per_page_title %} {{page_title}} {% endblock %} @@ -27,7 +26,7 @@ {% set product_highlights = [ { "svg_src": "#send", - "card_heading": "Send customized one-way customized messages", + "card_heading": "Send customized one-way messages", "p_text": "Upload a file with recipient phone numbers and Notify.gov sends customized messages", }, { diff --git a/app/templates/views/dashboard/activity-table.html b/app/templates/views/dashboard/activity-table.html new file mode 100644 index 000000000..1dbb2bc7d --- /dev/null +++ b/app/templates/views/dashboard/activity-table.html @@ -0,0 +1,74 @@ +

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 not job.finished_processing %} + {% if job.scheduled_for%} + Scheduled for {{ job.scheduled_for|format_datetime_table }} + {% elif job.processing_started %} + Sending since {{ job.processing_started|format_datetime_table }} + {% else %} + Pending since {{ job.created_at|format_datetime_table }} + {% endif %} + {% else %} + Sent on {{ job.processing_started|format_datetime_table }} + {% endif %} + {{ 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 50786f7cc..e22d49dc3 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -6,151 +6,49 @@ {% 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

- -
- -
-
-
- -

Recent activity

-
-
- - -
-
-
-
{{ current_service.name }} - last 7 days
-
-
-
-
-
-
-
- - {% 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') }} +
+
+

Total messages

+
+ +
+
+
+ +{% include 'views/dashboard/activity-table.html' %} + +{% if current_user.has_permissions('manage_service') %}{% endif %} + +{{ 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/features.html b/app/templates/views/features.html index 456f16815..0f7e7eb58 100644 --- a/app/templates/views/features.html +++ b/app/templates/views/features.html @@ -28,7 +28,7 @@

Personalized content

Notify makes it easy to send personalized messages from a single template.

-

See how to personalize your content.

+

See how to personalize your content.

Bulk sending

To send a batch of messages at once, upload a list of contact details to Notify. You can also schedule the date and time you want them to be sent.

diff --git a/app/templates/views/guides/benchmark-performance.html b/app/templates/views/guides/benchmark-performance.html index bd2662fbe..c302fc5b8 100644 --- a/app/templates/views/guides/benchmark-performance.html +++ b/app/templates/views/guides/benchmark-performance.html @@ -8,7 +8,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guides/best-practices.html b/app/templates/views/guides/best-practices.html index 9826e6a33..1f46d4544 100644 --- a/app/templates/views/guides/best-practices.html +++ b/app/templates/views/guides/best-practices.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% set page_title = "Best Practices" %} +{% set page_title = "Best practices" %} {% block per_page_title %} {{page_title}} @@ -8,7 +8,7 @@ {% block content_column_content %}
-

Best Practices

+

Best practices

For texting the public

Effectively reaching your audience and supporting your program’s goals starts with strategically planning out what text messages can help you achieve and how to approach a thoughtful rollout. diff --git a/app/templates/views/guides/clear-goals.html b/app/templates/views/guides/clear-goals.html index d1054e3a9..a6e3a742b 100644 --- a/app/templates/views/guides/clear-goals.html +++ b/app/templates/views/guides/clear-goals.html @@ -8,7 +8,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guides/establish-trust.html b/app/templates/views/guides/establish-trust.html index 0ab95c73a..026e9da81 100644 --- a/app/templates/views/guides/establish-trust.html +++ b/app/templates/views/guides/establish-trust.html @@ -10,7 +10,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guides/multiple-languages.html b/app/templates/views/guides/multiple-languages.html index f295df308..b8bb7883c 100644 --- a/app/templates/views/guides/multiple-languages.html +++ b/app/templates/views/guides/multiple-languages.html @@ -8,7 +8,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guides/rules-and-regulations.html b/app/templates/views/guides/rules-and-regulations.html index 5943e462d..8ad6d000b 100644 --- a/app/templates/views/guides/rules-and-regulations.html +++ b/app/templates/views/guides/rules-and-regulations.html @@ -8,7 +8,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guides/write-for-action.html b/app/templates/views/guides/write-for-action.html index ff382864a..31ac2aa84 100644 --- a/app/templates/views/guides/write-for-action.html +++ b/app/templates/views/guides/write-for-action.html @@ -9,7 +9,7 @@ {% endblock %} {% block content_column_content %} -{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }} +{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}

{{page_title}}

diff --git a/app/templates/views/guidance/create-and-send-messages.html b/app/templates/views/how-to/create-and-send-messages.html similarity index 100% rename from app/templates/views/guidance/create-and-send-messages.html rename to app/templates/views/how-to/create-and-send-messages.html diff --git a/app/templates/views/guidance/edit-and-format-messages.html b/app/templates/views/how-to/edit-and-format-messages.html similarity index 100% rename from app/templates/views/guidance/edit-and-format-messages.html rename to app/templates/views/how-to/edit-and-format-messages.html diff --git a/app/templates/views/guidance/index.html b/app/templates/views/how-to/index.html similarity index 95% rename from app/templates/views/guidance/index.html rename to app/templates/views/how-to/index.html index 062bae555..aa2361cb6 100644 --- a/app/templates/views/guidance/index.html +++ b/app/templates/views/how-to/index.html @@ -4,11 +4,11 @@ {% from "components/service-link.html" import service_link %} {% block per_page_title %} - Guidance + How to {% endblock %} {% block content_column_content %} -

Guidance

+

How to

Notify allows you to easily create templates for messages for your recipients. You can customize messages to encourage your recipient to manage their benefits and increase follow-through.

@@ -61,7 +61,7 @@ your recipient to manage their benefits and increase follow-through.

  1. Add a placeholder to your content by placing two brackets around the personalized elements.
  2. You can manually enter the personalized content or you can upload a spreadsheet with the details and let Notify do the - work for you. See data preparation.
  3. + work for you.

Example

@@ -80,7 +80,7 @@ all or part of the message contingent upon specific criteria associated with the
  1. Use two brackets and ?? to define the conditional content.
  2. You can manually enter the conditional content or you can upload a spreadsheet with the personal details and let Notify - do the work for you. See data preparation.
  3. + do the work for you.

Examples

diff --git a/app/templates/views/guidance/send-files-by-email.html b/app/templates/views/how-to/send-files-by-email.html similarity index 100% rename from app/templates/views/guidance/send-files-by-email.html rename to app/templates/views/how-to/send-files-by-email.html diff --git a/app/templates/views/jobs/job.html b/app/templates/views/jobs/job.html index 7014e1987..d5d3711d5 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.finished_processing %} +
+ {% endif %} + {{ partials['status']|safe }} + {% if not job.finished_processing %} +
+ {% endif %} {% if not finished %}
Download All Users

+

+ Get Redis Report +

{% endblock %} diff --git a/app/templates/views/send-test.html b/app/templates/views/send-test.html index 720849ae6..fd5eb63db 100644 --- a/app/templates/views/send-test.html +++ b/app/templates/views/send-test.html @@ -37,8 +37,8 @@ data_kwargs={'force-focus': True} ) %}
-