Compare commits

..

1 Commits

Author SHA1 Message Date
Sheev Davé
42ff7aa623 Update issue_template.yml
Added a11y checks and language in the acceptance criteria, tasks, and dependencies sections
2025-01-22 12:14:31 -08:00
193 changed files with 5906 additions and 5154 deletions

View File

@@ -127,6 +127,16 @@
}
],
"results": {
".github/workflows/checks.yml": [
{
"type": "Secret Keyword",
"filename": ".github/workflows/checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 68,
"is_secret": false
}
],
"app/assets/js/uswds.min.js": [
{
"type": "Secret Keyword",
@@ -151,7 +161,7 @@
"filename": "app/config.py",
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
"is_verified": false,
"line_number": 118,
"line_number": 120,
"is_secret": false
}
],
@@ -517,7 +527,7 @@
"filename": "tests/app/main/views/test_accept_invite.py",
"hashed_secret": "07f0a6c13923fc3b5f0c57ffa2d29b715eb80d71",
"is_verified": false,
"line_number": 631,
"line_number": 643,
"is_secret": false
}
],
@@ -674,5 +684,5 @@
}
]
},
"generated_at": "2025-03-20T18:22:36Z"
"generated_at": "2025-01-16T16:38:48Z"
}

View File

@@ -37,7 +37,7 @@ body:
- type: textarea
id: acceptanceCriteria
attributes:
label: "Detailed condition or criteria that must be met for the user story to be considered complete."
label: "Detailed condition or criteria that must be met for the user story to be considered complete. This includes basic a11y checks around screen readers and keyboard testing."
description: "List the acceptance criteria for the user story."
validations:
required: true
@@ -48,7 +48,7 @@ body:
- type: textarea
id: tasks
attributes:
label: "List of specific tasks or sub-tasks that need to be done to implement the user story."
label: "List of specific tasks or sub-tasks that need to be done to implement the user story. This includes a11y standards."
description: "Outline the tasks necessary to implement the user story."
validations:
required: true
@@ -59,22 +59,11 @@ body:
- type: textarea
id: dependencies
attributes:
label: "List any dependencies that need to be resolved before starting or completing this user story."
label: "List any dependencies that need to be resolved before starting or completing this user story. This includes screenreaders and keyboard testing passing."
description: "Specify any dependencies related to the user story."
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:**'

View File

@@ -23,7 +23,5 @@ Please enter a detailed description here.
## 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/)
* Conduct automated 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

View File

@@ -10,7 +10,7 @@ env:
FLASK_APP: application.py
WERKZEUG_DEBUG_PIN: off
REDIS_ENABLED: 0
NODE_VERSION: 22.3.0
NODE_VERSION: 16.15.1
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: Check imports alphabetized
run: poetry run isort --check-only ./app ./tests
- name: Run style checks
run: poetry run flake8 .
- name: Check imports alphabetized
run: poetry run isort --check-only ./app ./tests
- name: Check dead code
run: make dead-code
- name: Run js tests
@@ -51,75 +51,73 @@ jobs:
- name: Check coverage threshold
run: poetry run coverage report --fail-under=90
# TODO FIX THIS!
# end-to-end-tests:
# if: ${{ github.actor != 'dependabot[bot]' }}
end-to-end-tests:
if: ${{ github.actor != 'dependabot[bot]' }}
# permissions:
# checks: write
# pull-requests: write
# contents: write
# runs-on: ubuntu-latest
# environment: staging
# services:
# postgres:
# image: postgres
# env:
# POSTGRES_USER: user
# POSTGRES_PASSWORD: password
# POSTGRES_DB: test_notification_api
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# ports:
# # Maps tcp port 5432 on service container to the host
# - 5432:5432
# redis:
# image: redis
# options: >-
# --health-cmd "redis-cli ping"
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# ports:
# # Maps tcp port 6379 on service container to the host
# - 6379:6379
permissions:
checks: write
pull-requests: write
contents: write
runs-on: ubuntu-latest
environment: staging
services:
postgres:
image: postgres
env:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: test_notification_api
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
redis:
image: redis
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 6379 on service container to the host
- 6379:6379
# steps:
# - uses: actions/checkout@v4
# - uses: ./.github/actions/setup-project
# - uses: jwalton/gh-find-current-pr@v1
# id: findPr
# - name: Check API Server availability
# run: |
# curl --fail -v https://notify-api-staging.app.cloud.gov || exit 1
# - name: Run Admin server
# # If we want to log stuff and see what's broken,
# # insert this line:
# # tail -f admin-server.log &
# # above make e2e-test
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- uses: jwalton/gh-find-current-pr@v1
id: findPr
- name: Check API Server availability
run: |
curl --fail -v https://notify-api-staging.app.cloud.gov || exit 1
- name: Run Admin server
# If we want to log stuff and see what's broken,
# insert this line:
# tail -f admin-server.log &
# above make e2e-test
# run: |
# make run-flask > admin-server.log 2>&1 &
# tail -f admin-server.log &
# make e2e-test
# env:
# API_HOST_NAME: https://notify-api-staging.app.cloud.gov/
# SECRET_KEY: ${{ secrets.SECRET_KEY }}
# DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
# ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
# ADMIN_CLIENT_USERNAME: notify-admin
# NOTIFY_ENVIRONMENT: e2etest
# NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
# NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
# NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
# NOTIFY_E2E_TEST_URI: http://localhost:6012/
# VCAP_SERVICES: ${{ secrets.VCAP_SERVICES }}
run: |
make run-flask > admin-server.log 2>&1 &
tail -f admin-server.log &
make e2e-test
env:
API_HOST_NAME: https://notify-api-staging.app.cloud.gov/
SECRET_KEY: ${{ secrets.SECRET_KEY }}
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
ADMIN_CLIENT_USERNAME: notify-admin
NOTIFY_ENVIRONMENT: e2etest
NOTIFY_E2E_AUTH_STATE_PATH: ${{ secrets.NOTIFY_E2E_AUTH_STATE_PATH }}
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
NOTIFY_E2E_TEST_URI: http://localhost:6012/
VCAP_SERVICES: ${{ secrets.VCAP_SERVICES }}
validate-new-relic-config:
runs-on: ubuntu-latest
environment: staging
@@ -141,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.1.0
- uses: pypa/gh-action-pip-audit@v1.0.8
with:
inputs: requirements.txt
ignore-vulns: |
@@ -168,7 +166,7 @@ jobs:
env:
NOTIFY_ENVIRONMENT: scanning
- name: Run OWASP Baseline Scan
uses: zaproxy/action-baseline@v0.14.0
uses: zaproxy/action-baseline@v0.9.0
with:
docker_name: "ghcr.io/zaproxy/zaproxy:weekly"
target: "http://localhost:6012"
@@ -178,7 +176,7 @@ jobs:
cmd_options: "-I"
a11y-scan:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project

View File

@@ -16,7 +16,7 @@ env:
FLASK_APP: application.py
WERKZEUG_DEBUG_PIN: off
REDIS_ENABLED: 0
NODE_VERSION: 22.3.0
NODE_VERSION: 16.15.1
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.1.0
- uses: pypa/gh-action-pip-audit@v1.0.6
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.12.0
uses: zaproxy/action-full-scan@v0.7.0
with:
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
target: 'http://localhost:6012'

View File

