Compare commits

..

8 Commits

Author SHA1 Message Date
Kenneth Kehl
050ce753a2 ugh 2025-01-17 10:45:16 -08:00
Kenneth Kehl
038f9aead6 try again 2025-01-16 14:10:56 -08:00
Kenneth Kehl
50e8e4bdb0 try again 2025-01-16 14:05:10 -08:00
Kenneth Kehl
c05b0076fa try again 2025-01-16 13:59:44 -08:00
Kenneth Kehl
2d8d2945b7 try again 2025-01-16 13:45:40 -08:00
Kenneth Kehl
7c9fbe8417 try again 2025-01-16 13:38:06 -08:00
Kenneth Kehl
4519a5a333 try again 2025-01-16 13:31:40 -08:00
Kenneth Kehl
e600087266 initial 2025-01-16 12:00:15 -08:00
146 changed files with 4309 additions and 3553 deletions

View File

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

View File

@@ -64,17 +64,6 @@ body:
validations: validations:
required: false required: false
- type: markdown
attributes:
value: '**Accessibility:**'
- type: textarea
id: accessibility
attributes:
label: "List any specific accessibility guidance or tests that need to be considered for this user story."
description: "List what type of accessibility tests need to pass."
validations:
required: false
- type: markdown - type: markdown
attributes: attributes:
value: '**Notes:**' value: '**Notes:**'

View File

