mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-27 09:43:58 -04:00
Compare commits
1 Commits
f9a08dffe0
...
sheevdave-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f384ba00bb |
14
.ds.baseline
14
.ds.baseline
@@ -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",
|
||||
@@ -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-04-10T19:38:31Z"
|
||||
"generated_at": "2025-01-16T16:38:48Z"
|
||||
}
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/issue_template.yml
vendored
2
.github/ISSUE_TEMPLATE/issue_template.yml
vendored
@@ -68,7 +68,7 @@ body:
|
||||
attributes:
|
||||
value: '**Accessibility:**'
|
||||
- type: textarea
|
||||
id: accessibility
|
||||
id: notes
|
||||
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."
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -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
|
||||
|
||||
139
.github/workflows/checks.yml
vendored
139
.github/workflows/checks.yml
vendored
@@ -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: |
|
||||
@@ -167,9 +165,8 @@ jobs:
|
||||
run: make run-flask &
|
||||
env:
|
||||
NOTIFY_ENVIRONMENT: scanning
|
||||
FEATURE_SOCKET_ENABLED: true
|
||||
- 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"
|
||||
@@ -179,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
|
||||
|
||||
6
.github/workflows/daily_checks.yml
vendored
6
.github/workflows/daily_checks.yml
vendored
@@ -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'
|
||||
|
||||
25
.github/workflows/deploy-demo.yml
vendored
25
.github/workflows/deploy-demo.yml
vendored
@@ -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 }}
|
||||
|
||||
25
.github/workflows/deploy-prod.yml
vendored
25
.github/workflows/deploy-prod.yml
vendored
@@ -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 }}
|
||||
|
||||
25
.github/workflows/deploy.yml
vendored
25
.github/workflows/deploy.yml
vendored
@@ -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 }}
|
||||
|
||||
24
.github/workflows/drift.yml
vendored
24
.github/workflows/drift.yml
vendored
@@ -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:
|
||||
|
||||
10
.github/workflows/terraform-demo.yml
vendored
10
.github/workflows/terraform-demo.yml
vendored
@@ -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:
|
||||
|
||||
10
.github/workflows/terraform-production.yml
vendored
10
.github/workflows/terraform-production.yml
vendored
@@ -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:
|
||||
|
||||
10
.github/workflows/terraform-staging.yml
vendored
10
.github/workflows/terraform-staging.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
5
.pa11yci
5
.pa11yci
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"defaults": {
|
||||
"standard": "WCAG2AA",
|
||||
"runners": ["htmlcs"],
|
||||
"chromeLaunchConfig": {
|
||||
"executablePath": "/usr/bin/google-chrome"
|
||||
},
|
||||
"runners": ["htmlcs"],
|
||||
"concurrency": 1,
|
||||
"hideElements": [
|
||||
"nav > ol a",
|
||||
|
||||
@@ -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
|
||||
|
||||
34
Makefile
34
Makefile
@@ -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
|
||||
@@ -69,7 +53,7 @@ generate-version-file: ## Generates the app version file
|
||||
@echo -e "__git_commit__ = \"${GIT_COMMIT}\"\n__time__ = \"${DATE}\"" > ${APP_VERSION_FILE}
|
||||
|
||||
.PHONY: test
|
||||
test: py-lint py-test js-test ## Run tests
|
||||
test: py-lint py-test js-lint js-test ## Run tests
|
||||
|
||||
.PHONY: py-lint
|
||||
py-lint: ## Run python linting scanners and black
|
||||
@@ -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 ***"
|
||||
@@ -99,7 +76,7 @@ too-complex:
|
||||
py-test: export NEW_RELIC_ENVIRONMENT=test
|
||||
py-test: ## Run python unit tests
|
||||
poetry run coverage run -m pytest --maxfail=10 --ignore=tests/end_to_end tests/
|
||||
poetry run coverage report --fail-under=93
|
||||
poetry run coverage report --fail-under=96
|
||||
poetry run coverage html -d .coverage_cache
|
||||
|
||||
.PHONY: dead-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)
|
||||
|
||||
@@ -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'",
|
||||
@@ -158,14 +156,11 @@ def _csp(config):
|
||||
"https://www.googletagmanager.com",
|
||||
"https://www.google-analytics.com",
|
||||
"https://dap.digitalgov.gov",
|
||||
"https://cdn.socket.io",
|
||||
],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
"https://gov-bam.nr-data.net",
|
||||
"https://www.google-analytics.com",
|
||||
"http://localhost:6011",
|
||||
"ws://localhost:6011",
|
||||
],
|
||||
"style-src": ["'self'", asset_domain],
|
||||
"img-src": ["'self'", asset_domain, logo_domain],
|
||||
@@ -173,20 +168,13 @@ 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_socket_enabled = application.config.get("FEATURE_SOCKET_ENABLED", False)
|
||||
feature_about_page_enabled = application.config.get(
|
||||
"FEATURE_ABOUT_PAGE_ENABLED", False
|
||||
)
|
||||
return dict(
|
||||
FEATURE_SOCKET_ENABLED=feature_socket_enabled,
|
||||
FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled,
|
||||
)
|
||||
|
||||
@application.context_processor
|
||||
@@ -628,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,
|
||||
@@ -687,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()))
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
(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 +13,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 +36,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 +46,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 +77,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 +90,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 +101,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 +114,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 +124,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 +149,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 +165,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 +188,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 +197,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 +214,7 @@
|
||||
labels = [];
|
||||
deliveredData = [];
|
||||
failedData = [];
|
||||
pendingData = [];
|
||||
|
||||
let totalMessages = 0;
|
||||
|
||||
for (var dateString in data) {
|
||||
@@ -245,8 +225,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 +253,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;
|
||||
@@ -336,7 +316,7 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize activityChart chart and table with service data by default
|
||||
fetchData(currentType);
|
||||
fetchData('service');
|
||||
|
||||
const allRows = Array.from(document.querySelectorAll('#activity-table tbody tr'));
|
||||
allRows.forEach((row, index) => {
|
||||
@@ -349,9 +329,9 @@
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const isJobPage = window.location.pathname.includes('/jobs/');
|
||||
if (!isJobPage) return;
|
||||
|
||||
const jobEl = document.querySelector('[data-job-id]');
|
||||
const jobId = jobEl?.dataset?.jobId;
|
||||
const featureEnabled = jobEl?.dataset?.feature === 'true';
|
||||
const apiHost = jobEl?.dataset?.host;
|
||||
|
||||
if (!jobId) return;
|
||||
|
||||
if (featureEnabled) {
|
||||
const socket = io(apiHost);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('join', { room: `job-${jobId}` });
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
socket.emit('leave', { room: `job-${jobId}` });
|
||||
});
|
||||
|
||||
const debouncedUpdate = debounce((data) => {
|
||||
updateAllJobSections();
|
||||
}, 1000);
|
||||
|
||||
socket.on('job_updated', (data) => {
|
||||
if (data.job_id !== jobId) return;
|
||||
debouncedUpdate(data);
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllJobSections() {
|
||||
const resourceEl = document.querySelector('[data-socket-update="status"]');
|
||||
const url = resourceEl?.dataset?.resource;
|
||||
|
||||
if (!url) {
|
||||
console.warn('No resource URL found for job updates');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then(({ status, counts, notifications }) => {
|
||||
const sections = {
|
||||
status: document.querySelector('[data-socket-update="status"]'),
|
||||
counts: document.querySelector('[data-socket-update="counts"]'),
|
||||
notifications: document.querySelector(
|
||||
'[data-socket-update="notifications"]'
|
||||
),
|
||||
};
|
||||
|
||||
if (status && sections.status) {
|
||||
sections.status.innerHTML = status;
|
||||
}
|
||||
if (counts && sections.counts) {
|
||||
sections.counts.innerHTML = counts;
|
||||
}
|
||||
if (notifications && sections.notifications) {
|
||||
sections.notifications.innerHTML = notifications;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error fetching job update partials:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1,117 +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) => {
|
||||
if (input.type === "hidden") return;
|
||||
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) => {
|
||||
if (input.type === "hidden") return;
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class Config(object):
|
||||
],
|
||||
}
|
||||
|
||||
FEATURE_SOCKET_ENABLED = getenv("FEATURE_SOCKET_ENABLED", "false") == "true"
|
||||
FEATURE_ABOUT_PAGE_ENABLED = getenv("FEATURE_ABOUT_PAGE_ENABLED", "false") == "true"
|
||||
|
||||
|
||||
def _s3_credentials_from_env(bucket_prefix):
|
||||
|
||||
@@ -344,7 +344,7 @@ def nl2br(value):
|
||||
html="escape",
|
||||
)
|
||||
).then(utils_nl2br)
|
||||
) # nosec
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 everyone’s 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 can’t 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\._']+$")
|
||||
|
||||
|
||||
@@ -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
|
||||
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,69 @@ 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
|
||||
]
|
||||
filtered_jobs = [job for job in job_response if job["job_status"] != "cancelled"]
|
||||
sorted_jobs = sorted(filtered_jobs, key=lambda job: job["created_at"], reverse=True)
|
||||
|
||||
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=sorted_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 +198,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 +326,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():
|
||||
|
||||
@@ -23,17 +23,16 @@ 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")
|
||||
def test_feature_flags():
|
||||
return jsonify(
|
||||
{"FEATURE_SOCKET_ENABLED": current_app.config["FEATURE_SOCKET_ENABLED"]}
|
||||
{"FEATURE_ABOUT_PAGE_ENABLED": current_app.config["FEATURE_ABOUT_PAGE_ENABLED"]}
|
||||
)
|
||||
|
||||
|
||||
@@ -259,11 +258,11 @@ 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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
|
||||
from functools import partial
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
@@ -58,12 +57,8 @@ def view_job(service_id, job_id):
|
||||
|
||||
filter_args = parse_filter_args(request.args)
|
||||
filter_args["status"] = set_status_filters(filter_args)
|
||||
api_host_name = os.environ.get('API_HOST_NAME')
|
||||
|
||||
return render_template(
|
||||
"views/jobs/job.html",
|
||||
api_host_name=api_host_name,
|
||||
FEATURE_SOCKET_ENABLED=current_app.config["FEATURE_SOCKET_ENABLED"],
|
||||
job=job,
|
||||
status=request.args.get("status", ""),
|
||||
updates_url=url_for(
|
||||
@@ -356,7 +351,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,
|
||||
],
|
||||
@@ -364,7 +359,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,
|
||||
],
|
||||
@@ -372,7 +367,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,
|
||||
],
|
||||
@@ -380,7 +375,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,
|
||||
],
|
||||
@@ -406,9 +401,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) or ("help=0" in request.referrer)
|
||||
else:
|
||||
session["arrived_from_preview_page"] = False
|
||||
|
||||
@@ -470,4 +463,4 @@ def get_preview_of_content(notification):
|
||||
notification["personalisation"],
|
||||
redact_missing_personalisation=True,
|
||||
).subject
|
||||
) # nosec
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -56,6 +56,10 @@ def about_notify_nav():
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Join Notify",
|
||||
"link": "main.join_notify",
|
||||
},
|
||||
{
|
||||
"name": "Contact us",
|
||||
"link": "main.contact",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -58,6 +58,8 @@ class HeaderNavigation(Navigation):
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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! #}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{% 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 message 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>
|
||||
@@ -37,7 +37,7 @@
|
||||
<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
|
||||
Your message is sending
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
@@ -49,7 +49,7 @@
|
||||
<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
|
||||
Your message is pending
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
@@ -64,7 +64,7 @@
|
||||
<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
|
||||
Your message has been sent
|
||||
</h2>
|
||||
<p class="usa-alert__text">
|
||||
{{ job.template_name }} - {{ current_service.name }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}}) }}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
17
app/templates/views/dashboard/_inbox.html
Normal file
17
app/templates/views/dashboard/_inbox.html
Normal 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>
|
||||
38
app/templates/views/dashboard/_inbox_messages.html
Normal file
38
app/templates/views/dashboard/_inbox_messages.html
Normal 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 service’s phone number ({}) you’ll 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>
|
||||
25
app/templates/views/dashboard/_totals.html
Normal file
25
app/templates/views/dashboard/_totals.html
Normal 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>
|
||||
@@ -18,7 +18,7 @@
|
||||
</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 class="table-container" id="activityContainer" data-currentUserName="{{ current_user.name }}">
|
||||
<div id="tableActivity" class="table-overflow-x-auto">
|
||||
<h2 id="table-heading" class="margin-top-4 margin-bottom-1">Service activity</h2>
|
||||
|
||||
@@ -46,16 +46,14 @@
|
||||
</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 %}
|
||||
{% if job.scheduled_for and not job.processing_finished %}
|
||||
Scheduled for {{ job.scheduled_for|format_datetime_table }}
|
||||
{% elif job.processing_finished and not job.statistics|selectattr('status', 'equalto', 'sending')|list %}
|
||||
Sent on {{ job.processing_finished|format_datetime_table }}
|
||||
{% elif job.processing_started %}
|
||||
Sending since {{ job.processing_started|format_datetime_table }}
|
||||
{% else %}
|
||||
Sent on {{ job.processing_started|format_datetime_table }}
|
||||
Pending since {{ job.created_at|format_datetime_table }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="table-field sender sender-column">{{ job.created_by.name }}</td>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
|
||||
{% 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 %}
|
||||
@@ -15,38 +15,29 @@ Dashboard
|
||||
|
||||
<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 %}
|
||||
<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' %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<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>
|
||||
</div>
|
||||
<div id="totalMessageTable" class="margin-0"></div>
|
||||
{% include 'views/dashboard/activity-table.html' %}
|
||||
{{ ajax_block(partials, updates_url, 'inbox') }}
|
||||
|
||||
{% if current_user.has_permissions('manage_service') %}{% endif %}
|
||||
<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/most-used-templates.html' %}
|
||||
{% include 'views/dashboard/activity-table.html' %}
|
||||
|
||||
{% if current_user.has_permissions('manage_service') %}{% endif %}
|
||||
|
||||
{{ ajax_block(partials, updates_url, 'template-statistics') }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
25
app/templates/views/dashboard/inbox.html
Normal file
25
app/templates/views/dashboard/inbox.html
Normal 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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 America’s Texting
|
||||
The <a class="use-link usa-link--external" href="https://codeforamerica.org/resources/texting-playbook/"
|
||||
target="_blank">Code for America’s Texting
|
||||
Playbook</a>
|
||||
reported specific learnings around appointment reminders, completing
|
||||
document submission, and maintenance reminders.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 name)), your reference is ((ref number)).</li>
|
||||
<li>Add a placeholder using double brackets. For example: Hello ((first name)), your reference is ((ref 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>
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ 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>
|
||||
</ol>
|
||||
@@ -78,7 +78,7 @@ 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>
|
||||
</ol>
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
|
||||
@@ -11,31 +11,22 @@
|
||||
{% block maincolumn_content %}
|
||||
|
||||
{{ page_header("Message status") }}
|
||||
<div data-job-id="{{ job.id }}" data-feature="{{FEATURE_SOCKET_ENABLED | lower}}" data-host="{{ api_host_name }}">
|
||||
{% if not job.finished_processing %}
|
||||
{% if not job.processing_finished %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="status"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-key="status"
|
||||
data-form=""
|
||||
>
|
||||
{% endif %}
|
||||
{{ partials['status']|safe }}
|
||||
{% if not job.finished_processing %}
|
||||
{% if not job.processing_finished %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not finished %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="counts"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-key="counts"
|
||||
data-form=""
|
||||
>
|
||||
@@ -50,12 +41,8 @@
|
||||
</p>
|
||||
{% if not job.processing_finished %}
|
||||
<div
|
||||
data-module="update-content"
|
||||
data-resource="{{ updates_url }}"
|
||||
{% if FEATURE_SOCKET_ENABLED %}
|
||||
data-socket-update="notifications"
|
||||
{% else %}
|
||||
data-module="update-content"
|
||||
{% endif %}
|
||||
data-key="notifications"
|
||||
data-form=""
|
||||
>
|
||||
@@ -64,6 +51,7 @@
|
||||
{% if not job.processing_finished %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div> </div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 GSA’s renewed focus on its original mission: to be the backbone of the federal government’s 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 %}
|
||||
@@ -11,7 +11,7 @@
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<a class="usa-link" target="_blank" rel="noopener noreferrer" aria-label="Download live services CSV report (opens in a new tab)" href="{{ url_for('main.live_services_csv') }}">Download live services csv report</a>
|
||||
<a class="usa-link" target="_blank" href="{{ url_for('main.live_services_csv') }}">Download live services csv report</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="usa-link" href="{{ url_for('main.notifications_sent_by_service') }}">Monthly notification statuses for live services</a>
|
||||
@@ -34,8 +34,5 @@
|
||||
<p>
|
||||
<a class="usa-link" href="{{ url_for('main.download_all_users') }}">Download All Users</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="usa-link" href="{{ url_for('main.get_redis_report') }}">Get Redis Report</a>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,14 +34,11 @@
|
||||
{% call form_wrapper(
|
||||
class='js-stick-at-top-when-scrolling send-one-off-form' if template.template_type != 'sms' else 'send-one-off-form',
|
||||
module="autofocus",
|
||||
data_kwargs={'force-focus': True},
|
||||
data_force_focus=True
|
||||
data_kwargs={'force-focus': True}
|
||||
) %}
|
||||
<div class="grid-row">
|
||||
{% set extra_class = "extra-tracking" if form.placeholder_value.label.text == "phone number" else "" %}
|
||||
{% set placeholder_id = "phone number" if form.placeholder_value.label.text == "phone number" else "" %}
|
||||
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
|
||||
{{ form.placeholder_value(param_extensions={"classes": "", "id": "phone-number"}) }}
|
||||
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}" aria-live="polite" role="alert">
|
||||
{{ form.placeholder_value(param_extensions={"classes": ""}) }}
|
||||
</div>
|
||||
{% if skip_link or link_to_upload %}
|
||||
<div class="grid-col-12 margin-top-1">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<script type="text/javascript" src="{{ asset_url('js/setTimezone.js') }}"></script>
|
||||
|
||||
{% block service_page_title %}
|
||||
Upload your bulk-sending spreadsheet
|
||||
Upload a list of {{ 999|recipient_count_label(template.template_type) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -15,61 +15,27 @@
|
||||
{{ usaBackLink(params) }}
|
||||
{% endblock %}
|
||||
|
||||
{% set phone_numbers = [
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Label column A (the first column) as Phone number",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Double check it's the only column with Phone number as its label",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Make sure no duplicate phone numbers are listed in Column A",
|
||||
}
|
||||
]
|
||||
%}
|
||||
|
||||
{% set additional_data = [
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Match column labels one-to-one to the message template placeholders",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Label each additional <a class=\"usa-link\" href=\"/using-notify/how-to#personalize-content\">personalized placeholder</a> separately",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Separate each word in a column label with a space or dash, but no commas",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Fill in each personalized placeholder with the appropriate data or information",
|
||||
},
|
||||
{
|
||||
"svg_src": "#check_circle",
|
||||
"card_heading": "Fill in each <a class=\"usa-link\" href=\"/using-notify/how-to#conditional-content\">conditional placeholder</a> column with a Yes (Y) or No (N) to “answer” whether the recipient meets its criteria",
|
||||
}
|
||||
]
|
||||
%}
|
||||
|
||||
{% block maincolumn_content %}
|
||||
|
||||
{{ page_header('Upload your bulk-sending spreadsheet')}}
|
||||
<p class="font-sans-lg text-base">Organize phone numbers and information in a single spreadsheet and upload when you have multiple messages to send. Column headers in the spreadsheet will place the data in the right spots within the template that's selected.</p>
|
||||
{{ page_header('Upload a list of {}'.format(999|recipient_count_label(template.template_type))) }}
|
||||
|
||||
<div class="page-footer bottom-gutter margin-top-3">
|
||||
<div class="page-footer bottom-gutter">
|
||||
{{file_upload(
|
||||
form.file,
|
||||
allowed_file_extensions=allowed_file_extensions,
|
||||
button_text='Choose and upload a spreadsheet',
|
||||
button_text='Choose a file',
|
||||
show_errors=False
|
||||
)}}
|
||||
</div>
|
||||
|
||||
<h2 class="font-body-lg">A spreadsheet is available to use</h2>
|
||||
<h2 class="font-body-lg">Your file needs to look like this example</h2>
|
||||
<p class="hint">
|
||||
Save your file as a
|
||||
<abbr title="Comma Separated Values">CSV</abbr><span aria-hidden="true">, </span>
|
||||
<abbr title="Tab Separated Values">TSV</abbr><span aria-hidden="true">, </span>
|
||||
<abbr title="Open Document Spreadsheet">ODS</abbr>
|
||||
or Microsoft Excel spreadsheet.
|
||||
</p>
|
||||
|
||||
<div class="spreadsheet" data-module="fullscreen-table">
|
||||
{% call(item, row_number) list_table(
|
||||
@@ -84,72 +50,11 @@
|
||||
{% endfor %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
<p class="hint">
|
||||
Each template saved in Notify is given an example <abbr title="Comma Separated Values">CSV</abbr> formatted spreadsheet like this. It's the most reliable file format for uploading your contact list. Use this example to populate your template with the right data items. Start by downloading this example for your message template. Then save it as a <abbr title="Comma Separated Values">CSV</abbr> file for bulk messaging.
|
||||
</p>
|
||||
<p class="table-show-more-link">
|
||||
<a class="usa-link display-flex margin-top-1" href="{{ url_for('.get_example_csv', service_id=current_service.id, template_id=template.id) }}" download>Download this example (<abbr title="Comma separated values">CSV</abbr>)
|
||||
<img class="margin-left-05" src="{{ asset_url('img/material-icons/download.svg') }}" alt="" />
|
||||
</a>
|
||||
<a class="usa-link" href="{{ url_for('.get_example_csv', service_id=current_service.id, template_id=template.id) }}" download>Download this example (<abbr title="Comma separated values">CSV</abbr>)</a>
|
||||
</p>
|
||||
<h2 class="font-body-lg">Your bulk-sending spreadsheet checklist</h2>
|
||||
<div class="grid-container margin-top-2 padding-0">
|
||||
<div class="grid-row">
|
||||
<div class="grid-col-12">
|
||||
<h3>Phone numbers</h3>
|
||||
<ul class="usa-icon-list">
|
||||
{% for item in phone_numbers %}
|
||||
<li class="usa-icon-list__item">
|
||||
<div class="usa-icon-list__icon text-green">
|
||||
<svg aria-hidden="true" focusable="false" role="img" class="usa-icon">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}{{ item.svg_src }}"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="usa-icon-list__content">
|
||||
{{item.card_heading | safe }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="grid-col-12">
|
||||
<h3>Additional data</h3>
|
||||
<ul class="usa-icon-list">
|
||||
{% for item in additional_data %}
|
||||
<li class="usa-icon-list__item">
|
||||
<div class="usa-icon-list__icon text-green">
|
||||
<svg aria-hidden="true" focusable="false" role="img" class="usa-icon">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}{{ item.svg_src }}"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="usa-icon-list__content">
|
||||
{{item.card_heading | safe }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="grid-col-12">
|
||||
<h3>If an error occurs</h3>
|
||||
<ul class="usa-icon-list">
|
||||
<li class="usa-icon-list__item">
|
||||
<div class="usa-icon-list__icon text-red">
|
||||
<svg aria-hidden="true" focusable="false" role="img" class="usa-icon">
|
||||
<use xlink:href="{{ asset_url('img/sprite.svg') }}#error"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="usa-icon-list__content">
|
||||
If you're receiving any errors, double check your file format and make sure your data is pasted in with values only
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="font-body-lg margin-bottom-2">You are filling in this template</h2>
|
||||
<p>Template: {{ template.name }}</p>
|
||||
<h2 class="font-body-lg margin-bottom-1">Your file will populate this template:<br><span class="font-body-lg">({{ template.name }})</span></h2>
|
||||
{{ template|string }}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
label='Search by name',
|
||||
autofocus=True
|
||||
) }}
|
||||
{% call form_wrapper(data_force_focus=True) %}
|
||||
{% call form_wrapper() %}
|
||||
{% if has_organizations %}
|
||||
{{ form.organizations }}
|
||||
{{ sticky_page_footer('Save') }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
{% block maincolumn_content %}
|
||||
|
||||
{% call form_wrapper(data_force_focus=True) %}
|
||||
{% call form_wrapper() %}
|
||||
{{ page_header('Free text message allowance') }}
|
||||
{{ form.free_sms_allowance }}
|
||||
{{ page_footer('Save') }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user