@@ -16,21 +16,23 @@ 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
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
with:
terraform_version: "^1.7.5"
terraform_wrapper: false
files: |
terraform/demo/**
terraform/shared/**
.github/workflows/deploy-demo.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/demo
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init
- name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/demo
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -89,7 +91,16 @@ jobs:
--var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-admin-demo.*.acl
.github/actions/deploy-proxy/action.yml
.github/workflows/deploy-demo.yml
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -16,21 +16,23 @@ 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
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
with:
terraform_version: "^1.7.5"
terraform_wrapper: false
files: |
terraform/production/**
terraform/shared/**
.github/workflows/deploy-prod.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/production
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init
- name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/production
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -89,7 +91,16 @@ jobs:
--var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-admin-production.*.acl
.github/actions/deploy-proxy/action.yml
.github/workflows/deploy-prod.yml
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -21,21 +21,23 @@ 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
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v44
with:
terraform_version: "^1.7.5"
terraform_wrapper: false
files: |
terraform/staging/**
terraform/shared/**
.github/workflows/deploy.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init
- name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -96,7 +98,16 @@ jobs:
--strategy rolling
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-admin-staging.*.acl
.github/actions/deploy-proxy/action.yml
.github/workflows/deploy.yml
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -15,14 +15,6 @@ 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:
@@ -43,14 +35,6 @@ 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:
@@ -71,14 +55,6 @@ 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:

View File

@@ -18,14 +18,6 @@ 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
@@ -59,7 +51,7 @@ jobs:
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
- name: Update PR
uses: actions/github-script@v7
uses: actions/github-script@v6
# we would like to update the PR even when a prior step failed
if: ${{ always() }}
with:

View File

@@ -18,14 +18,6 @@ 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
@@ -59,7 +51,7 @@ jobs:
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
- name: Update PR
uses: actions/github-script@v7
uses: actions/github-script@v6
# we would like to update the PR even when a prior step failed
if: ${{ always() }}
with:

View File

@@ -18,14 +18,6 @@ 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
@@ -59,7 +51,7 @@ jobs:
# inspiration: https://learn.hashicorp.com/tutorials/terraform/github-actions#review-actions-workflow
- name: Update PR
uses: actions/github-script@v7
uses: actions/github-script@v6
# we would like to update the PR even when a prior step failed
if: ${{ always() }}
with:

8
.gitignore vendored
View File

@@ -14,15 +14,7 @@
*.XLSX
## Non user files allowed to be commited
!app/assets/pdf/best-practices-for-texting-the-public.pdf
!app/assets/pdf/investing-in-notifications-tts-public-benefits-studio-decision-memo.pdf
!app/assets/pdf/best-practices-section-outline.pdf
!app/assets/pdf/standing-up-your-own-notify.pdf
!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

View File

@@ -1,10 +1,7 @@
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["htmlcs"],
"chromeLaunchConfig": {
"executablePath": "/usr/bin/google-chrome"
},
"runners": ["htmlcs"],
"concurrency": 1,
"hideElements": [
"nav > ol a",

View File

@@ -8,7 +8,6 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
exclude: ^app/assets/pdf/.*\.pdf$
- id: debug-statements
- id: check-merge-conflict
- id: check-toml

View File

@@ -6,7 +6,6 @@ APP_VERSION_FILE = app/version.py
GIT_BRANCH ?= $(shell git symbolic-ref --short HEAD 2> /dev/null || echo "detached")
GIT_COMMIT ?= $(shell git rev-parse HEAD 2> /dev/null || echo "")
GIT_HOOKS_PATH ?= $(shell git config --global core.hooksPath || echo "")
VIRTUALENV_ROOT := $(shell [ -z $$VIRTUAL_ENV ] && echo $$(pwd)/venv || echo $$VIRTUAL_ENV)
@@ -15,8 +14,7 @@ NVMSH := $(shell [ -f "$(HOME)/.nvm/nvm.sh" ] && echo "$(HOME)/.nvm/nvm.sh" || e
## DEVELOPMENT
.PHONY: bootstrap
bootstrap: ## Set up everything to run the app
make generate-version-file
bootstrap: generate-version-file ## Set up everything to run the app
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
@@ -26,20 +24,6 @@ bootstrap: ## Set up everything to run the app
source $(NVMSH) && npm ci --no-audit
source $(NVMSH) && npm run build
.PHONY: bootstrap-with-git-hooks
bootstrap-with-git-hooks: ## Sets everything up and accounts for pre-existing git hooks
make generate-version-file
poetry self add poetry-dotenv-plugin
poetry lock --no-update
poetry install --sync --no-root
poetry run playwright install --with-deps
git config --global --unset-all core.hooksPath
poetry run pre-commit install
git config --global core.hookspath "${GIT_HOOKS_PATH}"
source $(NVMSH) --no-use && nvm install && npm install
source $(NVMSH) && npm ci --no-audit
source $(NVMSH) && npm run build
.PHONY: watch-frontend
watch-frontend: ## Build frontend and watch for changes
source $(NVMSH) && npm run watch
@@ -78,13 +62,6 @@ 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 ***"
@@ -178,8 +155,3 @@ 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)

View File

@@ -1,6 +1,5 @@
import os
import pathlib
import re
import secrets
from functools import partial
from time import monotonic
@@ -145,7 +144,6 @@ def _csp(config):
"frame-src": [
"https://www.youtube.com",
"https://www.youtube-nocookie.com",
"https://www.googletagmanager.com",
],
"frame-ancestors": "'none'",
"form-action": "'self'",
@@ -170,20 +168,14 @@ def _csp(config):
def create_app(application):
@application.after_request
def add_csp_header(response):
existing_csp = response.headers.get("Content-Security-Policy", "")
response.headers["Content-Security-Policy"] = existing_csp + "; form-action 'self';"
return response
# @application.context_processor
# def inject_feature_flags():
# this is where feature flags can be easily added as a dictionary within context
# feature_about_page_enabled = application.config.get(
# "FEATURE_ABOUT_PAGE_ENABLED", False
# )
# return dict(
# FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled,
# )
@application.context_processor
def inject_feature_flags():
feature_about_page_enabled = application.config.get(
"FEATURE_ABOUT_PAGE_ENABLED", False
)
return dict(
FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled,
)
@application.context_processor
def inject_initial_signin_url():
@@ -624,8 +616,6 @@ def setup_event_handlers():
def add_template_filters(application):
application.add_template_filter(slugify)
for fn in [
format_auth_type,
format_billions,
@@ -683,10 +673,3 @@ def init_jinja(application):
]
jinja_loader = jinja2.FileSystemLoader(template_folders)
application.jinja_loader = jinja_loader
def slugify(text):
"""
Converts text to lowercase, replaces spaces with hyphens, and removes invalid characters.
"""
return re.sub(r"[^a-z0-9-]", "", re.sub(r"\s+", "-", text.lower()))

View File

@@ -1,14 +1,10 @@
(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'
};
@@ -16,7 +12,7 @@
const FONT_WEIGHT = 'bold';
const MAX_Y = 120;
const createChart = function(containerId, labels, deliveredData, failedData, pendingData) {
const createChart = function(containerId, labels, deliveredData, failedData) {
const container = d3.select(containerId);
container.selectAll('*').remove(); // Clear any existing content
@@ -39,7 +35,7 @@
}
// Calculate total messages
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData) + d3.sum(pendingData);
const totalMessages = d3.sum(deliveredData) + d3.sum(failedData);
// Create legend only if there are messages
const legendContainer = d3.select('.chart-legend');
@@ -49,8 +45,7 @@
// Show legend if there are messages
const legendData = [
{ label: 'Delivered', color: COLORS.delivered },
{ label: 'Failed', color: COLORS.failed },
{ label: 'Pending', color: COLORS.pending }
{ label: 'Failed', color: COLORS.failed }
];
const legendItem = legendContainer.selectAll('.legend-item')
@@ -81,9 +76,8 @@
.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) + (pendingData[i] || 0)));
const y = d3.scaleSymlog()
const maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0)));
const y = d3.scaleSqrt()
.domain([0, maxY + 2]) // Add 2 units of space at the top
.nice()
.range([height, 0]);
@@ -95,7 +89,7 @@
// Generate the y-axis with whole numbers
const yAxis = d3.axisLeft(y)
.ticks(Math.min(maxY + 2, 3))
.ticks(Math.min(maxY + 2, 10)) // Generate up to 10 ticks based on the data
.tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis
svg.append('g')
@@ -106,13 +100,12 @@
const stackData = labels.map((label, i) => ({
label: label,
delivered: deliveredData[i],
failed: failedData[i] || 0,
pending: pendingData[i] || 0
failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0
}));
// Stack the data
const stack = d3.stack()
.keys(['delivered', 'failed', 'pending'])
.keys(['delivered', 'failed'])
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
@@ -120,8 +113,8 @@
// Color scale
const color = d3.scaleOrdinal()
.domain(['delivered', 'failed', 'pending'])
.range([COLORS.delivered, COLORS.failed, COLORS.pending]);
.domain(['delivered', 'failed'])
.range([COLORS.delivered, COLORS.failed]);
// Create bars with animation
const barGroups = svg.selectAll('.bar-group')
@@ -130,12 +123,11 @@
.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)
@@ -156,13 +148,11 @@
.transition()
.duration(1000)
.attr('y', d => y(d[1]))
.attr('height', d => {
const calculatedHeight = y(d[0]) - y(d[1]);
return calculatedHeight < minBarHeight ? minBarHeight : calculatedHeight;
}); };
.attr('height', d => y(d[0]) - y(d[1]));
};
// Function to create an accessible table
const createTable = function(tableId, chartType, labels, deliveredData, failedData, pendingData) {
const createTable = function(tableId, chartType, labels, deliveredData, failedData) {
const table = document.getElementById(tableId);
table.innerHTML = ""; // Clear previous data
@@ -174,7 +164,7 @@
// Create table header
const headerRow = document.createElement('tr');
const headers = ['Day', 'Delivered', 'Failed', 'Pending'];
const headers = ['Day', 'Delivered', 'Failed'];
headers.forEach(headerText => {
const th = document.createElement('th');
th.textContent = headerText;
@@ -197,10 +187,6 @@
cellFailed.textContent = failedData[index];
row.appendChild(cellFailed);
const cellPending = document.createElement('td');
cellPending.textContent = pendingData[index];
row.appendChild(cellPending);
tbody.appendChild(row);
});
@@ -210,19 +196,12 @@
};
const fetchData = function(type) {
var ctx = document.getElementById('weeklyChart');
if (!ctx) {
return;
}
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?timezone=${encodeURIComponent(userTimezone)}`;
var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`;
return fetch(url)
.then(response => {
if (!response.ok) {
@@ -234,7 +213,7 @@
labels = [];
deliveredData = [];
failedData = [];
pendingData = [];
let totalMessages = 0;
for (var dateString in data) {
@@ -245,8 +224,9 @@
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;
}
}
@@ -272,18 +252,17 @@
}
} else {
// If there are messages, create the chart and table
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
}
createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
}
return data;
})
.catch(error => console.error('Error fetching daily stats:', error));
};
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;
@@ -291,67 +270,36 @@
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`;
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';
});
}
// 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
};
document.addEventListener('DOMContentLoaded', function() {
// Initialize activityChart chart and table with service data by default
fetchData(currentType);
const allRows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
allRows.forEach((row, index) => {
row.style.display = (index < 5) ? '' : 'none';
});
fetchData('service');
// 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 && pendingData.length > 0) {
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData);
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) {
createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
}
});

View File

@@ -13,23 +13,16 @@
} else {
$submitButton.data('clicked', 'true');
setTimeout(renableSubmitButton($submitButton), 1500);
if ($submitButton.is('[name="Send"], [name="Schedule"]')) {
$submitButton.prop('disabled', true);
setTimeout(() => {
renableSubmitButton($submitButton);
}, 10000);
} else {
setTimeout(renableSubmitButton($submitButton), 1500);
}
}
};
let renableSubmitButton = $submitButton => () => {
$submitButton.data('clicked', '');
$submitButton.prop('disabled', false);
};
$('form').on('submit', disableSubmitButtons);

View File

@@ -6,17 +6,17 @@
var chartTitle = document.getElementById('chartTitle').textContent;
// Access data attributes from the HTML
var messagesSent = parseInt(chartContainer.getAttribute('data-messages-sent'));
var messagesRemaining = parseInt(chartContainer.getAttribute('data-messages-remaining'));
var totalMessages = messagesSent + messagesRemaining;
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;
// Update the message below the chart
document.getElementById('message').innerText = `${messagesSent.toLocaleString()} sent / ${messagesRemaining.toLocaleString()} remaining`;
document.getElementById('message').innerText = `${sms_sent.toLocaleString()} sent / ${sms_remaining_messages.toLocaleString()} remaining`;
// Calculate minimum width for "Messages Sent" as 1% of the total chart width
var minSentPercentage = (messagesSent === 0) ? 0 : 0.02;
var minSentPercentage = (sms_sent === 0) ? 0 : 0.02;
var minSentValue = totalMessages * minSentPercentage;
var displaySent = Math.max(messagesSent, minSentValue);
var displaySent = Math.max(sms_sent, 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: ${messagesSent.toLocaleString()}`);
.html(`Messages Sent: ${sms_sent.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: ${messagesRemaining.toLocaleString()}`);
.html(`Remaining: ${sms_remaining_messages.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 = messagesSent.toLocaleString(); // Value for Messages Sent
tdMessagesSent.textContent = sms_sent.toLocaleString(); // Value for Messages Sent
var tdRemaining = document.createElement('td');
tdRemaining.textContent = messagesRemaining.toLocaleString(); // Value for Remaining
tdRemaining.textContent = sms_remaining_messages.toLocaleString(); // Value for Remaining
tbodyRow.appendChild(tdMessagesSent);
tbodyRow.appendChild(tdRemaining);

View File

@@ -1,115 +0,0 @@
function showError(input, errorElement, message) {
errorElement.textContent = ""; // Clear existing message
errorElement.style.display = "block";
// Small delay to ensure screen readers pick up the change
setTimeout(() => {
errorElement.textContent = message;
}, 10);
if (input.type !== "radio" && input.type !== "checkbox") {
input.classList.add("usa-input--error");
}
input.setAttribute("aria-describedby", errorElement.id);
}
function hideError(input, errorElement) {
errorElement.style.display = "none";
if (input.type !== "radio" && input.type !== "checkbox") {
input.classList.remove("usa-input--error");
}
input.removeAttribute("aria-describedby");
}
function getFieldLabel(input) {
const label = document.querySelector(`label[for="${input.id}"]`);
return label ? label.textContent.trim() : "This field";
}
// Attach validation logic to forms
function attachValidation() {
const forms = document.querySelectorAll('form[data-force-focus="True"]');
forms.forEach((form) => {
const inputs = form.querySelectorAll("input, textarea, select");
form.addEventListener("submit", function (event) {
let isValid = true;
let firstInvalidInput = null;
const validatedRadioNames = new Set();
inputs.forEach((input) => {
const errorId = input.type === "radio" ? `${input.name}-error` : `${input.id}-error`;
let errorElement = document.getElementById(errorId);
if (!errorElement) {
errorElement = document.createElement("span");
errorElement.id = errorId;
errorElement.classList.add("usa-error-message");
errorElement.setAttribute("aria-live", "polite");
errorElement.style.display = "none";
if (input.type === "radio") {
const group = form.querySelectorAll(`input[name="${input.name}"]`);
const lastRadio = group[group.length - 1];
lastRadio.parentElement.insertAdjacentElement("afterend", errorElement);
} else {
input.insertAdjacentElement("afterend", errorElement);
}
}
if (input.type === "radio") {
if (validatedRadioNames.has(input.name)) return;
validatedRadioNames.add(input.name);
const radioGroup = form.querySelectorAll(`input[name="${input.name}"]`);
const isChecked = Array.from(radioGroup).some(radio => radio.checked);
if (!isChecked) {
showError(input, errorElement, `Error: A selection must be made.`);
isValid = false;
if (!firstInvalidInput) {
firstInvalidInput = input;
}
}
} else if (input.value.trim() === "") {
showError(input, errorElement, `Error: ${getFieldLabel(input)} is required.`);
isValid = false;
if (!firstInvalidInput) {
firstInvalidInput = input;
}
}
});
if (!isValid) {
event.preventDefault();
if (firstInvalidInput) firstInvalidInput.focus();
}
});
inputs.forEach((input) => {
input.addEventListener("input", function () {
const errorId = input.type === "radio" ? `${input.name}-error` : `${input.id}-error`;
const errorElement = document.getElementById(errorId);
if (errorElement && input.value.trim() !== "") {
hideError(input, errorElement);
}
});
if (input.type === "radio") {
input.addEventListener("change", function () {
const errorElement = document.getElementById(`${input.name}-error`);
if (errorElement) {
hideError(input, errorElement);
}
});
}
});
});
}
// Automatically attach validation only in the browser
if (typeof window !== "undefined") {
document.addEventListener("DOMContentLoaded", attachValidation);
}
// ✅ Check if we're in a Node.js environment (for Jest) before using `module.exports`
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
module.exports = { showError, hideError, getFieldLabel, attachValidation };
}

Binary file not shown.

View File

@@ -60,6 +60,17 @@ $failed: color('gray-cool-20');
}
}
.usa-tooltip {
line-height: 1;
.usa-tooltip__body {
width: units(mobile);
font-size: size("body", 2);
height: auto;
white-space: wrap;
line-height: units(1);
}
}
.progress-bar {
width: 300px;
height: 20px;

View File

@@ -49,12 +49,12 @@
}
.sms-message-sender, .sms-message-file-name, .sms-message-scheduler, .sms-message-template, .sms-message-sender {
margin: units(0.5) 0 0;
margin:0.25rem 0 0;
}
.sms-message-recipient {
color: color('gray-cool-90');
margin: units(0.5) 0 units(2);
margin: units(1) 0 units(1);
}
.sms-message-status {

View File

@@ -139,6 +139,11 @@ td.table-empty-message {
font-family: family('sans');
}
.usa-dark-background .pill-item__label {
color: white;
text-decoration: none !important;
}
.pill-item.usa-link {
text-decoration: none !important;
}
@@ -193,6 +198,9 @@ td.table-empty-message {
word-wrap: break-word;
}
// border: 1px solid color('gray-cool-10');
// padding: units(2);
.tick-cross-list-permissions {
margin: units(1) 0;
padding-left: units(2);
@@ -249,11 +257,6 @@ td.table-empty-message {
}
}
.usa-checkbox.template-list-item.template-list-item-with-checkbox.template-list-item-without-ancestors {
display: flex;
flex-direction: column;
}
.usa-checkbox__label-description {
margin: units(2px) 0 units(1) units(4);
}
@@ -285,8 +288,7 @@ td.table-empty-message {
@include u-width('mobile-lg');
margin-top: units(2);
}
input#search-by-name {
margin-top: units(1);
input#search {
width: 100%;
border: 1px solid color('gray-60');
}
@@ -550,24 +552,6 @@ td.table-empty-message {
max-width: 100%;
}
.usa-tooltip {
&__information {
background: color('ink');
margin-top: 4px;
color: white;
border-radius: 50%;
height: units(3);
width: units(3);
border: 1px solid color('ink');
}
.usa-tooltip__body {
min-width: units('card-lg');
max-width: units('mobile');
white-space: normal;
word-wrap: break-word;
}
}
// Tabs
.tabs {
@@ -591,31 +575,19 @@ td.table-empty-message {
.big-number-smallest {
font-size: units(3);
}
.pill-item__label {
color: white;
}
&:not(.pill-item--selected):hover {
background: color('blue-warm-70v');
.pill-item__label {
color: white;
}
}
&.pill-item--selected {
.pill-item__label {
color: color('blue-60v');
}
&:hover {
.pill-item__label {
color: color('blue-60v');
}
}
&.pill-item--selected:hover {
color: color('blue-60v');
}
}
}
}
}
// Etc
.email-brand,
.browse-list {
padding: 0;
@@ -1052,11 +1024,3 @@ nav.nav {
font-size: units(3);
font-weight: bold;
}
.form-control-error {
border: 4px solid #b10e1e
}
.usa-site-alert .usa-alert .usa-alert__body {
max-width: 75rem;
}

View File

@@ -88,6 +88,8 @@ class Config(object):
],
}
FEATURE_ABOUT_PAGE_ENABLED = getenv("FEATURE_ABOUT_PAGE_ENABLED", "false") == "true"
def _s3_credentials_from_env(bucket_prefix):
return {

View File

@@ -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/how-to) to increase response.
Learn how to [personalize messages](/using-notify/guidance) to increase response.
3. ## Check delivery status
[Analyze the delivery](/using-notify/delivery-status) of your messages and download reports

View File

@@ -344,7 +344,7 @@ def nl2br(value):
html="escape",
)
).then(utils_nl2br)
) # nosec
)
return ""

View File

@@ -257,7 +257,7 @@ def govuk_text_input_field_widget(
return Markup(
render_template("components/components/input/template.njk", params=params)
) # nosec
)
class GovukTextInputField(StringField):
@@ -690,9 +690,7 @@ def govuk_checkbox_field_widget(self, field, param_extensions=None, **kwargs):
if param_extensions:
merge_jsonlike(params, param_extensions)
return Markup(
render_template("forms/fields/checkboxes/macro.njk", params=params)
) # nosec
return Markup(render_template("forms/fields/checkboxes/macro.njk", params=params))
def govuk_checkboxes_field_widget(
@@ -706,7 +704,7 @@ def govuk_checkboxes_field_widget(
f' data-field-label="{field_label}">'
f" {checkboxes_string}"
f"</div>"
) # nosec
)
return result
@@ -759,14 +757,12 @@ def govuk_checkboxes_field_widget(
return _wrap_in_collapsible(
self.field_label,
Markup(
render_template("forms/fields/checkboxes/macro.njk", params=params)
), # nosec
Markup(render_template("forms/fields/checkboxes/macro.njk", params=params)),
)
else:
return Markup(
render_template("forms/fields/checkboxes/macro.njk", params=params)
) # nosec
)
def govuk_radios_field_widget(self, field, param_extensions=None, **kwargs):
@@ -809,7 +805,7 @@ def govuk_radios_field_widget(self, field, param_extensions=None, **kwargs):
return Markup(
render_template("components/components/radios/template.njk", params=params)
) # nosec
)
class GovukCheckboxField(BooleanField):

View File

@@ -61,7 +61,7 @@ class ValidEmail:
class NoCommasInPlaceHolders:
def __init__(self, message="You cannot put commas between double parenthesis"):
def __init__(self, message="You cannot put commas between double brackets"):
self.message = message
def __call__(self, form, field):
@@ -108,17 +108,33 @@ class OnlySMSCharacters:
)
if non_sms_characters:
raise ValidationError(
"Please remove the unaccepted character {} in your message, then save again".format(
"You cannot use {} in {}. {} will not show up properly on everyones phones.".format(
formatted_list(
non_sms_characters,
conjunction="and",
conjunction="or",
before_each="",
after_each="",
),
{
"sms": "text messages",
}.get(self._template_type),
("It" if len(non_sms_characters) == 1 else "They"),
)
)
# class NoPlaceholders:
# def __init__(self, message=None):
# self.message = message or (
# 'You cant use ((double brackets)) to personalize this message'
# )
# def __call__(self, form, field):
# if Field(field.data).placeholders:
# raise ValidationError(self.message)
class LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly:
regex = re.compile(r"^[a-zA-Z0-9\s\._']+$")

View File

@@ -1,25 +1,34 @@
import calendar
from datetime import datetime, timedelta
from datetime import datetime
from functools import partial
from itertools import groupby
from zoneinfo import ZoneInfo
from flask import abort, jsonify, render_template, request, session, url_for
from flask import Response, abort, jsonify, render_template, request, session, url_for
from flask_login import current_user
from werkzeug.utils import redirect
from app import (
billing_api_client,
current_service,
job_api_client,
service_api_client,
template_statistics_client,
)
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
from app.main import main
from app.main.views.user_profile import set_timezone
from app.statistics_utils import get_formatted_percentage
from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES, REQUESTED_STATUSES
from app.utils import (
DELIVERED_STATUSES,
FAILURE_STATUSES,
REQUESTED_STATUSES,
service_has_permission,
)
from app.utils.csv import Spreadsheet
from app.utils.pagination import generate_next_dict, generate_previous_dict
from app.utils.time import get_current_financial_year
from app.utils.user import user_has_permissions
from notifications_utils.recipients import format_phone_number_human_readable
@main.route("/services/<uuid:service_id>/dashboard")
@@ -39,111 +48,87 @@ 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
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
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"
]
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)
all_statistics = template_statistics_client.get_template_statistics_for_service(
service_id, limit_days=7
)
template_statistics = aggregate_template_usage(all_statistics)
return render_template(
"views/dashboard/dashboard.html",
jobs=job_lists,
updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id),
jobs=jobs,
service_data_retention_days=service_data_retention_days,
messages_remaining=messages_remaining,
messages_sent=messages_sent,
template_statistics=template_statistics,
most_used_template_count=max(
[row["count"] for row in template_statistics] or [0]
),
sms_sent=sms_sent,
sms_allowance_remaining=sms_allowance_remaining,
)
def job_is_finished(job_dict):
done_statuses = DELIVERED_STATUSES + FAILURE_STATUSES + ["cancelled"]
processed_count = sum(
stat["count"]
for stat in job_dict["statistics"]
if stat["status"] in done_statuses
)
return job_dict["notification_count"] == processed_count
@main.route("/services/<uuid:service_id>/daily-stats.json")
@user_has_permissions()
def get_daily_stats(service_id):
@main.route("/daily_stats.json")
def get_daily_stats():
service_id = session.get("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,
stats = service_api_client.get_service_notification_statistics_by_day(
service_id, start_date=date_range["start_date"], days=date_range["days"]
)
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days)
return jsonify(local_stats)
return jsonify(stats)
def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days):
tz = ZoneInfo(user_timezone)
today_local = datetime.now(tz).date()
start_local = today_local - timedelta(days=days - 1)
# Generate exactly days local dates, each with zeroed stats
days_list = [
(start_local + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(days)
]
aggregator = {
d: {
"sms": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
"email": {"delivered": 0, "failure": 0, "pending": 0, "requested": 0},
}
for d in days_list
}
# Convert each UTC timestamp to local date and iterate
for utc_ts, data in stats_utc.items():
utc_dt = datetime.strptime(utc_ts, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=ZoneInfo("UTC")
)
local_day = utc_dt.astimezone(tz).strftime("%Y-%m-%d")
if local_day in aggregator:
for msg_type in ["sms", "email"]:
for status in ["delivered", "failure", "pending", "requested"]:
aggregator[local_day][msg_type][status] += data[msg_type][status]
return aggregator
@main.route("/services/<uuid:service_id>/daily-stats-by-user.json")
@user_has_permissions()
def get_daily_stats_by_user(service_id):
@main.route("/daily_stats_by_user.json")
def get_daily_stats_by_user():
service_id = session.get("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_user_service_notification_statistics_by_day(
user_id = current_user.id
stats = service_api_client.get_user_service_notification_statistics_by_day(
service_id,
user_id=current_user.id,
user_id,
start_date=date_range["start_date"],
days=days,
days=date_range["days"],
)
return jsonify(stats)
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days)
return jsonify(local_stats)
@main.route("/services/<uuid:service_id>/dashboard.json")
@user_has_permissions("view_activity")
def service_dashboard_updates(service_id):
return jsonify(**get_dashboard_partials(service_id))
@main.route("/services/<uuid:service_id>/template-activity")
@user_has_permissions("view_activity")
def template_history(service_id):
return redirect(url_for("main.template_usage", service_id=service_id), code=301)
@main.route("/services/<uuid:service_id>/template-usage")
@@ -231,6 +216,103 @@ def usage(service_id):
)
@main.route("/services/<uuid:service_id>/monthly")
@user_has_permissions("view_activity")
def monthly(service_id):
year, current_financial_year = requested_and_current_financial_year(request)
return render_template(
"views/dashboard/monthly.html",
months=format_monthly_stats_to_list(
service_api_client.get_monthly_notification_stats(service_id, year)["data"]
),
years=get_tuples_of_financial_years(
partial_url=partial(url_for, ".monthly", service_id=service_id),
start=current_financial_year - 2,
end=current_financial_year,
),
selected_year=year,
)
@main.route("/services/<uuid:service_id>/inbox")
@user_has_permissions("view_activity")
@service_has_permission("inbound_sms")
def inbox(service_id):
return render_template(
"views/dashboard/inbox.html",
partials=get_inbox_partials(service_id),
updates_url=url_for(
".inbox_updates", service_id=service_id, page=request.args.get("page")
),
)
@main.route("/services/<uuid:service_id>/inbox.json")
@user_has_permissions("view_activity")
@service_has_permission("inbound_sms")
def inbox_updates(service_id):
return jsonify(get_inbox_partials(service_id))
@main.route("/services/<uuid:service_id>/inbox.csv")
@user_has_permissions("view_activity")
def inbox_download(service_id):
return Response(
Spreadsheet.from_rows(
[
[
"Phone number",
"Message",
"Received",
]
]
+ [
[
format_phone_number_human_readable(message["user_number"]),
message["content"].lstrip(("=+-@")),
format_datetime_numeric(message["created_at"]),
]
for message in service_api_client.get_inbound_sms(service_id)["data"]
]
).as_csv_data,
mimetype="text/csv",
headers={
"Content-Disposition": 'inline; filename="Received text messages {}.csv"'.format(
format_date_numeric(datetime.utcnow().isoformat())
)
},
)
def get_inbox_partials(service_id):
page = int(request.args.get("page", 1))
inbound_messages_data = service_api_client.get_most_recent_inbound_sms(
service_id, page=page
)
inbound_messages = inbound_messages_data["data"]
if not inbound_messages:
inbound_number = current_service.inbound_number
else:
inbound_number = None
prev_page = None
if page > 1:
prev_page = generate_previous_dict("main.inbox", service_id, page)
next_page = None
if inbound_messages_data["has_next"]:
next_page = generate_next_dict("main.inbox", service_id, page)
return {
"messages": render_template(
"views/dashboard/_inbox_messages.html",
messages=inbound_messages,
inbound_number=inbound_number,
prev_page=prev_page,
next_page=next_page,
)
}
def filter_out_cancelled_stats(template_statistics):
return [s for s in template_statistics if s["status"] != "cancelled"]
@@ -262,6 +344,71 @@ def aggregate_template_usage(template_statistics, sort_key="count"):
return sorted(templates, key=lambda x: x[sort_key], reverse=True)
def aggregate_notifications_stats(template_statistics):
template_statistics = filter_out_cancelled_stats(template_statistics)
notifications = {
template_type: {status: 0 for status in ("requested", "delivered", "failed")}
for template_type in ["sms", "email"]
}
for stat in template_statistics:
notifications[stat["template_type"]]["requested"] += stat["count"]
if stat["status"] in DELIVERED_STATUSES:
notifications[stat["template_type"]]["delivered"] += stat["count"]
elif stat["status"] in FAILURE_STATUSES:
notifications[stat["template_type"]]["failed"] += stat["count"]
return notifications
def get_dashboard_partials(service_id):
all_statistics = template_statistics_client.get_template_statistics_for_service(
service_id, limit_days=7
)
template_statistics = aggregate_template_usage(all_statistics)
stats = aggregate_notifications_stats(all_statistics)
dashboard_totals = (get_dashboard_totals(stats),)
free_sms_allowance = billing_api_client.get_free_sms_fragment_limit_for_year(
current_service.id,
)
# These 2 calls will update the dashboard sms allowance count while in trial mode.
billing_api_client.get_monthly_usage_for_service(
service_id, get_current_financial_year()
)
billing_api_client.create_or_update_free_sms_fragment_limit(
service_id, free_sms_fragment_limit=free_sms_allowance
)
yearly_usage = billing_api_client.get_annual_usage_for_service(
service_id,
get_current_financial_year(),
)
return {
"upcoming": render_template(
"views/dashboard/_upcoming.html",
),
"inbox": render_template(
"views/dashboard/_inbox.html",
),
"totals": render_template(
"views/dashboard/_totals.html",
service_id=service_id,
statistics=dashboard_totals[0],
),
"template-statistics": render_template(
"views/dashboard/template-statistics.html",
template_statistics=template_statistics,
most_used_template_count=max(
[row["count"] for row in template_statistics] or [0]
),
),
"usage": render_template(
"views/dashboard/_usage.html",
**get_annual_usage_breakdown(yearly_usage, free_sms_allowance),
),
}
def get_dashboard_totals(statistics):
for msg_type in statistics.values():

View File

@@ -23,11 +23,10 @@ from app.utils.user import user_is_logged_in
# Hook to check for feature flags
@main.before_request
def check_feature_flags():
# Placeholder for future feature flag checks
# Example:
# if request.path.startswith("/some-feature") and not current_app.config.get("FEATURE_SOME_FEATURE_ENABLED", False):
# abort(404)
pass
if request.path.startswith("/about") and not current_app.config.get(
"FEATURE_ABOUT_PAGE_ENABLED", False
):
abort(404)
@main.route("/test/feature-flags")
@@ -218,11 +217,11 @@ def benchmark_performance():
)
@main.route("/using-notify/how-to")
@main.route("/using-notify/guidance")
@user_is_logged_in
def how_to():
def guidance_index():
return render_template(
"views/how-to/index.html",
"views/guidance/index.html",
navigation_links=using_notify_nav(),
)
@@ -259,37 +258,37 @@ def why_text_messaging():
)
@main.route("/notify-service-ending")
@user_is_logged_in
def notify_service_ending():
@main.route("/join-notify")
def join_notify():
return render_template(
"views/notify-service-ending.html",
"views/join-notify.html",
navigation_links=about_notify_nav(),
)
@main.route("/using-notify/how-to/create-and-send-messages")
@main.route("/using-notify/guidance/create-and-send-messages")
@user_is_logged_in
def create_and_send_messages():
return render_template(
"views/how-to/create-and-send-messages.html",
"views/guidance/create-and-send-messages.html",
navigation_links=using_notify_nav(),
)
@main.route("/using-notify/how-to/edit-and-format-messages")
@main.route("/using-notify/guidance/edit-and-format-messages")
@user_is_logged_in
def edit_and_format_messages():
return render_template(
"views/how-to/edit-and-format-messages.html",
"views/guidance/edit-and-format-messages.html",
navigation_links=using_notify_nav(),
)
@main.route("/using-notify/how-to/send-files-by-email")
@main.route("/using-notify/guidance/send-files-by-email")
@user_is_logged_in
def send_files_by_email():
return render_template(
"views/how-to/send-files-by-email.html",
"views/guidance/send-files-by-email.html",
navigation_links=using_notify_nav(),
)

View File

@@ -23,7 +23,7 @@ def accept_invite(token):
<a href={url_for("main.sign_out")} class="usa-link">Sign out</a>
and click the link again to accept this invite.
"""
) # nosec
)
flash(message=message)
@@ -98,7 +98,7 @@ def accept_org_invite(token):
""".format(
current_user.email_address, url_for("main.sign_out")
)
) # nosec
)
flash(message=message)

View File

@@ -57,6 +57,7 @@ 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,
@@ -351,7 +352,7 @@ def _get_job_counts(job):
Markup(
f"""total<span class="usa-sr-only">
{"text message" if job_type == "sms" else job_type}s</span>"""
), # nosec
),
"",
job.notification_count,
],
@@ -359,7 +360,7 @@ def _get_job_counts(job):
Markup(
f"""pending<span class="usa-sr-only">
{message_count_noun(job.notifications_sending, job_type)}</span>"""
), # nosec
),
"pending",
job.notifications_sending,
],
@@ -367,7 +368,7 @@ def _get_job_counts(job):
Markup(
f"""delivered<span class="usa-sr-only">
{message_count_noun(job.notifications_delivered, job_type)}</span>"""
), # nosec
),
"delivered",
job.notifications_delivered,
],
@@ -375,7 +376,7 @@ def _get_job_counts(job):
Markup(
f"""failed<span class="usa-sr-only">
{message_count_noun(job.notifications_failed, job_type)}</span>"""
), # nosec
),
"failed",
job.notifications_failed,
],
@@ -401,9 +402,7 @@ def get_job_partials(job):
)
if request.referrer is not None:
session["arrived_from_preview_page"] = ("check" in request.referrer) or (
"help=0" in request.referrer
)
session["arrived_from_preview_page"] = "check" in request.referrer
else:
session["arrived_from_preview_page"] = False
@@ -465,4 +464,4 @@ def get_preview_of_content(notification):
notification["personalisation"],
redact_missing_personalisation=True,
).subject
) # nosec
)

View File

@@ -116,70 +116,6 @@ 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

View File

@@ -4,7 +4,6 @@ import uuid
from string import ascii_uppercase
from zipfile import BadZipFile
import bleach
from flask import (
abort,
current_app,
@@ -168,12 +167,10 @@ def send_messages(service_id, template_id):
# just show the first error, as we don't expect the form to have more
# than one, since it only has one field
first_field_errors = list(form.errors.values())[0]
error_message = '<span class="error-message usa-error-message">'
error_message = '<span class="usa-error-message">'
error_message = f"{error_message}{first_field_errors[0]}"
error_message = f"{error_message}</span>"
# use 'nosec' because we applied bleach.clean to strip dangerous tags
error_message = bleach.clean(error_message)
error_message = Markup(error_message) # nosec
error_message = Markup(error_message)
flash(error_message)
column_headings = get_spreadsheet_column_headings_from_template(template)
@@ -927,10 +924,6 @@ def get_template_error_dict(exception):
def preview_notification(service_id, template_id):
recipient = get_recipient()
if not recipient:
current_app.logger.warning(
f"No recipient found for service {service_id}, template {template_id}. Redirecting..."
)
return redirect(
url_for(
".send_one_off",

View File

@@ -62,18 +62,17 @@ def _get_access_token(code): # pragma: no cover
code_param = f"code={code}"
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
headers = {"Authorization": "Bearer %s" % token}
response = requests.post(url, headers=headers, timeout=30)
response = requests.post(url, headers=headers)
response_json = response.json()
id_token = get_id_token(response_json)
nonce = id_token["nonce"]
nonce_key = f"login-nonce-{unquote(nonce)}"
if not os.getenv("NOTIFY_ENVIRONMENT") == "development":
stored_nonce = redis_client.get(nonce_key).decode("utf8")
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"]
@@ -88,7 +87,10 @@ def _get_access_token(code): # pragma: no cover
def _get_user_email_and_uuid(access_token): # pragma: no cover
headers = {"Authorization": "Bearer %s" % access_token}
user_info_url = os.getenv("LOGIN_DOT_GOV_USER_INFO_URL")
user_attributes = requests.get(user_info_url, headers=headers, timeout=30)
user_attributes = requests.get(
user_info_url,
headers=headers,
)
user_email = user_attributes.json()["email"]
user_uuid = user_attributes.json()["sub"]
return user_email, user_uuid
@@ -110,7 +112,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 and not os.getenv("NOTIFY_ENVIRONMENT") == "development":
if not verify_path:
state_key = f"login-state-{unquote(state)}"
stored_state = unquote(redis_client.get(state_key).decode("utf8"))
if state != stored_state:

View File

@@ -17,7 +17,7 @@ def _sign_out_at_login_dot_gov():
url = f"{base_url}{client_id}&{post_logout_redirect_uri}"
current_app.logger.info(f"url={url}")
response = requests.post(url, timeout=30)
response = requests.post(url)
# response = requests.post(url)
current_app.logger.info(f"login.gov response: {response.text}")

View File

@@ -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": "How to", "link": "main.how_to"},
{"name": "Delivery Status", "link": "main.message_status"},
{"name": "Guidance", "link": "main.guidance_index"},
]
return nav_items
@@ -56,6 +56,10 @@ def about_notify_nav():
},
],
},
{
"name": "Join Notify",
"link": "main.join_notify",
},
{
"name": "Contact us",
"link": "main.contact",

View File

@@ -680,44 +680,42 @@ def count_content_length(service_id, template_type):
)
def _is_latin1(s):
return bool(s.encode(encoding="latin-1", errors="strict"))
def _get_content_count_error_and_message_for_template(template):
url = "https://en.wikipedia.org/wiki/ISO/IEC_8859-1"
if template.template_type == "sms":
s1 = (
"<html><body>Looks like your template may have one of these characters "
"• ™ ∞ ≤ or ≥ or emoji, which won't save."
)
s2 = "<br>Please remove any unaccepted characters or emojis and try again.</body></html>"
s1 = f"<html><body>Use of characters outside the <a href='{url}'>IEC_8859-1</a> character set may increase "
s2 = "the message fragment count, resulting in additional charges, and these IEC_8859-1 "
s3 = "characters may not display properly on some older mobile devices.</body></html>"
# Define characters that should be blocked
BLOCKED_CHARACTERS = {"", "", "", "", ""}
warning = ""
try:
_is_latin1(template.content)
except UnicodeEncodeError:
warning = f"{s1}{s2}{s3}"
def contains_blocked_characters(content):
"""Check if the content contains explicitly blocked characters."""
return any(c in BLOCKED_CHARACTERS for c in content)
# Check for blocked characters
if contains_blocked_characters(template.content):
warning = f"{s1}{s2}"
return False, Markup(warning) # nosec
# If message is too long, return the length error
if template.is_message_too_long():
return True, (
f"You have "
f"{character_count(template.content_count_without_prefix - SMS_CHAR_COUNT_LIMIT)} "
f"too many"
)
# Show charge message as usual if no warning
if template.placeholders:
return False, Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)} "
f"(not including personalization)."
) # nosec
return False, Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)}."
) # nosec
return False, (
Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)} "
f"(not including personalization). {warning}"
)
)
return False, (
# Markup marks html contents safe so that they render properly. Don't use it if there is user input.
Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)}. {warning} "
)
)
@main.route(

View File

@@ -54,10 +54,12 @@ class HeaderNavigation(Navigation):
"pricing",
"trial_mode_new",
"message_status",
"how_to",
"guidance_index",
},
"accounts-or-dashboard": {
"conversation",
"inbox",
"monthly",
"service_dashboard",
"template_usage",
"view_notification",
@@ -159,6 +161,8 @@ class MainNavigation(Navigation):
},
"dashboard": {
"conversation",
"inbox",
"monthly",
"service_dashboard",
"template_usage",
"view_notification",

View File

@@ -6,36 +6,17 @@ from app.notify_client import NotifyAdminAPIClient
class BillingAPIClient(NotifyAdminAPIClient):
def get_monthly_usage_for_service(self, service_id, year):
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(
return 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):
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(
return 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:
@@ -67,28 +48,13 @@ class BillingAPIClient(NotifyAdminAPIClient):
)
def get_data_for_billing_report(self, start_date, end_date):
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(
return 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(

View File

@@ -1,6 +1,3 @@
import json
from app.extensions import redis_client
from app.notify_client import NotifyAdminAPIClient, _attach_current_user
@@ -44,7 +41,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:
@@ -99,20 +96,9 @@ class NotificationApiClient(NotifyAdminAPIClient):
)
def get_notification_count_for_job_id(self, *, service_id, job_id):
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(
return self.get(
url="/service/{}/job/{}/notification_count".format(service_id, job_id)
)
redis_client.set(
f"notification-count-for-job-id-{service_id}-{job_id}",
json.dumps(result["count"]),
ex=30,
)
return result["count"]
)["count"]
notification_api_client = NotificationApiClient()

View File

@@ -537,11 +537,6 @@ 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()

View File

@@ -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)

View File

@@ -28,17 +28,8 @@ def get_csv_upload(service_id, upload_id):
return get_s3_object(*get_csv_location(service_id, upload_id))
def remove_blank_lines(filedata):
# sometimes people upload files with hundreds of blank lines at the end
data = filedata["data"]
cleaned_data = "\n".join(line for line in data.splitlines() if line.strip())
filedata["data"] = cleaned_data
return filedata
def s3upload(service_id, filedata):
filedata = remove_blank_lines(filedata)
upload_id = str(uuid.uuid4())
bucket_name, file_location, access_key, secret_key, region = get_csv_location(
service_id, upload_id

View File

@@ -14,8 +14,10 @@
<script nonce="{{ csp_nonce() }}">document.body.className = ((document.body.className) ? document.body.className + ' js-enabled' : 'js-enabled');</script>
{% block bodyStart %}
{% block extra_javascripts_before_body %}
<noscript><iframe sandbox src="https://www.googletagmanager.com/ns.html?id=GTM-WX5NGWF"
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WX5NGWF"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
{% endblock %}
{% endblock %}
@@ -29,30 +31,6 @@
{% block header %}
{% include 'components/usa_banner.html' %}
{% if current_user.is_authenticated or current_service or current_user.platform_admin %}
<section class="usa-site-alert usa-site-alert--info" aria-label="Site alert,,,,">
<div class="usa-alert">
<div class="usa-alert__body">
<p class="usa-alert__heading text-bold">Notify.gov Service Ending</p>
<p class="usa-alert__text">
GSA will no longer offer the Notify.gov service after June 8th, 2025. Visit
<a class="usa-link" href="/notify-service-ending">Notify.gov Service Ending</a> for more information.
</p>
</div>
</div>
</section>
{% else %}
<section class="usa-site-alert usa-site-alert--emergency usa-site-alert--no-heading" aria-label="Site alert,,,,">
<div class="usa-alert">
<div class="usa-alert__body">
<p class="usa-alert__heading text-bold">Notify.gov Service Ending</p>
<p class="usa-alert__text">
Notify.gov is no longer accepting new partners.
</p>
</div>
</div>
</section>
{% endif %}
{% include 'components/header.html' %}
{% endblock %}
@@ -118,10 +96,10 @@
{% block footer %}
{% if current_service and current_service.research_mode %}
{% set meta_suffix = 'Built by the <a href="https://tts.gsa.gov/" class="usa-link">Technology Transformation Services</a><span id="research-mode" class="research-mode">research mode</span>' %}
{% set meta_suffix = 'Built by the <a href="https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services/tts-solutions" class="usa-link">Technology Transformation Services</a><span id="research-mode" class="research-mode">research mode</span>' %}
{% else %}
{% set commit_hash = ", Latest version: " + config['COMMIT_HASH'] %}
{% set long_link = '<a href="https://tts.gsa.gov/" class="usa-link">Technology Transformation Services</a>' %}
{% set long_link = '<a href="https://www.gsa.gov/about-us/organization/federal-acquisition-service/technology-transformation-services/tts-solutions" class="usa-link">Technology Transformation Services</a>' %}
{% set meta_suffix = "Built by the " + long_link + commit_hash %}
{% endif %}
@@ -167,8 +145,10 @@
{% block bodyEnd %}
{% block extra_javascripts %}
{% endblock %}
<!--[if gt IE 8]><!-->
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
<script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script>
<!--<![endif]-->
{% endblock %}
</body>
</html>

View File

@@ -16,7 +16,7 @@
{#- Define common attributes we can use for both button and input types #}
{%- set buttonAttributes %}{% if params.name %} name="{{ params.name | trim }}"{% endif %} type="{{ params.type if params.type else 'submit' }}"{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick %} data-prevent-double-click="true"{% endif %}{% endset %}
{%- set buttonAttributes %}{% if params.name %} name="{{ params.name }}"{% endif %} type="{{ params.type if params.type else 'submit' }}"{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick %} data-prevent-double-click="true"{% endif %}{% endset %}
{#- Actually create a button... or a link! #}

View File

@@ -12,7 +12,7 @@
classes: params.label.classes,
isPageHeading: params.label.isPageHeading,
attributes: params.label.attributes,
for: params.text
for: params.id
}) | indent(2) | trim }}
{% if params.hint %}
{% set hintId = params.id + '-hint' %}
@@ -26,7 +26,7 @@
}) | indent(2) | trim }}
{% endif %}
{% if params.errorMessage %}
{% set errorId = params.label.text + '-error' %}
{% set errorId = params.id + '-error' %}
{% set describedBy = describedBy + ' ' + errorId if describedBy else errorId %}
{{ usaErrorMessage({
id: errorId,
@@ -34,19 +34,13 @@
attributes: params.errorMessage.attributes,
html: params.errorMessage.html,
text: params.errorMessage.text,
visuallyHiddenText: params.errorMessage.visuallyHiddenText,
visuallyHiddenText: params.errorMessage.visuallyHiddenText
}) | indent(2) | trim }}
{% endif %}
<input
class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}"
id="{{ params.label.text | default('unknown') | slugify }}"
name="{{ params.name }}"
type="{{ params.type | default('text') }}"
{%- if params.value %} value="{{ params.value }}"{% endif %}
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete }}"{% endif %}
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
{%- if params.required %} required{% endif %}
/>
<input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}"
{%- if params.value %} value="{{ params.value}}"{% endif %}
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}>
</div>

View File

@@ -20,7 +20,7 @@
</span>
{% endif %}
{% if field.errors and show_errors %}
<span class="error-message usa-error-message">
<span class="error-message">
{{ field.errors[0] }}
</span>
{% endif %}
@@ -29,7 +29,7 @@
'class': 'file-upload-field',
'accept': allowed_file_extensions|format_list_items('.{item}')|join(',')|e
}) }}
<label class="file-upload-button usa-button" for="{{ field.name }}">
<label class="file-upload-button" for="{{ field.name }}">
{{ button_text }}
</label>
{% if alternate_link and alternate_link_text %}

View File

@@ -5,8 +5,7 @@
class=None,
id=None,
module=None,
data_kwargs={},
data_force_focus=False
data_kwargs={}
) %}
<form
method="{{ method }}"
@@ -20,7 +19,6 @@
data-{{ key }}="{{ val }}"
{% endif %}
{% endfor %}
{% if data_force_focus %}data-force-focus="{{ data_force_focus }}"{% endif %}
novalidate
>
{{ caller() }}

View File

@@ -1,6 +1,7 @@
{% set is_about_page = request.path.startswith('/about') %}
{% set is_join_notify_page = request.path.startswith('/join-notify') %}
{% set is_contact_page = request.path.startswith('/contact') %}
{% set is_information_section = is_about_page or is_contact_page %}
{% set is_information_section = is_about_page or is_join_notify_page or is_contact_page %}
{% if current_user.is_authenticated %}
{% set navigation = [
@@ -33,6 +34,7 @@
{% else %}
{% set navigation = [
{"href": url_for('main.about_notify'), "text": "About Notify", "active": is_about_page},
{"href": url_for('main.join_notify'), "text": "Join Notify", "active": is_join_notify_page},
{"href": url_for('main.contact'), "text": "Contact us", "active": is_contact_page}
] %}
{% endif %}

View File

@@ -32,7 +32,7 @@
<legend class="form-label {% if bold_legend %}bold{% endif %}">
{{ field.label.text }}
{% if field.errors %}
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{{ field.errors[0] }}
</span>
{% endif %}

View File

@@ -52,7 +52,7 @@
</span>
{% endif %}
{% if field.errors %}
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{{ field.errors[0] }}
</span>
{% endif %}

View File

@@ -16,9 +16,19 @@
placeholder=''
) %}
<div
class="usa-form-group{% if field.errors %} usa-form-group--error{% endif %} {{ extra_form_group_classes }}"
class="form-group{% if field.errors %} form-group-error{% endif %} {{ extra_form_group_classes }}"
data-module="{% if autofocus %}autofocus{% elif colour_preview %}colour-preview{% endif %}"
>
{% if field.errors %}
<div class="usa-alert usa-alert--error edit-textbox-error-mt" role="alert">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">Error message</h4>
<p class="usa-alert__text" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</p>
</div>
</div>
{% endif %}
<label class="usa-label" for="{{ field.name }}">
{% if label %}
{{ label }}
@@ -31,12 +41,6 @@
{{ hint }}
</div>
{% endif %}
{% if field.errors %}
<span id="{{ field.name}}-error" class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
<span class="usa-sr-only">Error:</span>
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
</span>
{% endif %}
{%
if highlight_placeholders or autosize
%}
@@ -55,8 +59,6 @@
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 %}

View File

@@ -21,11 +21,11 @@
<div class="ajax-block-container">
<p class='bottom-gutter'>
{% if not job.finished_processing %}
{% if job.still_processing or arrived_from_preview_page_url %}
{% if job.scheduled_for %}
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">Your {{ 'message has' if job.notification_count == 1 else 'messages have' }} been scheduled</h2>
<h2 class="usa-alert__heading">Your text has been scheduled</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }} was scheduled on {{ job.scheduled_for|format_datetime_normal }} by {{ job.created_by.name }}
</p>
@@ -33,46 +33,18 @@
</div>
{{display_message_status}}
{% else %}
{% if job.processing_started %}
<div class="usa-alert usa-alert--success">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">
Your {{ 'message is' if job.notification_count == 1 else 'messages are' }} sending
</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }}
has been sending since {{job.processing_started| format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
<div class="usa-alert usa-alert--success">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">Your text has been sent</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }} was sent on {% if job.processing_started %}
{{ job.processing_started|format_datetime_table }} {% else %}
{{ job.created_at|format_datetime_table }} {% endif %} by {{ job.created_by.name }}
</p>
</div>
{% else %}
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">
Your {{ 'message is' if job.notification_count == 1 else 'messages are' }} pending
</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }}
has been pending since {{job.created_at|format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
</div>
{% endif %}
</div>
{{display_message_status}}
{% endif %}
{% elif arrived_from_preview_page_url %}
<div class="usa-alert usa-alert--success">
<div class="usa-alert__body">
<h2 class="usa-alert__heading">
Your {{ 'message has' if job.notification_count == 1 else 'messages have' }} been sent
</h2>
<p class="usa-alert__text">
{{ job.template_name }} - {{ current_service.name }}
was sent on {{job.processing_started|format_datetime_normal}} by {{ job.created_by.name }}
</p>
</div>
</div>
{{display_message_status}}
{% endif %}
</p>
{% if job.status == 'sending limits exceeded'%}

View File

@@ -5,6 +5,10 @@
</h3>
<div id="message-length" class="usa-accordion__content usa-prose">
<p class="usa-body">
Each message sent to a recipient counts as one text. Keep track on your Dashboard of how many messages have sent and are remaining.
If your message is long then it will
cost more.
</p>
<p class="usa-body">
See <a class="usa-link" href="{{ url_for('.pricing') }}">pricing</a> for details.
</p>
</div>

View File

@@ -7,7 +7,7 @@
</h3>
<div id="m-a2" class="usa-accordion__content usa-prose">
<p class="usa-body">
Use double parenthesis and ?? to define optional content.
Use double brackets and ?? to define optional content.
</p>
<p class="bottom-gutter-1-3">
For example if you only want to show something to people who are under

View File

@@ -7,10 +7,11 @@
</h3>
<div id="personalization" class="usa-accordion__content usa-prose">
<p class="bottom-gutter-1-3">
Use double parenthesis to personalize your message:
Use double brackets to personalize your message:
</p>
{{ usaInsetText({
"text": "Hello ((first name)), your reference is ((ref number))",
"classes": ""})
}}
</div>

View File

@@ -4,7 +4,7 @@
Send a document by email
</h2>
<p class="usa-body">
Use double parenthesis to add a placeholder field to your template. This will contain a secure link to download the document.
Use double brackets to add a placeholder field to your template. This will contain a secure link to download the document.
</p>
{{ usaInsetText({
"text": "Download your document at: ((link_to_file))",

View File

@@ -16,10 +16,7 @@
<li>More effectively deliver program outcomes</li>
<li>Save administrative costs</li>
<li>Implement <a class="use-link usa-link--external"
href="https://digital.gov/resources/delivering-digital-first-public-experience/"
rel="noopener noreferrer"
target="_blank"
aria-label="21st Century IDEA (opens in a new tab)">21st Century
href="https://digital.gov/resources/delivering-digital-first-public-experience/" target="_blank">21st Century
IDEA</a> and other directives</li>
</ul>
<p>Notify.gov is an easy-to-use, web-based platform. It requires no technical expertise or system integration — users
@@ -69,6 +66,7 @@
</li>
{% endfor %}
</ul>
<p><a href="/join-notify">See if Notify is right for you</a></p>
<p>Notify.gov is a product of the <a href="/studio">Public Benefits Studio</a>, a product accelerator inside
the federal government. </p>
</section>

View File

@@ -22,26 +22,16 @@
</ul>
<p>
Notify.gov operates under a full three-year <a class="use-link usa-link--external"
href="https://digital.gov/resources/an-introduction-to-ato/"
target="_blank"
rel="noopener noreferrer"
aria-label="Authority-to-Operate (ATO) (opens in a new tab)">Authority-to-Operate (ATO)</a>. This
href="https://digital.gov/resources/an-introduction-to-ato/" target="_blank">Authority-to-Operate (ATO)</a>. This
federal security authorization process leverages security
controls provided by National Institute of Standards and Technology (NIST).
</p>
<p>
Our infrastructure runs on <a class="use-link usa-link--external"
href="https://www.cloud.gov/"
target="_blank"
rel="noopener noreferrer"
aria-label="cloud dot gov (opens in a new tab)">cloud.gov</a> and utilizes several
Our infrastructure runs on <a class="use-link usa-link--external" href="https://www.cloud.gov/"
target="_blank">cloud.gov</a> and utilizes several
services through Amazon Web
Services (AWS), including <a class="use-link usa-link--external"
href="https://aws.amazon.com/sns/"
rel="noopener noreferrer"
target="_blank"
aria-label="AWS SNS (opens in a new tab)">
Services (AWS), including <a class="use-link usa-link--external" href="https://aws.amazon.com/sns/" target="_blank">
AWS SNS </a> for sending SMS
messages.
</p>
@@ -63,22 +53,15 @@
</p>
<h2>Multi-Factor Authentication</h2>
<p>
Notify.gov uses <a class="use-link usa-link--external"
href="https://login.gov/what-is-login/"
rel="noopener noreferrer"
target="_blank"
aria-label="Login dot gov (opens in a new tab)">Login.gov</a> for enhanced security.
Notify.gov uses <a class="use-link usa-link--external" href="https://login.gov/what-is-login/"
target="_blank">Login.gov</a> for enhanced security.
Login.gov is an extra layer of security created by the government that uses multi-factor authentication and stronger
passwords to protect your account.
</p>
<p>
To access Notify.gov, users will use a Login.gov account associated with their agency (.gov) email with one of the
<a class="use-link usa-link--external"
href="https://login.gov/help/get-started/authentication-methods/"
rel="noopener noreferrer"
target="_blank"
aria-label="login dot gov multi-factor authentication methods (opens in a new tab)"
>multi-factor authentication
<a class="use-link usa-link--external" href="https://login.gov/help/get-started/authentication-methods/"
target="_blank">multi-factor authentication
methods</a> offered through Login.gov.
</p>
</section>

View File

@@ -16,9 +16,7 @@
<p>
Confusing or <a class="use-link usa-link--external"
href="https://kffhealthnews.org/news/tougher-returned-mail-policies-add-to-medicaid-enrollment-drop/"
target="_blank"
rel="noopener noreferrer"
aria-label="unreceived notifications (opens in a new tab)">unreceived notifications</a> are one of the largest barriers to people getting and keeping
target="_blank">unreceived notifications</a> are one of the largest barriers to people getting and keeping
benefits. The typical ways the government communicates with people often fall short. Low income households are more
likely to experience housing instability, which means paper mail, already slow, can easily be missed.
</p>
@@ -26,9 +24,7 @@
<p>
<a class="use-link usa-link--external"
href="https://www.pewresearch.org/internet/fact-sheet/mobile/?tabItem=64e32376-5a21-4b1d-8f8b-5f92406db984"
target="_blank"
rel="noopener noreferrer"
aria-label="Pew Research shows that nearly all adults in the US have a cell phone (opens in a new tab)">Pew Research shows that nearly all adults in the US have a cell phone.</a> Reliance on smartphones
target="_blank">Pew Research shows that nearly all adults in the US have a cell phone.</a> Reliance on smartphones
for online access is especially common among Americans with lower household incomes and those with lower levels of
formal education. Of those earning less than $30,000 a year, 28% say their mobile phone is the sole method to
digitally connect.
@@ -44,7 +40,8 @@
</p>
<p>
Texting not only helps programs reach people using a nearly-universal communication method, it is a cost effective
way to do so.
way to do so. With Notify.gov <a href="/join-notify">you can get started for free</a>, allowing you to try out
texting to complement your existing communications and outreach strategies.
</p>
<h2 id="what-texting-is-best-for">What texting is best for</h2>
<p>

View File

@@ -14,7 +14,7 @@
{{ page_header('About your service') }}
{% call form_wrapper(data_force_focus=True) %}
{% call form_wrapper() %}
{{ form.name(param_extensions={"hint": {"text": "You can change this later"}}) }}

View File

@@ -102,7 +102,7 @@ Error
<div class="usa-alert usa-alert--error" role="alert">
<div class="usa-alert__body">
<h1 class="usa-alert__heading banner-title" data-module="track-error" data-error-type="Missing placeholder columns"
data-error-label="{{ upload_id }}">Your column names need to match the double parenthesis in your template</h1>
data-error-label="{{ upload_id }}">Your column names need to match the double brackets in your template</h1>
<p class="usa-alert__text">
Your file is missing {{ recipients.missing_column_headers | formatted_list(
conjunction='and',

View File

@@ -101,9 +101,6 @@
{% set button_text %}
{{ "Schedule" if scheduled_for else 'Send'}}
{% endset %}
{{ usaButton({
"text": button_text,
"name": button_text
}) }}
{{ usaButton({ "text": button_text }) }}
</form>
{% endblock %}

View File

@@ -7,10 +7,19 @@
{% block content_column_content %}
<section class="usa-prose">
<h1>{{page_title}}</h1>
<p>For any questions, contact us at <a href="mailto:tts-notify@gsa.gov"
<p>Is your organization interested in using Notify.gov? Find more information at <a href="/join-notify">Join
Notify</a> or contact us at <a href="mailto:tts-notify@gsa.gov"
aria-label="contact us at tts-notify@gsa.gov">tts-notify@gsa.gov</a>.</p>
<p>If you are a current Notify.gov partner and have technical issues or questions, we are available
at <a href="mailto:notify-support@gsa.gov"
aria-label="Email Notify for technical questions at notify-support@gsa.gov">notify-support@gsa.gov</a></p>
<p>You can expect a response within one business day.</p>
<div class="usa-summary-box maxw-tablet __web-inspector-hide-shortcut__" role="region"
aria-label="For partnership inquiries">
<div class="usa-summary-box__body">
<div class="usa-summary-box__text">
<p class="margin-0">If you are a current Notify.gov partner and have technical issues or questions, we are available at <a
href="mailto:notify-support@gsa.gov"
aria-label="Email Notify for technical questions at notify-support@gsa.gov">notify-support@gsa.gov</a></p>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block backLink %}
{{ usaBackLink({ "href": url_for("main.service_dashboard", service_id=current_service.id) }) }}
{{ usaBackLink({ "href": url_for("main.inbox", service_id=current_service.id) }) }}
{% endblock %}
{% block maincolumn_content %}

View File

@@ -0,0 +1,17 @@
<div class="ajax-block">
{% if current_service.inbound_sms_summary != None %}
<a id="total-received" class="usa-link banner-dashboard" class="banner-dashboard" href="{{ url_for('.inbox', service_id=current_service.id) }}">
<span class="banner-dashboard-count">
{{ current_service.inbound_sms_summary.count|format_thousands }}
</span>
<span class="banner-dashboard-count-label">
{{ current_service.inbound_sms_summary.count|message_count_label('sms', suffix='received') }}
</span>
{% if current_service.inbound_sms_summary.most_recent %}
<span class="banner-dashboard-meta">
latest message {{ current_service.inbound_sms_summary.most_recent | format_delta }}
</span>
{% endif %}
</a>
{% endif %}
</div>

View File

@@ -0,0 +1,38 @@
{% from "components/table.html" import list_table, field, hidden_field_heading, right_aligned_field_heading, row_heading %}
{% from "components/previous-next-navigation.html" import previous_next_navigation %}
<div class="ajax-block-container">
{% if messages %}
<p class="bottom-gutter-2-3 top-gutter-1-2">
<a href="{{ url_for('.inbox_download', service_id=current_service.id) }}" download class="usa-link bold">Download these messages</a>
</p>
{% endif %}
{% call(item, row_number) list_table(
messages,
caption="Inbox",
caption_visible=False,
empty_message='When users text your services phone number ({}) youll see the messages here'.format(inbound_number),
field_headings=[
'From',
'First two lines of message'
],
field_headings_visible=False
) %}
{% call field() %}
<a
class="usa-link file-list-filename"
href="{{ url_for('.conversation', service_id=current_service.id, notification_id=item.id) }}#n{{ item.id }}"
>
{{ item.user_number | format_phone_number_human_readable }}
</a>
<span class="file-list-hint">{{ item.content }}</span>
{% endcall %}
{% call field(align='right') %}
<span class="align-with-message-body">
{{ item.created_at | format_delta }}
</span>
{% endcall %}
{% endcall %}
{{ previous_next_navigation(prev_page, next_page) }}
</div>

View File

@@ -0,0 +1,25 @@
<div class="ajax-block-container">
<div class="grid-row grid-gap">
<div id="total-sms" class="grid-col-12 margin-top-2">
<span class="big-number-with-status display-block margin-bottom-2">
<p>
<span class="big-number-smaller">
<span class="big-number-number">
{% if statistics['sms']['requested'] is number %}
{{ "{:,}".format(statistics['sms']['requested']) }}
{% else %}
{{ statistics['sms']['requested'] }}
{% endif %}
</span>
<span class="big-number-label">{{ statistics['sms']['requested']|message_count_label('sms', suffix='sent') }} in the last seven days</span>
</span>
</p>
<a class="usa-button usa-button--outline" href="{{ url_for('.view_notifications', service_id=service_id, message_type='sms', status='sending,delivered,failed') }}">
Details
</a>
{# Removing the failures area for now, as the user can click on the above link to see all the details.
In the future state of the dashboard, the all statuses will be more apparent with data visualizations #}
</span>
</div>
</div>
</div>

View File

@@ -1,74 +0,0 @@
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2>
<div id="activityChartContainer">
<form class="usa-form">
<label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options">
<option value disabled>- Select -</option>
<option value="service" selected>{{ current_service.name }}</option>
<option value="individual">{{ current_user.name }}</option>
</select>
</form>
<div id="activityChart">
<div class="chart-header">
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
<div class="chart-legend" role="region" aria-label="Legend"></div>
</div>
<div class="chart-container" id="weeklyChart"></div>
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
</div>
</div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
<div class="table-container" id="activityContainer" data-currentUserName="{{ current_user.name }}" data-currentServiceId="{{current_service.id}}">
<div id="tableActivity" class="table-overflow-x-auto">
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
<table class="usa-table job-table" id="activity-table">
<caption class="usa-sr-only">Table showing the sent jobs for {{current_service.name}}</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" class="table-field-heading-first" id="jobId">Job ID#</th>
<th data-sortable scope="col" class="table-field-heading" scope="col">Template</th>
<th data-sortable scope="col" class="table-field-heading">Job status</th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading sender-column">Sender
</th>
<th data-sortable scope="col" class="table-field-heading"># of Recipients</th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs %}
<tr id="{{ job.id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ url_for('.view_job', service_id=current_service.id, job_id=job.id )}}">
{{ job.id[:8] if job.id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">
{% if not job.finished_processing %}
{% if job.scheduled_for%}
Scheduled for {{ job.scheduled_for|format_datetime_table }}
{% elif job.processing_started %}
Sending since {{ job.processing_started|format_datetime_table }}
{% else %}
Pending since {{ job.created_at|format_datetime_table }}
{% endif %}
{% else %}
Sent on {{ job.processing_started|format_datetime_table }}
{% endif %}
</td>
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are
kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>

View File

@@ -1,52 +1,145 @@
{% extends "withnav_template.html" %}
{% from "components/table.html" import list_table, field, text_field, link_field, right_aligned_field_heading,
hidden_field_heading, row_heading, notification_status_field, notification_carrier_field,
notification_carrier_message_field %}
{% from "components/table.html" import list_table, field, text_field, link_field, right_aligned_field_heading, hidden_field_heading, row_heading, notification_status_field, notification_carrier_field, notification_carrier_message_field %}
{% from "components/ajax-block.html" import ajax_block %}
{% block service_page_title %}
Dashboard
Dashboard
{% endblock %}
{% block maincolumn_content %}
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
<div class="dashboard margin-top-0 margin-bottom-2">
<div class="dashboard margin-top-0 margin-bottom-2">
<h1 class="usa-sr-only">Dashboard</h1>
{% if current_user.has_permissions('manage_templates') and not current_service.all_templates %}
{% include 'views/dashboard/write-first-messages.html' %}
{% include 'views/dashboard/write-first-messages.html' %}
{% endif %}
{% include 'views/dashboard/_upcoming.html' %}
{{ ajax_block(partials, updates_url, 'upcoming') }}
<h2 class="font-body-2xl line-height-sans-2 margin-top-0">{{ current_service.name }} Dashboard</h2>
<div id="totalMessageChartContainer" data-messages-sent="{{ messages_sent }}" data-messages-remaining="{{ messages_remaining }}">
<div class="grid-row flex-align-center">
<h2 id="chartTitle" class="margin-right-1">Total messages</h2>
<button
type="button"
class="usa-tooltip usa-tooltip__information margin-right-0"
data-position="top"
title="Total messages track the sum of messages for the service: pending, failed, or delivered"
>
<span class="usa-sr-only">More information</span>
i
</button>
</div>
<svg id="totalMessageChart"></svg>
<div id="message"></div>
{{ ajax_block(partials, updates_url, 'inbox') }}
<div id="totalMessageChartContainer" data-sms-sent="{{ sms_sent }}" data-sms-allowance-remaining="{{ sms_allowance_remaining }}">
<h2 id="chartTitle">Total messages</h2>
<svg id="totalMessageChart"></svg>
<div id="message"></div>
</div>
<div id="totalMessageTable" class="margin-0"></div>
{% include 'views/dashboard/activity-table.html' %}
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Recent activity</h2>
<div id="activityChartContainer">
<form class="usa-form">
<label class="usa-label" for="options">Account</label>
<select class="usa-select margin-bottom-2" name="options" id="options">
<option value disabled>- Select -</option>
<option value="service" selected>{{ current_service.name }}</option>
<option value="individual">{{ current_user.name }}</option>
</select>
</form>
<div id="activityChart">
<div class="chart-header">
<div class="chart-subtitle">{{ current_service.name }} - last 7 days</div>
<div class="chart-legend" role="region" aria-label="Legend"></div>
</div>
<div class="chart-container" id="weeklyChart"></div>
<table id="weeklyTable" class="usa-sr-only usa-table"></table>
</div>
</div>
<div id="aria-live-account" class="usa-sr-only" aria-live="polite"></div>
{% if current_user.has_permissions('manage_service') %}{% endif %}
{% include 'views/dashboard/most-used-templates.html' %}
<div class="table-container">
<div id="table1" class="table-overflow-x-auto hidden">
<h2 class="margin-top-4 margin-bottom-1">My activity</h2>
<table class="usa-table job-table">
<caption class="usa-sr-only">Table showing the sent jobs for {{current_user.name}}</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
<th data-sortable scope="col" class="table-field-heading"><span>Template</span></th>
<th data-sortable scope="col" class="table-field-heading"><span>Job status</span></th>
<th data-sortable scope="col" class="table-field-heading"><span># of Recipients</span></th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs[:5] %}
{% if job.created_by.name == current_user.name %}
{% set notification = job.notifications[0] %}
<tr id="{{ job.job_id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ job.view_job_link }}">
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">Sent on
{{ (job.processing_finished if job.processing_finished else job.processing_started
if job.processing_started else job.created_at)|format_datetime_table }}
</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div id="table2" class="table-overflow-x-auto visible">
<h2 class="margin-top-4 margin-bottom-1">Service activity</h2>
<table class="usa-table job-table">
<caption class="usa-sr-only">Table showing the sent jobs for this service</caption>
<thead class="table-field-headings">
<tr>
<th scope="col" role="columnheader" class="table-field-heading-first" id="jobId"><span>Job ID#</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Template</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Job status</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span>Sender</span></th>
<th data-sortable scope="col" role="columnheader" class="table-field-heading"><span># of Recipients</span></th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for job in jobs[:5] %}
{% set notification = job.notifications[0] %}
<tr id="{{ job.job_id }}">
<td class="table-field jobid" role="rowheader">
<a class="usa-link" href="{{ job.view_job_link }}">
{{ job.job_id[:8] if job.job_id else 'Manually entered number' }}
</a>
</td>
<td class="table-field template">{{ job.template_name }}</td>
<td class="table-field time-sent">Sent on
{{ (job.processing_finished if job.processing_finished else job.processing_started
if job.processing_started else job.created_at)|format_datetime_table }}
</td>
<td class="table-field sender">{{ job.created_by.name }}</td>
<td class="table-field count-of-recipients">{{ job.notification_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr class="table-row">
<td class="table-empty-message" colspan="10">No batched job messages found &thinsp;(messages are kept for {{ service_data_retention_days }} days).</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{{ ajax_block(partials, updates_url, 'template-statistics') }}
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "withnav_template.html" %}
{% from "components/ajax-block.html" import ajax_block %}
{% from "components/page-header.html" import page_header %}
{% from "components/components/back-link/macro.njk" import usaBackLink %}
{% block service_page_title %}
Received text messages
{% endblock %}
{% block backLink %}
{{ usaBackLink({ "href": url_for('main.service_dashboard', service_id=current_service.id) }) }}
{% endblock %}
{% block maincolumn_content %}
{{ page_header('Received text messages') }}
{{ ajax_block(
partials,
updates_url,
'messages',
) }}
{% endblock %}

View File

@@ -34,13 +34,12 @@
<td><p>{{ item.template_folder }}</p></td>
<td><p>{{ item.last_used|format_datetime_table}}</p></td>
<td><p>{{ item.created_by }}</p></td>
<td><p>{{ '{:,.0f}'.format(item.count) }}</p></td>
<td><p>{{ item.count }}</p></td>
</tr>
{% endfor %}
</tbody>
</table>
<a
href="{{ url_for('.template_usage', service_id=current_service.id) }}" class="usa-link show-more-no-border"><span>See templates by month</span></a>
<a href="/services/78409625-0c0a-485e-b82c-b19c8f4b1bdb/template-usage" class="usa-link show-more-no-border"><span>See templates by month</span></a>
</div>
{% endif %}
</div>

View File

@@ -27,13 +27,11 @@
{% set content_hint = 'Your service name will be added to the start of your message. You can turn this off in Settings.' %}
{% endif %}
{% call form_wrapper(data_force_focus=True) %}
<div class="grid-container padding-0">
{% call form_wrapper() %}
<div class="grid-row">
<div class="tablet:grid-col-9 mobile-lg:grid-col-12">
{{ form.name(param_extensions={
"extra_form_group_classes": "margin-bottom-2",
"id": "name",
"required": True,
"hint": {"text": "Your recipients will not see this"}
}) }}
{{ textbox(
@@ -43,8 +41,7 @@
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!',
required=True
placeholder='Edit me! Check out the Personalization section below for details on cool ((stuff)) you can do with your messages!'
) }}
{% if current_user.platform_admin %}
{{ form.process_type }}
@@ -61,12 +58,12 @@
</div>
</div>
</div>
<div class="grid-row width-mobile-lg">
<div class="grid-row width-full">
<div class="tablet:grid-col-2 mobile-lg:grid-col-12">
{{ page_footer('Save') }}
</div>
<div class="tablet:grid-col-10 mobile-lg:grid-col-12">
<p class="usa-hint margin-top-5 tablet:margin-left-2">
<p class="usa-hint margin-top-5 tablet:margin-left-neg-2">
After saving, you'll have the option to send.
</p>
</div>

View File

@@ -28,7 +28,7 @@
<h2 class="heading-medium" id="personalised-messages">Personalized content</h2>
<p class="usa-body">Notify makes it easy to send personalized messages from a single template.</p>
<p class="usa-body">See <a class="usa-link" href="{{ url_for('.how_to', _anchor='personalized-content') }}">how to personalize your content</a>.</p>
<p class="usa-body">See <a class="usa-link" href="{{ url_for('.guidance_index', _anchor='personalized-content') }}">how to personalize your content</a>.</p>
<h2 class="heading-medium" id="bulk-sending">Bulk sending</h2>
<p class="usa-body">To send a batch of messages at once, upload a list of contact details to Notify. You can also schedule the date and time you want them to be sent.</p>

View File

@@ -61,7 +61,7 @@
<ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li>
<li>Add a new template or choose an existing template and select <b class="bold">Edit</b>.</li>
<li>Add a placeholder using double parenthesis. For example: Hello ((first&nbsp;name)), your reference is ((ref&nbsp;number)).</li>
<li>Add a placeholder using double brackets. For example: Hello ((first&nbsp;name)), your reference is ((ref&nbsp;number)).</li>
<li>Select <b class="bold">Save</b>.</li>
</ol>
@@ -81,7 +81,7 @@
<ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li>
<li>Add a new template or choose an existing template and select <b class="bold">Edit</b>.</li>
<li>Use double parenthesis and ?? to define optional content. For example, if you only want to show something to people who are under 18: ((under18??Please get your application signed by a parent or guardian.))</li>
<li>Use double brackets and ?? to define optional content. For example, if you only want to show something to people who are under 18: ((under18??Please get your application signed by a parent or guardian.))</li>
<li>Select <b class="bold">Save</b>.</li>
</ol>

View File

@@ -4,11 +4,11 @@
{% from "components/service-link.html" import service_link %}
{% block per_page_title %}
How to
Guidance
{% endblock %}
{% block content_column_content %}
<h1 class="font-body-2xl margin-bottom-3">How to</h1>
<h1 class="font-body-2xl margin-bottom-3">Guidance</h1>
<p>Notify allows you to easily create templates for messages for your recipients. You can customize messages to encourage
your recipient to manage their benefits and increase follow-through.</p>
@@ -59,9 +59,9 @@ your recipient to manage their benefits and increase follow-through.</p>
<h3>To personalize your content</h3>
<ol class="list">
<li>Add a placeholder to your content by placing two parenthesis around the personalized elements.</li>
<li>Add a placeholder to your content by placing two brackets around the personalized elements.</li>
<li>You can manually enter the personalized content or you can upload a spreadsheet with the details and let Notify do the
work for you.</li>
work for you. See <a href="#prepare-data">data preparation</a>.</li>
</ol>
<h4>Example</h4>
@@ -78,9 +78,9 @@ all or part of the message contingent upon specific criteria associated with the
<h3>To add conditional content</h3>
<ol class="list">
<li>Use two parenthesis and ?? to define the conditional content.</li>
<li>Use two brackets and ?? to define the conditional content.</li>
<li>You can manually enter the conditional content or you can upload a spreadsheet with the personal details and let Notify
do the work for you.</li>
do the work for you. See <a href="#prepare-data">data preparation</a>.</li>
</ol>
<h4>Examples</h4>

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>
@@ -23,9 +23,7 @@
<p>
When the <a class="use-link usa-link--external"
href="https://www.cbpp.org/research/food-assistance/targeted-text-message-outreach-can-increase-wic-enrollment-pilots-show"
target="_blank"
rel="noopener noreferrer"
aria-label="Center on Budget and Policy Priorities (opens in a new tab)">Center on Budget and Policy Priorities</a> studied WIC, they found key learnings about the
target="_blank">Center on Budget and Policy Priorities</a> studied WIC, they found key learnings about the
quantity of messages delivered, how people engage with messages, and how they take action.
</p>
@@ -70,11 +68,8 @@
</li>
</ul>
<p>
The <a class="use-link usa-link--external"
href="https://codeforamerica.org/resources/texting-playbook/"
target="_blank"
rel="noopener noreferrer"
aria-label="Code for America's Texting Playbook (opens in a new tab)">Code for Americas Texting
The <a class="use-link usa-link--external" href="https://codeforamerica.org/resources/texting-playbook/"
target="_blank">Code for Americas Texting
Playbook</a>
reported specific learnings around appointment reminders, completing
document submission, and maintenance reminders.

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set page_title = "Best practices" %}
{% set page_title = "Best Practices" %}
{% block per_page_title %}
{{page_title}}
@@ -8,7 +8,7 @@
{% block content_column_content %}
<section class="usa-prose">
<h1>Best practices</h1>
<h1>Best Practices</h1>
<p class="font-sans-lg text-base">For texting the public</p>
<p>Effectively reaching your audience and supporting your programs goals starts with strategically planning out what
text messages can help you achieve and how to approach a thoughtful rollout.

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>
@@ -142,9 +142,7 @@
<p>
The Department of Veterans Affairs provides a <a class="use-link usa-link--external"
href="https://github.com/department-of-veterans-affairs/va.gov-team/blob/master/products/va-notify/notification-guide.md#vanotify-notification-guide"
target="_blank"
rel="noopener noreferrer"
aria-label="helpful flow-chart (opens in a new tab)">helpful flow-chart</a> that can help you decide if a text message is needed
target="_blank">helpful flow-chart</a> that can help you decide if a text message is needed
for the communication problem you are trying to solve.
</p>
<h2>Use a hypothesis framework to plan your campaign</h2>

View File

@@ -10,7 +10,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>
@@ -101,11 +101,8 @@
<p class="sms-message-wrapper text-left">
{{circle_number(7) }}<b>Dept. of Social Services:</b> Hi {{circle_number(8) }}<b>Julie,</b> Your Medicaid
renewal is closing December 31, 2023. You can renew online at {{circle_number(9) }}<a
class="use-link usa-link--external"
href="https://www.application.yourstate.gov"
target="_blank"
rel="application.yourstate.gov"
aria-label="Yourstate dot gov (opens in a new tab)">https://www.application.yourstate.gov</a> or {{circle_number(10) }}
class="use-link usa-link--external" href="https://www.application.yourstate.gov" target="_blank"
rel="application.yourstate.gov">https://www.application.yourstate.gov</a> or {{circle_number(10) }}
<b>call the number on the back</b> of your Medicaid card.
</p>
</div>

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>
@@ -29,10 +29,7 @@
{% set links = [
{
"p_text": 'The <a class="use-link usa-link--external" href="https://www.fcc.gov/sites/default/files/tcpa-rules.pdf"
target="_blank"
rel="noopener noreferrer"
aria-label="Telephone Consumer Protection Act (TCPA) (opens in a new tab)"
>Telephone Consumer
target="_blank">Telephone Consumer
Protection Act (TCPA)</a> (47 USC § 227) is the federal law that impacts how organizations are allowed to
communicate
in bulk with the public via telephone (including text message or SMS).'
@@ -40,9 +37,7 @@
{
"p_text": 'The <a class="use-link usa-link--external"
href="https://www.fcc.gov/document/fcc-provides-best-practices-enable-critical-health-care-coverage-calls"
target="_blank"
rel="noopener noreferrer"
aria-label="FCC has ruled (opens in a new tab)">FCC has ruled</a> that <b>Federal</b> and <b>State</b> programs are exempt from the TCPA and can
target="_blank">FCC has ruled</a> that <b>Federal</b> and <b>State</b> programs are exempt from the TCPA and can
send
text messages to the public without consent if conducting official business. Without explicit mention in the ruling,
local governments, phone carriers, or any texting intermediaries might require it.'
@@ -57,10 +52,7 @@
case.
For additional questions about the enforcement of the TCPA, you can watch <a class="use-link usa-link--external"
href="https://www.performance.gov/cx/life-experiences/facing-a-financial-shock/outputs/fcc-training/"
target="_blank"
rel="noopener noreferrer"
aria-label="a recorded training on public
benefits texting (opens in a new tab)">a recorded training on public
target="_blank">a recorded training on public
benefits texting</a> provided by the FCC.'
},
] %}
@@ -73,9 +65,7 @@
<h4 class="usa-collection__heading">
<a class="usa-link {% if item.is_downloadable %}usa-link--downloadable{% else %}usa-link--external{% endif %}"
href="{% if item.is_downloadable %}{{ asset_url(item.url_link) }}{% else %}{{ item.url_link }}{% endif %}"
target="_blank"
rel="noopener noreferrer"
aria-label="{{ item.url_text }} (opens in a new tab)">
target="_blank">
{{ item.url_text }}
</a>
</h4>
@@ -129,7 +119,6 @@
process
to keep your phone number list up-to-date. The FCC offers a <a class="use-link usa-link--external"
href="https://www.fcc.gov/reassigned-numbers-database" target="_blank"
aria-label="Reassigned Numbers Database (opens in a new tab)"
rel="Reassigned Numbers Database">Reassigned
Numbers Database</a> to identify phone numbers that may have been reassigned since your agency obtained them.
</p>

View File

@@ -9,7 +9,7 @@
{% endblock %}
{% block content_column_content %}
{{ breadcrumbs.breadcrumb(page_title, "Best practices", "main.best_practices") }}
{{ breadcrumbs.breadcrumb(page_title, "Best Practices", "main.best_practices") }}
<section class="usa-prose">
<h1>{{page_title}}</h1>
@@ -81,10 +81,7 @@
</ul>
<h2>What provoking action looks like</h2>
<p class="padding-bottom-2"><a class="use-link usa-link--external"
href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC10002044/"
target="_blank"
rel="noopener noreferrer"
aria-label="Evidence shows (opens in a new tab)">Evidence
href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC10002044/" target="_blank">Evidence
shows</a> that employing
behavioral science is an effective way to increase the
likelihood of a recipient

View File

@@ -30,9 +30,6 @@
error_message_with_html=True
) }}
{% endif %}
<p>
Team members must be invited directly using their .gov email address, not from forwarded invitations. Invitation links to create a Notify.gov account are active for only 24 hours. Expired invitations can e re-sent from the Team members page.
</p>
{% include 'views/manage-users/permissions.html' %}

View File

@@ -11,18 +11,7 @@
{% block maincolumn_content %}
{{ page_header("Message status") }}
{% if not job.finished_processing %}
<div
data-module="update-content"
data-resource="{{ updates_url }}"
data-key="status"
data-form=""
>
{% endif %}
{{ partials['status']|safe }}
{% if not job.finished_processing %}
</div>
{% endif %}
{{ partials['status']|safe }}
{% if not finished %}
<div
data-module="update-content"

View File

@@ -34,7 +34,7 @@
{% set product_highlights = [
{
"svg_src": "#chat",
"card_heading": "Up to 250,000 messages to use over your first year*",
"card_heading": "Up to 100,000 messages to use over your first year*",
},
{
"svg_src": "#phone",

View File

@@ -43,6 +43,7 @@
{% endcall %}
</div>
{% elif error == 'message-too-long' %}
{# the only row_errors we can get when sending one off messages is that the message is too long #}
<div class="bottom-gutter">
{% call banner_wrapper(type='dangerous') %}
{% include "partials/check/message-too-long.html" %}
@@ -76,15 +77,13 @@
help='3' if help else 0
)}}" class='page-footer'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<!-- <p>Placeholder: This message will be delivered to <b>400 phone numbers</b> and will use a total of <b>800 message parts</b>, leaving Washington DSHS with <b>249,200 message parts remaining</b>.</p> -->
<h3>Does everything look good?</h3>
{% if not error %}
{% set button_text %}
{{ "Schedule" if scheduled_for else 'Send'}}
{% endset %}
{{ usaButton({
"text": button_text,
"name": button_text
}) }}
{{ usaButton({ "text": button_text }) }}
{% endif %}
</form>
</div>

View File

@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% set page_title = "Notify.gov Service Ending" %}
{% block per_page_title %}{{page_title}}{% endblock %}
{% block content_column_content %}
<section class="usa-prose">
<h1>{{page_title}}</h1>
<p>GSA will no longer offer the Notify.gov service after June 8th, 2025. Current partners will continue to have service access until it is fully suspended. After May 30, partners will no longer be able to send messages, and the full service will be suspended by June 8, 2025.
</p>
<h2>Notify.gov Status Announcement</h2>
<p class="text-italic">Notify.gov was in beta and will no longer be offered by GSA. Operations will be suspended on or before June 8. There is a chance that the service may need to stop sooner. GSA will keep partners informed to the extent possible.</p>
<p class="text-italic">All Notify.gov partners will be moved to trial mode no later than May 30, and will no longer be able to send messages. Partners will be able to access their templates and data through June 8.</p>
<p class="text-italic">We encourage government programs to explore ways to continue utilizing technology to meet people where they are. There are several commercial tools on the market that may work for Notify.gov use cases.</p>
<p class="text-italic">GSA is working to avoid potential disruptions to partners operations and ensure a smooth transition.</p>
<p class="text-italic">This decision is part of GSAs renewed focus on its original mission: to be the backbone of the federal governments administrative operations — streamlining processes, optimizing resources, and ensuring cost-effectiveness. GSA is driven to reducing federal spend, contributing to a more flexible and streamlined government, and delivering value for the American people.</p>
<p class="text-italic">We wish you all the best as you continue your work. </p>
</section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More