@@ -20,10 +20,3 @@ Please enter a detailed description here.
* Consideration 1 * Consideration 1
* Consideration 2 * Consideration 2
* Consideration ... * Consideration ...
## A11y Checks (if applicable)
* Double check work is getting picked up by the automated E2E tests
* Conduct browser-based tests through [AxeDevTools](https://www.deque.com/axe/devtools/) and [WAVE](https://wave.webaim.org/)
* Review the [Manual Checklist](https://docs.google.com/document/d/192bBXStebdXWtYhZQ73qaWMJhGcuSB1W6c9YBXhWZvc/edit?usp=sharing)
* Make sure there are no linting errors in VSCode or other IDE of choice

View File

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

View File

@@ -16,7 +16,7 @@ env:
FLASK_APP: application.py FLASK_APP: application.py
WERKZEUG_DEBUG_PIN: off WERKZEUG_DEBUG_PIN: off
REDIS_ENABLED: 0 REDIS_ENABLED: 0
NODE_VERSION: 22.3.0 NODE_VERSION: 16.15.1
jobs: jobs:
dependency-audits: dependency-audits:
@@ -26,7 +26,7 @@ jobs:
- uses: ./.github/actions/setup-project - uses: ./.github/actions/setup-project
- name: Create requirements.txt - name: Create requirements.txt
run: poetry export --without-hashes --format=requirements.txt > 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: with:
inputs: requirements.txt inputs: requirements.txt
- name: Run npm audit - name: Run npm audit
@@ -50,7 +50,7 @@ jobs:
env: env:
NOTIFY_ENVIRONMENT: scanning NOTIFY_ENVIRONMENT: scanning
- name: Run OWASP Full Scan - name: Run OWASP Full Scan
uses: zaproxy/action-full-scan@v0.12.0 uses: zaproxy/action-full-scan@v0.7.0
with: with:
docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' docker_name: 'ghcr.io/zaproxy/zaproxy:weekly'
target: 'http://localhost:6012' target: 'http://localhost:6012'

View File

@@ -16,21 +16,23 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
# Looks like we need to install Terraform ourselves now! - name: Check for changes to Terraform
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 id: changed-terraform-files
- name: Setup Terraform uses: tj-actions/changed-files@v44
uses: hashicorp/setup-terraform@v3
with: with:
terraform_version: "^1.7.5" files: |
terraform_wrapper: false terraform/demo/**
terraform/shared/**
.github/workflows/deploy-demo.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/demo working-directory: terraform/demo
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init run: terraform init
- name: Terraform apply - name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/demo working-directory: terraform/demo
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -89,7 +91,16 @@ jobs:
--var LOGIN_PEM="$LOGIN_PEM" --var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling --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 - name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy uses: ./.github/actions/deploy-proxy
env: env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }} CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -16,21 +16,23 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
# Looks like we need to install Terraform ourselves now! - name: Check for changes to Terraform
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 id: changed-terraform-files
- name: Setup Terraform uses: tj-actions/changed-files@v44
uses: hashicorp/setup-terraform@v3
with: with:
terraform_version: "^1.7.5" files: |
terraform_wrapper: false terraform/production/**
terraform/shared/**
.github/workflows/deploy-prod.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/production working-directory: terraform/production
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init run: terraform init
- name: Terraform apply - name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/production working-directory: terraform/production
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -89,7 +91,16 @@ jobs:
--var LOGIN_PEM="$LOGIN_PEM" --var LOGIN_PEM="$LOGIN_PEM"
--strategy rolling --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 - name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy uses: ./.github/actions/deploy-proxy
env: env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }} CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -21,21 +21,23 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
# Looks like we need to install Terraform ourselves now! - name: Check for changes to Terraform
# https://github.com/actions/runner-images/issues/10796#issuecomment-2417064348 id: changed-terraform-files
- name: Setup Terraform uses: tj-actions/changed-files@v44
uses: hashicorp/setup-terraform@v3
with: with:
terraform_version: "^1.7.5" files: |
terraform_wrapper: false terraform/staging/**
terraform/shared/**
.github/workflows/deploy.yml
- name: Terraform init - name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging working-directory: terraform/staging
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.TERRAFORM_STATE_SECRET_ACCESS_KEY }}
run: terraform init run: terraform init
- name: Terraform apply - name: Terraform apply
if: steps.changed-terraform-files.outputs.any_changed == 'true'
working-directory: terraform/staging working-directory: terraform/staging
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.TERRAFORM_STATE_ACCESS_KEY }}
@@ -96,7 +98,16 @@ jobs:
--strategy rolling --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 - name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy uses: ./.github/actions/deploy-proxy
env: env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }} CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}

View File

@@ -15,14 +15,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Check for drift
uses: dflook/terraform-check@v1 uses: dflook/terraform-check@v1
env: env:
@@ -43,14 +35,6 @@ jobs:
with: with:
ref: 'production' 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 - name: Check for drift
uses: dflook/terraform-check@v1 uses: dflook/terraform-check@v1
env: env:
@@ -71,14 +55,6 @@ jobs:
with: with:
ref: 'production' 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 - name: Check for drift
uses: dflook/terraform-check@v1 uses: dflook/terraform-check@v1
env: env:

View File

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

View File

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

View File

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

4
.gitignore vendored
View File

@@ -15,10 +15,6 @@
## Non user files allowed to be commited ## Non user files allowed to be commited
!app/assets/pdf/tcpa_overview.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/no_eof_marker.pdf
!tests/test_pdf_files/multi_page_pdf.pdf !tests/test_pdf_files/multi_page_pdf.pdf
!tests/test_pdf_files/big.pdf !tests/test_pdf_files/big.pdf

View File

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

View File

@@ -62,13 +62,6 @@ py-lint: ## Run python linting scanners and black
poetry run flake8 . poetry run flake8 .
poetry run isort --check-only ./app ./tests 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 .PHONY: avg-complexity
avg-complexity: avg-complexity:
echo "*** Shows average complexity in radon of all code ***" echo "*** Shows average complexity in radon of all code ***"
@@ -162,8 +155,3 @@ upload-static:
# @cf map-route notify-admin ${DNS_NAME} --hostname www # @cf map-route notify-admin ${DNS_NAME} --hostname www
# @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www # @cf unmap-route notify-admin-failwhale ${DNS_NAME} --hostname www
# @echo "Failwhale is disabled" # @echo "Failwhale is disabled"
.PHONY: test-single
test-single: export NEW_RELIC_ENVIRONMENT=test
test-single: ## Run a single test file
poetry run pytest $(TEST_FILE)

View File

@@ -1,6 +1,5 @@
import os import os
import pathlib import pathlib
import re
import secrets import secrets
from functools import partial from functools import partial
from time import monotonic from time import monotonic
@@ -169,15 +168,14 @@ def _csp(config):
def create_app(application): def create_app(application):
# @application.context_processor @application.context_processor
# def inject_feature_flags(): def inject_feature_flags():
# this is where feature flags can be easily added as a dictionary within context feature_about_page_enabled = application.config.get(
# feature_about_page_enabled = application.config.get( "FEATURE_ABOUT_PAGE_ENABLED", False
# "FEATURE_ABOUT_PAGE_ENABLED", False )
# ) return dict(
# return dict( FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled,
# FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled, )
# )
@application.context_processor @application.context_processor
def inject_initial_signin_url(): def inject_initial_signin_url():
@@ -618,8 +616,6 @@ def setup_event_handlers():
def add_template_filters(application): def add_template_filters(application):
application.add_template_filter(slugify)
for fn in [ for fn in [
format_auth_type, format_auth_type,
format_billions, format_billions,
@@ -677,10 +673,3 @@ def init_jinja(application):
] ]
jinja_loader = jinja2.FileSystemLoader(template_folders) jinja_loader = jinja2.FileSystemLoader(template_folders)
application.jinja_loader = jinja_loader application.jinja_loader = jinja_loader
def slugify(text):
"""
Converts text to lowercase, replaces spaces with hyphens, and removes invalid characters.
"""
return re.sub(r"[^a-z0-9-]", "", re.sub(r"\s+", "-", text.lower()))

View File

@@ -1,14 +1,10 @@
(function (window) { (function (window) {
if (document.getElementById('activityChartContainer')) { 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 = { const COLORS = {
delivered: '#0076d6', delivered: '#0076d6',
failed: '#fa9441', failed: '#fa9441',
pending: '#C7CACE',
text: '#666' text: '#666'
}; };
@@ -16,7 +12,7 @@
const FONT_WEIGHT = 'bold'; const FONT_WEIGHT = 'bold';
const MAX_Y = 120; const MAX_Y = 120;
const createChart = function(containerId, labels, deliveredData, failedData, pendingData) { const createChart = function(containerId, labels, deliveredData, failedData) {
const container = d3.select(containerId); const container = d3.select(containerId);
container.selectAll('*').remove(); // Clear any existing content container.selectAll('*').remove(); // Clear any existing content
@@ -39,7 +35,7 @@
} }
// Calculate total messages // 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 // Create legend only if there are messages
const legendContainer = d3.select('.chart-legend'); const legendContainer = d3.select('.chart-legend');
@@ -49,8 +45,7 @@
// Show legend if there are messages // Show legend if there are messages
const legendData = [ const legendData = [
{ label: 'Delivered', color: COLORS.delivered }, { label: 'Delivered', color: COLORS.delivered },
{ label: 'Failed', color: COLORS.failed }, { label: 'Failed', color: COLORS.failed }
{ label: 'Pending', color: COLORS.pending }
]; ];
const legendItem = legendContainer.selectAll('.legend-item') const legendItem = legendContainer.selectAll('.legend-item')
@@ -81,9 +76,8 @@
.range([0, width]) .range([0, width])
.padding(0.1); .padding(0.1);
// Adjust the y-axis domain to add some space above the tallest bar // 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 maxY = d3.max(deliveredData.map((d, i) => d + (failedData[i] || 0)));
const y = d3.scaleSqrt()
const y = d3.scaleSymlog()
.domain([0, maxY + 2]) // Add 2 units of space at the top .domain([0, maxY + 2]) // Add 2 units of space at the top
.nice() .nice()
.range([height, 0]); .range([height, 0]);
@@ -95,7 +89,7 @@
// Generate the y-axis with whole numbers // Generate the y-axis with whole numbers
const yAxis = d3.axisLeft(y) 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 .tickFormat(d3.format('d')); // Ensure whole numbers on the y-axis
svg.append('g') svg.append('g')
@@ -106,13 +100,12 @@
const stackData = labels.map((label, i) => ({ const stackData = labels.map((label, i) => ({
label: label, label: label,
delivered: deliveredData[i], delivered: deliveredData[i],
failed: failedData[i] || 0, failed: failedData[i] || 0 // Ensure there's a value for failed, even if it's 0
pending: pendingData[i] || 0
})); }));
// Stack the data // Stack the data
const stack = d3.stack() const stack = d3.stack()
.keys(['delivered', 'failed', 'pending']) .keys(['delivered', 'failed'])
.order(d3.stackOrderNone) .order(d3.stackOrderNone)
.offset(d3.stackOffsetNone); .offset(d3.stackOffsetNone);
@@ -120,8 +113,8 @@
// Color scale // Color scale
const color = d3.scaleOrdinal() const color = d3.scaleOrdinal()
.domain(['delivered', 'failed', 'pending']) .domain(['delivered', 'failed'])
.range([COLORS.delivered, COLORS.failed, COLORS.pending]); .range([COLORS.delivered, COLORS.failed]);
// Create bars with animation // Create bars with animation
const barGroups = svg.selectAll('.bar-group') const barGroups = svg.selectAll('.bar-group')
@@ -130,12 +123,11 @@
.append('g') .append('g')
.attr('class', 'bar-group') .attr('class', 'bar-group')
.attr('fill', d => color(d.key)); .attr('fill', d => color(d.key));
const minBarHeight = 5;
barGroups.selectAll('rect') barGroups.selectAll('rect')
.data(d => d) .data(d => d)
.enter() .enter()
.append('rect') .append('rect')
.filter(d => d[1] - d[0] > 0)
.attr('x', d => x(d.data.label)) .attr('x', d => x(d.data.label))
.attr('y', height) .attr('y', height)
.attr('height', 0) .attr('height', 0)
@@ -156,13 +148,11 @@
.transition() .transition()
.duration(1000) .duration(1000)
.attr('y', d => y(d[1])) .attr('y', d => y(d[1]))
.attr('height', d => { .attr('height', d => y(d[0]) - y(d[1]));
const calculatedHeight = y(d[0]) - y(d[1]); };
return calculatedHeight < minBarHeight ? minBarHeight : calculatedHeight;
}); };
// Function to create an accessible table // 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); const table = document.getElementById(tableId);
table.innerHTML = ""; // Clear previous data table.innerHTML = ""; // Clear previous data
@@ -174,7 +164,7 @@
// Create table header // Create table header
const headerRow = document.createElement('tr'); const headerRow = document.createElement('tr');
const headers = ['Day', 'Delivered', 'Failed', 'Pending']; const headers = ['Day', 'Delivered', 'Failed'];
headers.forEach(headerText => { headers.forEach(headerText => {
const th = document.createElement('th'); const th = document.createElement('th');
th.textContent = headerText; th.textContent = headerText;
@@ -197,10 +187,6 @@
cellFailed.textContent = failedData[index]; cellFailed.textContent = failedData[index];
row.appendChild(cellFailed); row.appendChild(cellFailed);
const cellPending = document.createElement('td');
cellPending.textContent = pendingData[index];
row.appendChild(cellPending);
tbody.appendChild(row); tbody.appendChild(row);
}); });
@@ -210,19 +196,12 @@
}; };
const fetchData = function(type) { const fetchData = function(type) {
var ctx = document.getElementById('weeklyChart'); var ctx = document.getElementById('weeklyChart');
if (!ctx) { if (!ctx) {
return; return;
} }
var userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; var url = type === 'service' ? `/daily_stats.json` : `/daily_stats_by_user.json`;
var url = type === 'service'
? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}`
: `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`;
return fetch(url) return fetch(url)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
@@ -234,7 +213,7 @@
labels = []; labels = [];
deliveredData = []; deliveredData = [];
failedData = []; failedData = [];
pendingData = [];
let totalMessages = 0; let totalMessages = 0;
for (var dateString in data) { for (var dateString in data) {
@@ -245,8 +224,9 @@
labels.push(formattedDate); labels.push(formattedDate);
deliveredData.push(data[dateString].sms.delivered); deliveredData.push(data[dateString].sms.delivered);
failedData.push(data[dateString].sms.failure); failedData.push(data[dateString].sms.failure);
pendingData.push(data[dateString].sms.pending || 0);
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure + data[dateString].sms.pending; // Calculate the total number of messages
totalMessages += data[dateString].sms.delivered + data[dateString].sms.failure;
} }
} }
@@ -272,18 +252,17 @@
} }
} else { } else {
// If there are messages, create the chart and table // If there are messages, create the chart and table
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData); createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData); 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 handleDropdownChange = function(event) {
const selectedValue = event.target.value; const selectedValue = event.target.value;
currentType = selectedValue;
const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`); const subTitle = document.querySelector(`#activityChartContainer .chart-subtitle`);
const selectElement = document.getElementById('options'); const selectElement = document.getElementById('options');
const selectedText = selectElement.options[selectElement.selectedIndex].text; const selectedText = selectElement.options[selectElement.selectedIndex].text;
@@ -291,67 +270,36 @@
subTitle.textContent = `${selectedText} - last 7 days`; subTitle.textContent = `${selectedText} - last 7 days`;
fetchData(selectedValue); fetchData(selectedValue);
// Update ARIA live region
const liveRegion = document.getElementById('aria-live-account'); const liveRegion = document.getElementById('aria-live-account');
liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`; liveRegion.textContent = `Data updated for ${selectedText} - last 7 days`;
const tableHeading = document.querySelector('#tableActivity h2'); // Switch tables based on dropdown selection
const senderColumns = document.querySelectorAll('.sender-column'); const selectedTable = selectedValue === "individual" ? "table1" : "table2";
const allRows = document.querySelectorAll('#activity-table tbody tr'); const tables = document.querySelectorAll('.table-overflow-x-auto');
const caption = document.querySelector('#activity-table caption'); tables.forEach(function(table) {
table.classList.add('hidden'); // Hide all tables by adding the hidden class
if (selectedValue === 'individual') { table.classList.remove('visible'); // Ensure they are not visible
});
tableHeading.textContent = 'My activity'; const tableToShow = document.getElementById(selectedTable);
caption.textContent = `Table showing the sent jobs for ${currentUserName}`; tableToShow.classList.remove('hidden'); // Remove hidden class
tableToShow.classList.add('visible'); // Add visible class
senderColumns.forEach(col => {
col.style.display = 'none';
});
allRows.forEach(row => row.style.display = 'none');
const userRows = Array.from(allRows).filter(row => {
const senderCell = row.querySelector('.sender-column');
const rowSender = senderCell ? senderCell.textContent.trim() : '';
return rowSender === currentUserName;
});
userRows.slice(0, 5).forEach(row => {
row.style.display = '';
});
} else {
tableHeading.textContent = 'Service activity';
caption.textContent = `Table showing the sent jobs for service`;
senderColumns.forEach(col => {
col.style.display = '';
});
allRows.forEach((row, index) => {
row.style.display = (index < 5) ? '' : 'none';
});
}
}; };
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize activityChart chart and table with service data by default // 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) => {
row.style.display = (index < 5) ? '' : 'none';
});
// Add event listener to the dropdown
const dropdown = document.getElementById('options'); const dropdown = document.getElementById('options');
dropdown.addEventListener('change', handleDropdownChange); dropdown.addEventListener('change', handleDropdownChange);
}); });
// Resize chart on window resize // Resize chart on window resize
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0 && pendingData.length > 0) { if (labels.length > 0 && deliveredData.length > 0 && failedData.length > 0) {
createChart('#weeklyChart', labels, deliveredData, failedData, pendingData); createChart('#weeklyChart', labels, deliveredData, failedData);
createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData, pendingData); createTable('weeklyTable', 'activityChart', labels, deliveredData, failedData);
} }
}); });

View File

@@ -13,23 +13,16 @@
} else { } else {
$submitButton.data('clicked', 'true'); $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 => () => { let renableSubmitButton = $submitButton => () => {
$submitButton.data('clicked', ''); $submitButton.data('clicked', '');
$submitButton.prop('disabled', false);
}; };
$('form').on('submit', disableSubmitButtons); $('form').on('submit', disableSubmitButtons);

View File

@@ -6,17 +6,17 @@
var chartTitle = document.getElementById('chartTitle').textContent; var chartTitle = document.getElementById('chartTitle').textContent;
// Access data attributes from the HTML // Access data attributes from the HTML
var messagesSent = parseInt(chartContainer.getAttribute('data-messages-sent')); var sms_sent = parseInt(chartContainer.getAttribute('data-sms-sent'));
var messagesRemaining = parseInt(chartContainer.getAttribute('data-messages-remaining')); var sms_remaining_messages = parseInt(chartContainer.getAttribute('data-sms-allowance-remaining'));
var totalMessages = messagesSent + messagesRemaining; var totalMessages = sms_sent + sms_remaining_messages;
// Update the message below the chart // 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 // 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 minSentValue = totalMessages * minSentPercentage;
var displaySent = Math.max(messagesSent, minSentValue); var displaySent = Math.max(sms_sent, minSentValue);
var displayRemaining = totalMessages - displaySent; var displayRemaining = totalMessages - displaySent;
var svg = d3.select("#totalMessageChart"); var svg = d3.select("#totalMessageChart");
@@ -48,7 +48,7 @@
.attr("width", 0) // Start with width 0 for animation .attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) { .on('mouseover', function(event) {
tooltip.style('display', 'block') tooltip.style('display', 'block')
.html(`Messages Sent: ${messagesSent.toLocaleString()}`); .html(`Messages Sent: ${sms_sent.toLocaleString()}`);
}) })
.on('mousemove', function(event) { .on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`) tooltip.style('left', `${event.pageX + 10}px`)
@@ -66,7 +66,7 @@
.attr("width", 0) // Start with width 0 for animation .attr("width", 0) // Start with width 0 for animation
.on('mouseover', function(event) { .on('mouseover', function(event) {
tooltip.style('display', 'block') tooltip.style('display', 'block')
.html(`Remaining: ${messagesRemaining.toLocaleString()}`); .html(`Remaining: ${sms_remaining_messages.toLocaleString()}`);
}) })
.on('mousemove', function(event) { .on('mousemove', function(event) {
tooltip.style('left', `${event.pageX + 10}px`) tooltip.style('left', `${event.pageX + 10}px`)
@@ -115,9 +115,9 @@
var tbodyRow = document.createElement('tr'); var tbodyRow = document.createElement('tr');
var tdMessagesSent = document.createElement('td'); 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'); 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(tdMessagesSent);
tbodyRow.appendChild(tdRemaining); tbodyRow.appendChild(tdRemaining);

View File

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

Binary file not shown.

View File

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

View File

@@ -8,7 +8,7 @@
width: 100%; width: 100%;
max-width: 464px; max-width: 464px;
box-sizing: border-box; box-sizing: border-box;
padding: units(2); padding: units(1);
background: color('gray-cool-10'); background: color('gray-cool-10');
border: 1px solid color('gray-cool-10'); border: 1px solid color('gray-cool-10');
border-radius: 5px; border-radius: 5px;

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
Explore Notify, add team members, and practice [sending messages to teammates](/using-notify/trial-mode). Explore Notify, add team members, and practice [sending messages to teammates](/using-notify/trial-mode).
2. ## Personalize content 2. ## Personalize content
Learn how to [personalize messages](/using-notify/how-to) to increase response. Learn how to [personalize messages](/using-notify/guidance) to increase response.
3. ## Check delivery status 3. ## Check delivery status
[Analyze the delivery](/using-notify/delivery-status) of your messages and download reports [Analyze the delivery](/using-notify/delivery-status) of your messages and download reports

View File

@@ -1,3 +1,5 @@
import csv
from io import StringIO
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -28,6 +30,16 @@ class CsvFileValidator:
self.message = message self.message = message
def __call__(self, form, field): def __call__(self, form, field):
data = Spreadsheet.from_file_form(form).as_dict
data = data["data"]
if data is not None and data != "":
csv_file = StringIO(data)
reader = csv.reader(csv_file)
first_line = next(reader, None)
if first_line is None or all(cell.strip() == "" for cell in first_line):
raise ValidationError(f"No headers on row 1 in {field.data.filename}")
if not Spreadsheet.can_handle(field.data.filename): if not Spreadsheet.can_handle(field.data.filename):
raise ValidationError( raise ValidationError(
"{} is not a spreadsheet that Notify can read".format( "{} is not a spreadsheet that Notify can read".format(
@@ -61,7 +73,7 @@ class ValidEmail:
class NoCommasInPlaceHolders: 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 self.message = message
def __call__(self, form, field): def __call__(self, form, field):
@@ -108,17 +120,33 @@ class OnlySMSCharacters:
) )
if non_sms_characters: if non_sms_characters:
raise ValidationError( raise ValidationError(
"Please remove the unaccepted character {} in your message, then save again".format( "You cannot use {} in {}. {} will not show up properly on everyones phones.".format(
formatted_list( formatted_list(
non_sms_characters, non_sms_characters,
conjunction="and", conjunction="or",
before_each="", before_each="",
after_each="", after_each="",
), ),
{
"sms": "text messages",
}.get(self._template_type),
("It" if len(non_sms_characters) == 1 else "They"),
) )
) )
# class NoPlaceholders:
# def __init__(self, message=None):
# self.message = message or (
# 'You cant use ((double brackets)) to personalize this message'
# )
# def __call__(self, form, field):
# if Field(field.data).placeholders:
# raise ValidationError(self.message)
class LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly: class LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly:
regex = re.compile(r"^[a-zA-Z0-9\s\._']+$") regex = re.compile(r"^[a-zA-Z0-9\s\._']+$")

View File

@@ -1,25 +1,34 @@
import calendar import calendar
from datetime import datetime, timedelta from datetime import datetime
from functools import partial from functools import partial
from itertools import groupby 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 flask_login import current_user
from werkzeug.utils import redirect from werkzeug.utils import redirect
from app import ( from app import (
billing_api_client, billing_api_client,
current_service,
job_api_client, job_api_client,
service_api_client, service_api_client,
template_statistics_client, template_statistics_client,
) )
from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left
from app.main import main from app.main import main
from app.main.views.user_profile import set_timezone from app.main.views.user_profile import set_timezone
from app.statistics_utils import get_formatted_percentage 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.time import get_current_financial_year
from app.utils.user import user_has_permissions 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") @main.route("/services/<uuid:service_id>/dashboard")
@@ -39,111 +48,87 @@ def service_dashboard(service_id):
if not current_user.has_permissions("view_activity"): if not current_user.has_permissions("view_activity"):
return redirect(url_for("main.choose_template", service_id=service_id)) 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"] job_response = job_api_client.get_jobs(service_id)["data"]
service_data_retention_days = 7 service_data_retention_days = 7
active_jobs = [job for job in job_response if job["job_status"] != "cancelled"] jobs = [
sorted_jobs = sorted(active_jobs, key=lambda job: job["created_at"], reverse=True) {
job_lists = [ "job_id": job["id"],
{**job_dict, "finished_processing": job_is_finished(job_dict)} "time_left": get_time_left(job["created_at"]),
for job_dict in sorted_jobs "download_link": url_for(
".view_job_csv", service_id=current_service.id, job_id=job["id"]
),
"view_job_link": url_for(
".view_job", service_id=current_service.id, job_id=job["id"]
),
"created_at": job["created_at"],
"processing_finished": job.get("processing_finished"),
"processing_started": job.get("processing_started"),
"notification_count": job["notification_count"],
"created_by": job["created_by"],
"template_name": job["template_name"],
"original_file_name": job["original_file_name"],
}
for job in job_response
if job["job_status"] != "cancelled"
] ]
total_messages = service_api_client.get_service_message_ratio(service_id)
messages_remaining = total_messages.get("messages_remaining", 0)
messages_sent = total_messages.get("messages_sent", 0)
all_statistics = template_statistics_client.get_template_statistics_for_service(
service_id, limit_days=7
)
template_statistics = aggregate_template_usage(all_statistics)
return render_template( return render_template(
"views/dashboard/dashboard.html", "views/dashboard/dashboard.html",
jobs=job_lists, updates_url=url_for(".service_dashboard_updates", service_id=service_id),
partials=get_dashboard_partials(service_id),
jobs=jobs,
service_data_retention_days=service_data_retention_days, service_data_retention_days=service_data_retention_days,
messages_remaining=messages_remaining, sms_sent=sms_sent,
messages_sent=messages_sent, sms_allowance_remaining=sms_allowance_remaining,
template_statistics=template_statistics,
most_used_template_count=max(
[row["count"] for row in template_statistics] or [0]
),
) )
def job_is_finished(job_dict): @main.route("/daily_stats.json")
done_statuses = DELIVERED_STATUSES + FAILURE_STATUSES + ["cancelled"] def get_daily_stats():
processed_count = sum( service_id = session.get("service_id")
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):
date_range = get_stats_date_range() 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( stats = service_api_client.get_service_notification_statistics_by_day(
service_id, service_id, start_date=date_range["start_date"], days=date_range["days"]
start_date=date_range["start_date"],
days=days,
) )
return jsonify(stats)
local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days)
return jsonify(local_stats)
def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days): @main.route("/daily_stats_by_user.json")
tz = ZoneInfo(user_timezone) def get_daily_stats_by_user():
today_local = datetime.now(tz).date() service_id = session.get("service_id")
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):
date_range = get_stats_date_range() date_range = get_stats_date_range()
days = date_range["days"] user_id = current_user.id
user_timezone = request.args.get("timezone", "UTC") stats = service_api_client.get_user_service_notification_statistics_by_day(
stats_utc = service_api_client.get_user_service_notification_statistics_by_day(
service_id, service_id,
user_id=current_user.id, user_id,
start_date=date_range["start_date"], 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") @main.route("/services/<uuid:service_id>/template-usage")
@@ -231,6 +216,103 @@ def usage(service_id):
) )
@main.route("/services/<uuid:service_id>/monthly")
@user_has_permissions("view_activity")
def monthly(service_id):
year, current_financial_year = requested_and_current_financial_year(request)
return render_template(
"views/dashboard/monthly.html",
months=format_monthly_stats_to_list(
service_api_client.get_monthly_notification_stats(service_id, year)["data"]
),
years=get_tuples_of_financial_years(
partial_url=partial(url_for, ".monthly", service_id=service_id),
start=current_financial_year - 2,
end=current_financial_year,
),
selected_year=year,
)
@main.route("/services/<uuid:service_id>/inbox")
@user_has_permissions("view_activity")
@service_has_permission("inbound_sms")
def inbox(service_id):
return render_template(
"views/dashboard/inbox.html",
partials=get_inbox_partials(service_id),
updates_url=url_for(
".inbox_updates", service_id=service_id, page=request.args.get("page")
),
)
@main.route("/services/<uuid:service_id>/inbox.json")
@user_has_permissions("view_activity")
@service_has_permission("inbound_sms")
def inbox_updates(service_id):
return jsonify(get_inbox_partials(service_id))
@main.route("/services/<uuid:service_id>/inbox.csv")
@user_has_permissions("view_activity")
def inbox_download(service_id):
return Response(
Spreadsheet.from_rows(
[
[
"Phone number",
"Message",
"Received",
]
]
+ [
[
format_phone_number_human_readable(message["user_number"]),
message["content"].lstrip(("=+-@")),
format_datetime_numeric(message["created_at"]),
]
for message in service_api_client.get_inbound_sms(service_id)["data"]
]
).as_csv_data,
mimetype="text/csv",
headers={
"Content-Disposition": 'inline; filename="Received text messages {}.csv"'.format(
format_date_numeric(datetime.utcnow().isoformat())
)
},
)
def get_inbox_partials(service_id):
page = int(request.args.get("page", 1))
inbound_messages_data = service_api_client.get_most_recent_inbound_sms(
service_id, page=page
)
inbound_messages = inbound_messages_data["data"]
if not inbound_messages:
inbound_number = current_service.inbound_number
else:
inbound_number = None
prev_page = None
if page > 1:
prev_page = generate_previous_dict("main.inbox", service_id, page)
next_page = None
if inbound_messages_data["has_next"]:
next_page = generate_next_dict("main.inbox", service_id, page)
return {
"messages": render_template(
"views/dashboard/_inbox_messages.html",
messages=inbound_messages,
inbound_number=inbound_number,
prev_page=prev_page,
next_page=next_page,
)
}
def filter_out_cancelled_stats(template_statistics): def filter_out_cancelled_stats(template_statistics):
return [s for s in template_statistics if s["status"] != "cancelled"] return [s for s in template_statistics if s["status"] != "cancelled"]
@@ -262,6 +344,71 @@ def aggregate_template_usage(template_statistics, sort_key="count"):
return sorted(templates, key=lambda x: x[sort_key], reverse=True) 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): def get_dashboard_totals(statistics):
for msg_type in statistics.values(): for msg_type in statistics.values():

View File

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

View File

@@ -57,6 +57,7 @@ def view_job(service_id, job_id):
filter_args = parse_filter_args(request.args) filter_args = parse_filter_args(request.args)
filter_args["status"] = set_status_filters(filter_args) filter_args["status"] = set_status_filters(filter_args)
return render_template( return render_template(
"views/jobs/job.html", "views/jobs/job.html",
job=job, job=job,
@@ -401,9 +402,7 @@ def get_job_partials(job):
) )
if request.referrer is not None: if request.referrer is not None:
session["arrived_from_preview_page"] = ("check" in request.referrer) or ( session["arrived_from_preview_page"] = "check" in request.referrer
"help=0" in request.referrer
)
else: else:
session["arrived_from_preview_page"] = False session["arrived_from_preview_page"] = False

View File

@@ -116,70 +116,6 @@ def download_all_users():
return response 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): def is_over_threshold(number, total, threshold):
percentage = number / total * 100 if total else 0 percentage = number / total * 100 if total else 0
return percentage > threshold return percentage > threshold

View File

@@ -132,6 +132,7 @@ def send_messages(service_id, template_id):
form = CsvUploadForm() form = CsvUploadForm()
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
upload_id = s3upload( upload_id = s3upload(
service_id, service_id,
Spreadsheet.from_file_form(form).as_dict, Spreadsheet.from_file_form(form).as_dict,
@@ -167,7 +168,7 @@ def send_messages(service_id, template_id):
# just show the first error, as we don't expect the form to have more # just show the first error, as we don't expect the form to have more
# than one, since it only has one field # than one, since it only has one field
first_field_errors = list(form.errors.values())[0] 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}{first_field_errors[0]}"
error_message = f"{error_message}</span>" error_message = f"{error_message}</span>"
error_message = Markup(error_message) error_message = Markup(error_message)
@@ -924,10 +925,6 @@ def get_template_error_dict(exception):
def preview_notification(service_id, template_id): def preview_notification(service_id, template_id):
recipient = get_recipient() recipient = get_recipient()
if not recipient: if not recipient:
current_app.logger.warning(
f"No recipient found for service {service_id}, template {template_id}. Redirecting..."
)
return redirect( return redirect(
url_for( url_for(
".send_one_off", ".send_one_off",

View File

@@ -68,12 +68,11 @@ def _get_access_token(code): # pragma: no cover
id_token = get_id_token(response_json) id_token = get_id_token(response_json)
nonce = id_token["nonce"] nonce = id_token["nonce"]
nonce_key = f"login-nonce-{unquote(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: if nonce != stored_nonce:
current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}") current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}")
abort(403) abort(403)
try: try:
access_token = response_json["access_token"] access_token = response_json["access_token"]
@@ -113,7 +112,7 @@ def _do_login_dot_gov(): # $ pragma: no cover
verify_key = f"login-verify_email-{unquote(state)}" verify_key = f"login-verify_email-{unquote(state)}"
verify_path = bool(redis_client.get(verify_key)) 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)}" state_key = f"login-state-{unquote(state)}"
stored_state = unquote(redis_client.get(state_key).decode("utf8")) stored_state = unquote(redis_client.get(state_key).decode("utf8"))
if state != stored_state: if state != stored_state:

View File

@@ -2,7 +2,7 @@ def using_notify_nav():
nav_items = [ nav_items = [
{"name": "Get started", "link": "main.get_started"}, {"name": "Get started", "link": "main.get_started"},
{ {
"name": "Best practices", "name": "Best Practices",
"link": "main.best_practices", "link": "main.best_practices",
"sub_navigation_items": [ "sub_navigation_items": [
{ {
@@ -33,8 +33,8 @@ def using_notify_nav():
}, },
{"name": "Trial mode", "link": "main.trial_mode_new"}, {"name": "Trial mode", "link": "main.trial_mode_new"},
{"name": "Tracking usage", "link": "main.pricing"}, {"name": "Tracking usage", "link": "main.pricing"},
{"name": "Delivery status", "link": "main.message_status"}, {"name": "Delivery Status", "link": "main.message_status"},
{"name": "How to", "link": "main.how_to"}, {"name": "Guidance", "link": "main.guidance_index"},
] ]
return nav_items return nav_items
@@ -56,6 +56,10 @@ def about_notify_nav():
}, },
], ],
}, },
{
"name": "Join Notify",
"link": "main.join_notify",
},
{ {
"name": "Contact us", "name": "Contact us",
"link": "main.contact", "link": "main.contact",

View File

@@ -680,45 +680,41 @@ 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): 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": if template.template_type == "sms":
s1 = ( s1 = f"<html><body>Use of characters outside the <a href='{url}'>IEC_8859-1</a> character set may increase "
"<html><body>Looks like your template may have one of these characters " s2 = "the message fragment count, resulting in additional charges, and these IEC_8859-1 "
"• ™ ∞ ≤ or ≥ or emoji, which won't save." s3 = "characters may not display properly on some older mobile devices.</body></html>"
)
s2 = "<br>Please remove any unaccepted characters or emojis and try again.</body></html>"
# Define characters that should be blocked warning = ""
BLOCKED_CHARACTERS = {"", "", "", "", ""} 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
) # 🚨 ONLY show the warning, hiding "Will be charged..."
# If message is too long, return the length error
if template.is_message_too_long(): if template.is_message_too_long():
return True, ( return True, (
f"You have " f"You have "
f"{character_count(template.content_count_without_prefix - SMS_CHAR_COUNT_LIMIT)} " f"{character_count(template.content_count_without_prefix - SMS_CHAR_COUNT_LIMIT)} "
f"too many" f"too many"
) )
# Show charge message as usual if no warning
if template.placeholders: if template.placeholders:
return False, Markup( return False, (
f"Will be charged as {message_count(template.fragment_count, template.template_type)} " Markup(
f"(not including personalization)." 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} "
) )
return False, Markup(
f"Will be charged as {message_count(template.fragment_count, template.template_type)}."
) )

View File

@@ -42,7 +42,8 @@ class Spreadsheet:
@staticmethod @staticmethod
def normalise_newlines(file_content): def normalise_newlines(file_content):
return "\r\n".join(file_content.read().decode("utf-8").splitlines()) rows = file_content.read().decode("utf-8").splitlines()
return "\r\n".join(rows)
@classmethod @classmethod
def from_rows(cls, rows, filename=""): def from_rows(cls, rows, filename=""):

View File

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

View File

@@ -1,55 +1,24 @@
import json
from app.extensions import redis_client
from app.notify_client import NotifyAdminAPIClient from app.notify_client import NotifyAdminAPIClient
class BillingAPIClient(NotifyAdminAPIClient): class BillingAPIClient(NotifyAdminAPIClient):
def get_monthly_usage_for_service(self, service_id, year): def get_monthly_usage_for_service(self, service_id, year):
monthly_usage = redis_client.get(f"monthly-usage-summary-{service_id}-{year}") return self.get(
if monthly_usage is not None:
return json.loads(monthly_usage.decode("utf-8"))
result = self.get(
"/service/{0}/billing/monthly-usage".format(service_id), "/service/{0}/billing/monthly-usage".format(service_id),
params=dict(year=year), 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): def get_annual_usage_for_service(self, service_id, year=None):
annual_usage = redis_client.get(f"yearly-usage-summary-{service_id}-{year}") return self.get(
if annual_usage is not None:
return json.loads(annual_usage.decode("utf-8"))
result = self.get(
"/service/{0}/billing/yearly-usage-summary".format(service_id), "/service/{0}/billing/yearly-usage-summary".format(service_id),
params=dict(year=year), 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): 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:
return json.loads(frag_limit.decode("utf-8"))
result = self.get( result = self.get(
"/service/{0}/billing/free-sms-fragment-limit".format(service_id), "/service/{0}/billing/free-sms-fragment-limit".format(service_id),
params=dict(financial_year_start=year), params=dict(financial_year_start=year),
) )
redis_client.set(
f"free-sms-fragment-limit-{service_id}-{year}",
json.dumps(result["free_sms_fragment_limit"]),
ex=30,
)
return result["free_sms_fragment_limit"] return result["free_sms_fragment_limit"]
def create_or_update_free_sms_fragment_limit( def create_or_update_free_sms_fragment_limit(
@@ -67,28 +36,13 @@ class BillingAPIClient(NotifyAdminAPIClient):
) )
def get_data_for_billing_report(self, start_date, end_date): def get_data_for_billing_report(self, start_date, end_date):
x_start_date = str(start_date) return self.get(
x_start_date = x_start_date.replace(" ", "_")
x_end_date = str(end_date)
x_end_date = x_end_date.replace(" ", "_")
billing_data = redis_client.get(
f"get-data-for-billing-report-{x_start_date}-{x_end_date}"
)
if billing_data is not None:
return json.loads(billing_data.decode("utf-8"))
result = self.get(
url="/platform-stats/data-for-billing-report", url="/platform-stats/data-for-billing-report",
params={ params={
"start_date": str(start_date), "start_date": str(start_date),
"end_date": str(end_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): def get_data_for_volumes_by_service_report(self, start_date, end_date):
return self.get( return self.get(

View File

@@ -1,6 +1,3 @@
import json
from app.extensions import redis_client
from app.notify_client import NotifyAdminAPIClient, _attach_current_user from app.notify_client import NotifyAdminAPIClient, _attach_current_user
@@ -44,7 +41,7 @@ class NotificationApiClient(NotifyAdminAPIClient):
if job_id: if job_id:
return method( return method(
url="/service/{}/job/{}/notifications".format(service_id, job_id), url="/service/{}/job/{}/notifications".format(service_id, job_id),
**kwargs, **kwargs
) )
else: else:
if limit_days is not None: 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): def get_notification_count_for_job_id(self, *, service_id, job_id):
counts = redis_client.get( return self.get(
f"notification-count-for-job-id-{service_id}-{job_id}"
)
if counts is not None:
return json.loads(counts.decode("utf-8"))
result = self.get(
url="/service/{}/job/{}/notification_count".format(service_id, job_id) url="/service/{}/job/{}/notification_count".format(service_id, job_id)
) )["count"]
redis_client.set(
f"notification-count-for-job-id-{service_id}-{job_id}",
json.dumps(result["count"]),
ex=30,
)
return result["count"]
notification_api_client = NotificationApiClient() notification_api_client = NotificationApiClient()

View File

@@ -1,4 +1,3 @@
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import redis_client from app.extensions import redis_client
@@ -518,18 +517,7 @@ class ServiceAPIClient(NotifyAdminAPIClient):
return int(count) return int(count)
def get_global_notification_count(self, service_id): def get_global_notification_count(self, service_id):
notification_count = redis_client.get(f"notification-count-{service_id}") return self.get("/service/{}/notification-count".format(service_id))
if notification_count is not None:
return json.loads(notification_count.decode("utf-8"))
notification_count = self.get(
"/service/{}/notification-count".format(service_id)
)
redis_client.set(
f"notification-count-{service_id}", json.dumps(notification_count), ex=30
)
return notification_count
def get_service_invite_data(self, redis_key): def get_service_invite_data(self, redis_key):
""" """
@@ -537,11 +525,6 @@ class ServiceAPIClient(NotifyAdminAPIClient):
""" """
return self.get("/service/invite/redis/{0}".format(redis_key)) 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() service_api_client = ServiceAPIClient()

View File

@@ -116,7 +116,7 @@ class UserApiClient(NotifyAdminAPIClient):
data["next"] = next_string data["next"] = next_string
if code_type == "email": if code_type == "email":
data["email_auth_link_host"] = self.admin_url 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}")) current_app.logger.warn(hilite(f"Sending verify_code {code_type} to {user_id}"))
self.post(endpoint, data=data) self.post(endpoint, data=data)

View File

@@ -14,8 +14,10 @@
<script nonce="{{ csp_nonce() }}">document.body.className = ((document.body.className) ? document.body.className + ' js-enabled' : 'js-enabled');</script> <script nonce="{{ csp_nonce() }}">document.body.className = ((document.body.className) ? document.body.className + ' js-enabled' : 'js-enabled');</script>
{% block bodyStart %} {% block bodyStart %}
{% block extra_javascripts_before_body %} {% block extra_javascripts_before_body %}
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WX5NGWF" <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WX5NGWF"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
@@ -29,30 +31,6 @@
{% block header %} {% block header %}
{% include 'components/usa_banner.html' %} {% 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' %} {% include 'components/header.html' %}
{% endblock %} {% endblock %}
@@ -118,10 +96,10 @@
{% block footer %} {% block footer %}
{% if current_service and current_service.research_mode %} {% 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 %} {% else %}
{% set commit_hash = ", Latest version: " + config['COMMIT_HASH'] %} {% 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 %} {% set meta_suffix = "Built by the " + long_link + commit_hash %}
{% endif %} {% endif %}
@@ -167,8 +145,10 @@
{% block bodyEnd %} {% block bodyEnd %}
{% block extra_javascripts %} {% block extra_javascripts %}
{% endblock %} {% endblock %}
<!--[if gt IE 8]><!-->
<script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script> <script type="text/javascript" src="{{ asset_url('javascripts/all.js') }}"></script>
<script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script> <script type="text/javascript" src="{{ asset_url('js/uswds.min.js') }}"></script>
<!--<![endif]-->
{% endblock %} {% endblock %}
</body> </body>
</html> </html>

View File

@@ -16,7 +16,7 @@
{#- Define common attributes we can use for both button and input types #} {#- 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! #} {#- Actually create a button... or a link! #}

View File

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

View File

@@ -20,7 +20,7 @@
</span> </span>
{% endif %} {% endif %}
{% if field.errors and show_errors %} {% if field.errors and show_errors %}
<span class="error-message usa-error-message"> <span class="error-message">
{{ field.errors[0] }} {{ field.errors[0] }}
</span> </span>
{% endif %} {% endif %}

View File

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

View File

@@ -1,6 +1,7 @@
{% set is_about_page = request.path.startswith('/about') %} {% 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_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 %} {% if current_user.is_authenticated %}
{% set navigation = [ {% set navigation = [
@@ -33,6 +34,7 @@
{% else %} {% else %}
{% set navigation = [ {% set navigation = [
{"href": url_for('main.about_notify'), "text": "About Notify", "active": is_about_page}, {"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} {"href": url_for('main.contact'), "text": "Contact us", "active": is_contact_page}
] %} ] %}
{% endif %} {% endif %}

View File

@@ -32,7 +32,7 @@
<legend class="form-label {% if bold_legend %}bold{% endif %}"> <legend class="form-label {% if bold_legend %}bold{% endif %}">
{{ field.label.text }} {{ field.label.text }}
{% if field.errors %} {% 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] }} {{ field.errors[0] }}
</span> </span>
{% endif %} {% endif %}

View File

@@ -52,7 +52,7 @@
</span> </span>
{% endif %} {% endif %}
{% if field.errors %} {% 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] }} {{ field.errors[0] }}
</span> </span>
{% endif %} {% endif %}

View File

@@ -16,9 +16,19 @@
placeholder='' placeholder=''
) %} ) %}
<div <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 %}" 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 }}"> <label class="usa-label" for="{{ field.name }}">
{% if label %} {% if label %}
{{ label }} {{ label }}
@@ -31,12 +41,6 @@
{{ hint }} {{ hint }}
</div> </div>
{% endif %} {% 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 if highlight_placeholders or autosize
%} %}
@@ -55,8 +59,6 @@
data_highlight_placeholders='true' if highlight_placeholders else 'false', data_highlight_placeholders='true' if highlight_placeholders else 'false',
rows=rows|string, rows=rows|string,
placeholder=placeholder, placeholder=placeholder,
aria_describedby=field.name+"-error",
required='required' if required else None,
**kwargs **kwargs
) }} ) }}
{% if suffix %} {% if suffix %}

View File

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

View File

@@ -5,6 +5,10 @@
</h3> </h3>
<div id="message-length" class="usa-accordion__content usa-prose"> <div id="message-length" class="usa-accordion__content usa-prose">
<p class="usa-body"> <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> </p>
</div> </div>

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
Send a document by email Send a document by email
</h2> </h2>
<p class="usa-body"> <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> </p>
{{ usaInsetText({ {{ usaInsetText({
"text": "Download your document at: ((link_to_file))", "text": "Download your document at: ((link_to_file))",

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set page_title = "About Notify" %} {% set page_title = "About Notify" %}
{% block per_page_title %} {% block per_page_title %}
{{page_title}} {{page_title}}
{% endblock %} {% endblock %}
@@ -26,7 +27,7 @@
{% set product_highlights = [ {% set product_highlights = [
{ {
"svg_src": "#send", "svg_src": "#send",
"card_heading": "Send customized one-way messages", "card_heading": "Send customized one-way customized messages",
"p_text": "Upload a file with recipient phone numbers and Notify.gov sends customized messages", "p_text": "Upload a file with recipient phone numbers and Notify.gov sends customized messages",
}, },
{ {
@@ -66,6 +67,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </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 <p>Notify.gov is a product of the <a href="/studio">Public Benefits Studio</a>, a product accelerator inside
the federal government. </p> the federal government. </p>
</section> </section>

View File

@@ -40,7 +40,8 @@
</p> </p>
<p> <p>
Texting not only helps programs reach people using a nearly-universal communication method, it is a cost effective 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> </p>
<h2 id="what-texting-is-best-for">What texting is best for</h2> <h2 id="what-texting-is-best-for">What texting is best for</h2>
<p> <p>
@@ -50,37 +51,35 @@
{ {
"image_src": asset_url('images/calendar.svg'), "image_src": asset_url('images/calendar.svg'),
"card_heading": "Reminders", "card_heading": "Reminders",
"p_text": "Your Quality Control food phone interview is on ((date)) at ((time)). Failure to "p_text": "In a text bubble // Your Quality Control food phone interview is on ((date)) at ((time)). Failure to
attend may lead to closure of your benefits. Call 1-800-222-3333 with questions.", attend may lead to closure of your benefits. Call 1-800-222-3333 with questions.",
"alt_text": "reminder text example" "alt_text": "reminder text example"
}, },
{ {
"image_src": asset_url('images/alert.svg'), "image_src": asset_url('images/alert.svg'),
"card_heading": "Alerts to take action", "card_heading": "Alerts to take action",
"p_text": "Your household's Medicaid coverage is expiring. To keep getting Medicaid, you must "p_text": "In a text bubble // Your household's Medicaid coverage is expiring. To keep getting Medicaid, you must
complete your renewal by ((date)). You can renew online at dhs.state.gov…", complete your renewal by ((date)). You can renew online at dhs.state.gov…",
"alt_text": "alerts text example" "alt_text": "alerts text example"
}, },
{ {
"image_src": asset_url('images/alarm.svg'), "image_src": asset_url('images/alarm.svg'),
"card_heading": "Important status updates", "card_heading": "Important status updates",
"p_text": "Your passport has been issued at the Los Angeles Passport Agency. Please come to the "p_text": "In a text bubble // Your passport has been issued at the Los Angeles Passport Agency. Please come to the
desk between 1:30pm and 2:30pm today to pick up your passport…", desk between 1:30pm and 2:30pm today to pick up your passport…",
"alt_text": "status update text example" "alt_text": "status update text example"
}, },
] %} ] %}
{% for item in card_contents %} {% for item in card_contents %}
<div class="grid-container-tablet padding-3 radius-lg border-2px margin-left-0 "> <div class="radius-lg border-2px maxw-tablet">
<h3 class="usa-card__heading padding-y-2">{{item.card_heading}}</h3> <div class="grid-row grid-gap-4 padding-2 padding-x-3 flex-align-center">
<div class="grid-row grid-gap-3 flex-align-center"> <div class="grid-col flex-3">
<div class="grid-col-auto "> <p><b>{{item.card_heading}}</b></p>
<p class="sms-message-wrapper">{{item.p_text}}</p> <p>{{item.p_text}}</p>
</div>
<div class="grid-col-fill">
{% if item.image_src %}
<img src="{{ item.image_src }}" alt="{{ item.alt_text }}" class="height-15" />
{% endif %}
</div> </div>
{% if item.image_src %}
<img src="{{item.image_src}}" alt="{{ item.alt_text }}" class="height-15" />
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -14,7 +14,7 @@
{{ page_header('About your service') }} {{ 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"}}) }} {{ form.name(param_extensions={"hint": {"text": "You can change this later"}}) }}

View File

@@ -102,7 +102,7 @@ Error
<div class="usa-alert usa-alert--error" role="alert"> <div class="usa-alert usa-alert--error" role="alert">
<div class="usa-alert__body"> <div class="usa-alert__body">
<h1 class="usa-alert__heading banner-title" data-module="track-error" data-error-type="Missing placeholder columns" <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"> <p class="usa-alert__text">
Your file is missing {{ recipients.missing_column_headers | formatted_list( Your file is missing {{ recipients.missing_column_headers | formatted_list(
conjunction='and', conjunction='and',

View File

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

View File

@@ -7,10 +7,19 @@
{% block content_column_content %} {% block content_column_content %}
<section class="usa-prose"> <section class="usa-prose">
<h1>{{page_title}}</h1> <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> 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 <p>You can expect a response within one business day.</p>
at <a href="mailto:notify-support@gsa.gov" <div class="usa-summary-box maxw-tablet __web-inspector-hide-shortcut__" role="region"
aria-label="Email Notify for technical questions at notify-support@gsa.gov">notify-support@gsa.gov</a></p> 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> </section>
{% endblock %} {% endblock %}

View File

@@ -8,7 +8,7 @@
{% endblock %} {% endblock %}
{% block backLink %} {% 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 %} {% endblock %}
{% block maincolumn_content %} {% block maincolumn_content %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@
<ol class="list list-number"> <ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li> <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 new template or choose an existing template and select <b class="bold">Edit</b>.</li>
<li>Add a placeholder using double parenthesis. For example: Hello ((first&nbsp;name)), your reference is ((ref&nbsp;number)).</li> <li>Add a placeholder using double brackets. For example: Hello ((first&nbsp;name)), your reference is ((ref&nbsp;number)).</li>
<li>Select <b class="bold">Save</b>.</li> <li>Select <b class="bold">Save</b>.</li>
</ol> </ol>
@@ -81,7 +81,7 @@
<ol class="list list-number"> <ol class="list list-number">
<li>Go to the {{ service_link(current_service, 'main.choose_template', 'templates') }} page.</li> <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 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> <li>Select <b class="bold">Save</b>.</li>
</ol> </ol>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@
{% endcall %} {% endcall %}
</div> </div>
{% elif error == 'message-too-long' %} {% 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"> <div class="bottom-gutter">
{% call banner_wrapper(type='dangerous') %} {% call banner_wrapper(type='dangerous') %}
{% include "partials/check/message-too-long.html" %} {% include "partials/check/message-too-long.html" %}
@@ -76,15 +77,13 @@
help='3' if help else 0 help='3' if help else 0
)}}" class='page-footer'> )}}" class='page-footer'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <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> <h3>Does everything look good?</h3>
{% if not error %} {% if not error %}
{% set button_text %} {% set button_text %}
{{ "Schedule" if scheduled_for else 'Send'}} {{ "Schedule" if scheduled_for else 'Send'}}
{% endset %} {% endset %}
{{ usaButton({ {{ usaButton({ "text": button_text }) }}
"text": button_text,
"name": button_text
}) }}
{% endif %} {% endif %}
</form> </form>
</div> </div>

View File

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

View File

@@ -34,8 +34,5 @@
<p> <p>
<a class="usa-link" href="{{ url_for('main.download_all_users') }}">Download All Users</a> <a class="usa-link" href="{{ url_for('main.download_all_users') }}">Download All Users</a>
</p> </p>
<p>
<a class="usa-link" href="{{ url_for('main.get_redis_report') }}">Get Redis Report</a>
</p>
{% endblock %} {% endblock %}

View File

@@ -34,14 +34,11 @@
{% call form_wrapper( {% call form_wrapper(
class='js-stick-at-top-when-scrolling send-one-off-form' if template.template_type != 'sms' else 'send-one-off-form', class='js-stick-at-top-when-scrolling send-one-off-form' if template.template_type != 'sms' else 'send-one-off-form',
module="autofocus", module="autofocus",
data_kwargs={'force-focus': True}, data_kwargs={'force-focus': True}
data_force_focus=True
) %} ) %}
<div class="grid-row"> <div class="grid-row">
{% set extra_class = "extra-tracking" 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 %}" aria-live="polite" role="alert">
{% set placeholder_id = "phone number" if form.placeholder_value.label.text == "phone number" else "" %} {{ form.placeholder_value(param_extensions={"classes": ""}) }}
<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> </div>
{% if skip_link or link_to_upload %} {% if skip_link or link_to_upload %}
<div class="grid-col-12 margin-top-1"> <div class="grid-col-12 margin-top-1">

View File

@@ -29,6 +29,13 @@
</div> </div>
<h2 class="font-body-lg">Your file needs to look like this example</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"> <div class="spreadsheet" data-module="fullscreen-table">
{% call(item, row_number) list_table( {% call(item, row_number) list_table(
@@ -43,14 +50,10 @@
{% endfor %} {% endfor %}
{% endcall %} {% endcall %}
</div> </div>
<p class="hint">
Save your spreadsheet as a <abbr title="Comma Separated Values">CSV</abbr> file for bulk messaging. It is the most reliable when uploading your contact list. Start by downloading this example for your message template.
</p>
<p class="table-show-more-link"> <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>) <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>
<img class="margin-left-05" src="{{ asset_url('img/material-icons/download.svg') }}" alt="" />
</a>
</p> </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> <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 }} {{ template|string }}

View File

@@ -24,7 +24,7 @@
label='Search by name', label='Search by name',
autofocus=True autofocus=True
) }} ) }}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{% if has_organizations %} {% if has_organizations %}
{{ form.organizations }} {{ form.organizations }}
{{ sticky_page_footer('Save') }} {{ sticky_page_footer('Save') }}

View File

@@ -14,7 +14,7 @@
{% block maincolumn_content %} {% block maincolumn_content %}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ page_header('Free text message allowance') }} {{ page_header('Free text message allowance') }}
{{ form.free_sms_allowance }} {{ form.free_sms_allowance }}
{{ page_footer('Save') }} {{ page_footer('Save') }}

View File

@@ -24,7 +24,7 @@
See <a class="usa-link" href="{{ url_for(".pricing") }}">pricing</a> for the list See <a class="usa-link" href="{{ url_for(".pricing") }}">pricing</a> for the list
of rates. of rates.
</p> </p>
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ form.enabled }} {{ form.enabled }}
{{ page_footer('Save') }} {{ page_footer('Save') }}
{% endcall %} {% endcall %}

View File

@@ -14,7 +14,7 @@
{% block maincolumn_content %} {% block maincolumn_content %}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ page_header('Message batch limit') }} {{ page_header('Message batch limit') }}
{{ form.message_limit }} {{ form.message_limit }}
{{ page_footer('Save') }} {{ page_footer('Save') }}

View File

@@ -14,7 +14,7 @@
{% block maincolumn_content %} {% block maincolumn_content %}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ page_header('Rate limit') }} {{ page_header('Rate limit') }}
{{ form.rate_limit }} {{ form.rate_limit }}
{{ page_footer('Save') }} {{ page_footer('Save') }}

View File

@@ -15,7 +15,7 @@
<div class="grid-row"> <div class="grid-row">
<div class="grid-col-10"> <div class="grid-col-10">
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ form.enabled(param_extensions={ {{ form.enabled(param_extensions={
"fieldset": { "fieldset": {
"legend": { "legend": {

View File

@@ -20,7 +20,12 @@
<p> <p>
You may send up to 250,000 text messages during the pilot period. You may send up to 250,000 text messages during the pilot period.
</p> </p>
{% call form_wrapper(data_force_focus=True) %} <!--<p>
You have a free allowance of
{{ '{:,}'.format(current_service.free_sms_fragment_limit) }} text messages each
financial year.
</p>-->
{% call form_wrapper() %}
{{ form.enabled }} {{ form.enabled }}
{{ page_footer('Save') }} {{ page_footer('Save') }}
{% endcall %} {% endcall %}

View File

@@ -16,7 +16,7 @@
{{ page_header('Start text messages with service name') }} {{ page_header('Start text messages with service name') }}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ form.enabled }} {{ form.enabled }}
{{ page_footer('Save') }} {{ page_footer('Save') }}
{% endcall %} {% endcall %}

View File

@@ -16,7 +16,7 @@
{{ page_header('Add text message sender') }} {{ page_header('Add text message sender') }}
{% call form_wrapper(data_force_focus=True) %} {% call form_wrapper() %}
{{ form.sms_sender(param_extensions={ {{ form.sms_sender(param_extensions={
"hint": {"text": "Up to 11 characters, letters, numbers and spaces only"} "hint": {"text": "Up to 11 characters, letters, numbers and spaces only"}
}) }} }) }}

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