diff --git a/.ds.baseline b/.ds.baseline
index c64d22627..56c3afc7d 100644
--- a/.ds.baseline
+++ b/.ds.baseline
@@ -161,7 +161,7 @@
"filename": "app/config.py",
"hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc",
"is_verified": false,
- "line_number": 125,
+ "line_number": 123,
"is_secret": false
}
],
@@ -684,5 +684,5 @@
}
]
},
- "generated_at": "2024-11-14T15:53:44Z"
+ "generated_at": "2024-11-21T23:08:45Z"
}
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index f002bb3fc..c3ef5dcbb 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -144,6 +144,7 @@ jobs:
inputs: requirements.txt
ignore-vulns: |
PYSEC-2024-60
+ PYSEC-2022-43162
- name: Run npm audit
run: make npm-audit
diff --git a/app/assets/images/alarm.svg b/app/assets/images/alarm.svg
new file mode 100644
index 000000000..57e6180bc
--- /dev/null
+++ b/app/assets/images/alarm.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/alert.svg b/app/assets/images/alert.svg
new file mode 100644
index 000000000..d0d516d8f
--- /dev/null
+++ b/app/assets/images/alert.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/calendar.svg b/app/assets/images/calendar.svg
new file mode 100644
index 000000000..9b755e9fd
--- /dev/null
+++ b/app/assets/images/calendar.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss
index da5d77bf2..cec48e82b 100644
--- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss
+++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss
@@ -898,7 +898,7 @@ li.linked-card:hover svg,
display: block;
}
-.about-icon-list {
+.icon-list {
display: flex;
width: 24px;
height: 24px;
@@ -908,10 +908,6 @@ li.linked-card:hover svg,
margin-right: 4px;
}
-.usa-icon-list__content{
- padding-left: 0;
-}
-
.indented-paragraph {
margin-left: calc(24px + 4px);
margin-top: 4px;
diff --git a/app/config.py b/app/config.py
index f40b46dea..146230047 100644
--- a/app/config.py
+++ b/app/config.py
@@ -91,9 +91,7 @@ class Config(object):
getenv("FEATURE_BEST_PRACTICES_ENABLED", "false") == "true"
)
- FEATURE_ABOUT_PAGE_ENABLED = (
- getenv("FEATURE_ABOUT_PAGE_ENABLED", "false") == "true"
- )
+ FEATURE_ABOUT_PAGE_ENABLED = getenv("FEATURE_ABOUT_PAGE_ENABLED", "false") == "true"
def _s3_credentials_from_env(bucket_prefix):
diff --git a/app/main/views/index.py b/app/main/views/index.py
index c172ab74e..81e9de85d 100644
--- a/app/main/views/index.py
+++ b/app/main/views/index.py
@@ -1,4 +1,9 @@
-from flask import abort, current_app, redirect, render_template, request, url_for
+from flask import abort, current_app, jsonify, redirect, render_template, request, url_for
+
+import os
+import secrets
+from urllib.parse import unquote
+
from flask_login import current_user
from app import status_api_client
@@ -17,19 +22,28 @@ from app.utils.user import user_is_logged_in
# Hook to check for feature flags
@main.before_request
def check_feature_flags():
- if (
- request.path.startswith("/guides/best-practices")
- and not current_app.config.get("FEATURE_BEST_PRACTICES_ENABLED", False)
+ if request.path.startswith("/guides/best-practices") and not current_app.config.get(
+ "FEATURE_BEST_PRACTICES_ENABLED", False
):
abort(404)
- if (
- request.path.startswith("/about")
- and not current_app.config.get("FEATURE_ABOUT_PAGE_ENABLED", False)
+ if request.path.startswith("/about") and not current_app.config.get(
+ "FEATURE_ABOUT_PAGE_ENABLED", False
):
abort(404)
+@main.route("/test/feature-flags")
+def test_feature_flags():
+ return jsonify(
+ {
+ "FEATURE_BEST_PRACTICES_ENABLED": current_app.config[
+ "FEATURE_BEST_PRACTICES_ENABLED"
+ ]
+ }
+ )
+
+
@main.route("/")
def index():
if current_user and current_user.is_authenticated:
@@ -249,6 +263,7 @@ def benchmark_performance():
)
+@main.route("/using-notify/guidance")
@main.route("/guides/using-notify/guidance")
@user_is_logged_in
def guidance_index():
@@ -269,6 +284,22 @@ def about_notify():
)
+@main.route("/about/security")
+def about_security():
+ return render_template(
+ "views/about/security.html",
+ navigation_links=about_notify_nav(),
+ )
+
+
+@main.route("/about/why-text-messaging")
+def why_text_messaging():
+ return render_template(
+ "views/about/why-text-messaging.html",
+ navigation_links=about_notify_nav(),
+ )
+
+
@main.route("/using-notify/guidance/create-and-send-messages")
@user_is_logged_in
def create_and_send_messages():
diff --git a/app/main/views/sub_navigation_dictionaries.py b/app/main/views/sub_navigation_dictionaries.py
index b9fb7f8ae..3c81dc7ed 100644
--- a/app/main/views/sub_navigation_dictionaries.py
+++ b/app/main/views/sub_navigation_dictionaries.py
@@ -110,7 +110,31 @@ def best_practices_nav():
def about_notify_nav():
return [
{
- "name": "About notify",
+ "name": "About Notify",
"link": "main.about_notify",
+ "sub_navigation_items": [
+ {
+ "name": "Why text messaging",
+ "link": "main.why_text_messaging",
+ "sub_sub_navigation_items": [
+ {
+ "name": "Reach people using a common method",
+ "link": "main.why_text_messaging#reach-people-using-a-common-method",
+ },
+ {
+ "name": "Improve customer experience",
+ "link": "main.why_text_messaging#improve-customer-experience",
+ },
+ {
+ "name": "What texting is best for",
+ "link": "main.why_text_messaging#what-texting-is-best-for",
+ },
+ ],
+ },
+ {
+ "name": "Security",
+ "link": "main.about_security",
+ },
+ ],
},
]
diff --git a/app/navigation.py b/app/navigation.py
index a02df484d..271d6848b 100644
--- a/app/navigation.py
+++ b/app/navigation.py
@@ -53,7 +53,7 @@ class HeaderNavigation(Navigation):
"establish_trust",
"write_for_action",
"multiple_languages",
- "benchmark_performance"
+ "benchmark_performance",
},
"using_notify": {
"get_started",
diff --git a/app/templates/base.html b/app/templates/base.html
index 369e62b81..b2b6639e9 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -66,7 +66,20 @@
diff --git a/app/templates/components/folder-path.html b/app/templates/components/folder-path.html
index c686702ba..b6615a2da 100644
--- a/app/templates/components/folder-path.html
+++ b/app/templates/components/folder-path.html
@@ -22,7 +22,7 @@
{{ folder.name }}
{% endif %}
{% else %}
- Templates
+ Templates
{% endif %}
{% if not loop.last %}{{ folder_path_separator() }}{% endif %}
{% endif %}
diff --git a/app/templates/partials/jobs/notifications.html b/app/templates/partials/jobs/notifications.html
index 379a8efef..6df3085ee 100644
--- a/app/templates/partials/jobs/notifications.html
+++ b/app/templates/partials/jobs/notifications.html
@@ -2,7 +2,7 @@
{% from "components/page-footer.html" import page_footer %}
{% from "components/form.html" import form_wrapper %}
-
+
{% if job.scheduled %}
diff --git a/app/templates/views/about/about.html b/app/templates/views/about/about.html
index 39dfce671..cd52709c2 100644
--- a/app/templates/views/about/about.html
+++ b/app/templates/views/about/about.html
@@ -1,6 +1,6 @@
{% extends "base.html" %}
-{% set page_title = "About notify" %}
+{% set page_title = "About Notify" %}
{% block per_page_title %}
{{page_title}}
@@ -17,8 +17,9 @@
Meet people where they are
More effectively deliver program outcomes
Save administrative costs
-
Implement 21st Century IDEA and other directives
+
Implement 21st Century
+ IDEA and other directives
Notify.gov is an easy-to-use, web-based platform. It requires no technical expertise or system integration โ users
can create an account and get started within minutes. We take the security and privacy of messaging data seriously
@@ -56,9 +57,9 @@
{% for item in product_highlights %}
-
+
-
+
{{item.card_heading}}
@@ -68,10 +69,8 @@
{% endfor %}
- See if Notify is right for you
- Notify.gov is a product of the Public Benefits Studio , a product accelerator inside
+
See if Notify is right for you
+ Notify.gov is a product of the Public Benefits Studio , a product accelerator inside
the federal government.
-
-
{% endblock %}
diff --git a/app/templates/views/about/security.html b/app/templates/views/about/security.html
new file mode 100644
index 000000000..9ebc0420f
--- /dev/null
+++ b/app/templates/views/about/security.html
@@ -0,0 +1,66 @@
+{% extends "base.html" %}
+
+{% set page_title = "Security" %}
+
+{% block per_page_title %}
+{{page_title}}
+{% endblock %}
+
+{% block content_column_content %}
+
+
+ {{page_title}}
+ Notify.gov is built for the needs of government agencies with fundamental system
+ security processes in place to:
+
+
+ protect user data
+ keep systems secure
+ manage risks around information
+
+
+ Notify.gov operates under a full three-year Authority-to-Operate (ATO) . This
+ federal security authorization process leverages security
+ controls provided by National Institute of Standards and Technology (NIST).
+
+
+
+ Our infrastructure runs on cloud.gov and utilizes several
+ services through Amazon Web
+ Services (AWS), including
+ AWS SNS for sending SMS
+ messages.
+
+ For more information about the Notify.gov infrastructure, contact us at notify-support@gsa.gov .
+ Data
+
+ On Notify.gov, data is encrypted both in transit and at rest. To send a message, agencies upload a spreadsheet of
+ phone numbers and other necessary data from their existing data management system.
+
+
+ Notify.gov is not a system of record, so it does not have a System of Records Notice (SORN). Agencies are
+ responsible for managing their data outside of Notify.gov.
+
+ Data retention
+
+ Any data uploads that have recipient data are held for seven calendar days; personally identifiable information
+ (PII) is never stored in Notifyโs database.
+
+ Multi-Factor Authentication
+
+ Notify.gov uses Login.gov for enhanced security.
+ Login.gov is an extra layer of security created by the government that uses multi-factor authentication and stronger
+ passwords to protect your account.
+
+
+ To access Notify.gov, users will use a Login.gov account associated with their agency (.gov) email with one of the
+ multi-factor authentication
+ methods offered through Login.gov.
+
+
+{% endblock %}
diff --git a/app/templates/views/about/why-text-messaging.html b/app/templates/views/about/why-text-messaging.html
new file mode 100644
index 000000000..6aa0887cb
--- /dev/null
+++ b/app/templates/views/about/why-text-messaging.html
@@ -0,0 +1,86 @@
+{% extends "base.html" %}
+{% set page_title = "Why text messaging" %}
+
+{% block per_page_title %}
+{{page_title}}
+{% endblock %}
+
+{% block content_column_content %}
+
+
+
+ {{page_title}}
+ Reach people using a common method
+
+ Confusing or unreceived notifications are one of the largest barriers to people getting and keeping
+ benefits. The typical ways the government communicates with people often fall short. Low income households are more
+ likely to experience housing instability, which means paper mail, already slow, can easily be missed.
+
+
+
+ Pew Research shows that nearly all adults in the US have a cell phone. Reliance on smartphones
+ for online access is especially common among Americans with lower household incomes and those with lower levels of
+ formal education. Of those earning less than $30,000 a year, 28% say their mobile phone is the sole method to
+ digitally connect.
+
+
+ This means that for many people who rely on government services, cell phones may be the most reliable place to meet
+ people where they already are.
+
+ Improve customer experience
+
+ Text messages can deliver concise information and drive an audience to take action quickly. Timely reminders sent
+ via text message have been proven to decrease re-enrollment churn and save money for administering agencies.
+
+
+ Texting not only helps programs reach people using a nearly-universal communication method, it is a cost effective
+ way to do so. With Notify.gov you can get started for free , allowing you to try out
+ texting to complement your existing communications and outreach strategies.
+
+ What texting is best for
+
+ Agencies, like you, are already using Notify.gov to text about the following programs.
+
+ {% set card_contents = [
+ {
+ "image_src": asset_url('images/calendar.svg'),
+ "card_heading": "Reminders",
+ "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.",
+ "alt_text": "reminder text example"
+ },
+ {
+ "image_src": asset_url('images/alert.svg'),
+ "card_heading": "Alerts to take action",
+ "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โฆ",
+ "alt_text": "alerts text example"
+ },
+ {
+ "image_src": asset_url('images/alarm.svg'),
+ "card_heading": "Important status updates",
+ "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โฆ",
+ "alt_text": "status update text example"
+ },
+ ] %}
+ {% for item in card_contents %}
+
+
+
+
{{item.card_heading}}
+
{{item.p_text}}
+
+ {% if item.image_src %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+{% endblock %}
diff --git a/app/utils/csv.py b/app/utils/csv.py
index 4ed6d16b5..79e3535c8 100644
--- a/app/utils/csv.py
+++ b/app/utils/csv.py
@@ -103,6 +103,7 @@ def generate_notifications_csv(**kwargs):
"Carrier Response",
"Status",
"Time",
+ "Carrier",
]
for header in original_column_headers:
if header.lower() != "phone number":
@@ -118,6 +119,7 @@ def generate_notifications_csv(**kwargs):
"Carrier Response",
"Status",
"Time",
+ "Carrier",
]
yield ",".join(fieldnames) + "\n"
@@ -140,6 +142,7 @@ def generate_notifications_csv(**kwargs):
notification["provider_response"],
notification["status"],
preferred_tz_created_at,
+ notification["carrier"],
]
for header in original_column_headers:
if header.lower() != "phone number":
@@ -158,6 +161,7 @@ def generate_notifications_csv(**kwargs):
notification["provider_response"],
notification["status"],
preferred_tz_created_at,
+ notification["carrier"],
]
yield Spreadsheet.from_rows([map(str, values)]).as_csv_data
diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py
index 15be17081..f469f2586 100644
--- a/tests/app/test_navigation.py
+++ b/tests/app/test_navigation.py
@@ -18,6 +18,7 @@ EXCLUDED_ENDPOINTS = tuple(
Navigation.get_endpoint_with_blueprint,
{
"about_notify",
+ "about_security",
"accept_invite",
"accept_org_invite",
"accessibility_statement",
@@ -218,6 +219,7 @@ EXCLUDED_ENDPOINTS = tuple(
"suspend_service",
"template_history",
"template_usage",
+ "test_feature_flags",
"tour_step",
"trial_mode",
"trial_mode_new",
@@ -257,6 +259,7 @@ EXCLUDED_ENDPOINTS = tuple(
"view_template_version",
"view_template_versions",
"who_its_for",
+ "why_text_messaging",
"write_for_action",
},
)
diff --git a/tests/app/utils/test_csv.py b/tests/app/utils/test_csv.py
index d603fcd0e..db4b6a0ec 100644
--- a/tests/app/utils/test_csv.py
+++ b/tests/app/utils/test_csv.py
@@ -58,6 +58,7 @@ def _get_notifications_csv(
"to": recipient,
"recipient": recipient,
"client_reference": "ref 1234",
+ "carrier": "AT&T Mobility",
}
for i in range(rows)
],
@@ -88,15 +89,15 @@ def get_notifications_csv_mock(
(
None,
[
- "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time\n",
- "8005555555,foo,,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern\r\n",
+ "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n",
+ "8005555555,foo,,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern,AT&T Mobility\r\n",
],
),
(
"Anne Example",
[
- "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time\n",
- "8005555555,foo,Anne Example,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern\r\n", # noqa
+ "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n",
+ "8005555555,foo,Anne Example,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern,AT&T Mobility\r\n", # noqa
],
),
],
@@ -135,6 +136,7 @@ def test_generate_notifications_csv_without_job(
"Carrier Response",
"Status",
"Time",
+ "Carrier",
],
[
"8005555555",
@@ -144,6 +146,7 @@ def test_generate_notifications_csv_without_job(
"Did not like it",
"Delivered",
"1943-04-19 08:00:00 AM US/Eastern",
+ "AT&T Mobility",
],
),
(
@@ -159,6 +162,7 @@ def test_generate_notifications_csv_without_job(
"Carrier Response",
"Status",
"Time",
+ "Carrier",
"a",
"b",
"c",
@@ -171,6 +175,7 @@ def test_generate_notifications_csv_without_job(
"Did not like it",
"Delivered",
"1943-04-19 08:00:00 AM US/Eastern",
+ "AT&T Mobility",
"๐",
"๐",
"๐ฆ",
@@ -189,6 +194,7 @@ def test_generate_notifications_csv_without_job(
"Carrier Response",
"Status",
"Time",
+ "Carrier",
"a",
"b",
"c",
@@ -201,6 +207,7 @@ def test_generate_notifications_csv_without_job(
"Did not like it",
"Delivered",
"1943-04-19 08:00:00 AM US/Eastern",
+ "AT&T Mobility",
"๐,๐",
"๐,๐",
"๐ฆ",
diff --git a/tests/end_to_end/test_best_practices_content_pages.py b/tests/end_to_end/test_best_practices_content_pages.py
new file mode 100644
index 000000000..962100de3
--- /dev/null
+++ b/tests/end_to_end/test_best_practices_content_pages.py
@@ -0,0 +1,72 @@
+import os
+import re
+
+from playwright.sync_api import expect
+
+from tests.end_to_end.conftest import check_axe_report
+
+E2E_TEST_URI = os.getenv("NOTIFY_E2E_TEST_URI")
+
+
+def test_best_practices_side_menu(authenticated_page):
+ page = authenticated_page
+
+ page.goto(f"{E2E_TEST_URI}/best-practices")
+
+ page.wait_for_load_state("domcontentloaded")
+ check_axe_report(page)
+
+ response = page.request.get(f"{E2E_TEST_URI}/test/feature-flags")
+ feature_flags = response.json()
+ feature_best_practices_enabled = feature_flags.get("FEATURE_BEST_PRACTICES_ENABLED")
+
+ if feature_best_practices_enabled:
+ page.get_by_role("link", name="Best Practices").click()
+ expect(page).to_have_title(re.compile("Best Practice"))
+
+ page.get_by_role("link", name="Clear goals", exact=True).click()
+ expect(page).to_have_title(re.compile("Establish clear goals"))
+
+ page.get_by_role("link", name="Rules and regulations").click()
+ expect(page).to_have_title(re.compile("Rules and regulations"))
+
+ page.get_by_role("link", name="Establish trust").click()
+ expect(page).to_have_title(re.compile("Establish trust"))
+
+ page.get_by_role("link", name="Write for action").click()
+ expect(page).to_have_title(re.compile("Write texts that provoke"))
+
+ page.get_by_role("link", name="Multiple languages").click()
+ expect(page).to_have_title(re.compile("Text in multiple languages"))
+
+ page.get_by_role("link", name="Benchmark performance").click()
+ expect(page).to_have_title(re.compile("Measuring performance with"))
+
+ parent_link = page.get_by_role("link", name="Establish trust")
+ parent_link.hover()
+
+ submenu_item = page.get_by_role("link", name=re.compile("Get the word out"))
+ submenu_item.click()
+
+ expect(page).to_have_url(re.compile(r"#get-the-word-out"))
+
+ anchor_target = page.locator("#get-the-word-out")
+ expect(anchor_target).to_be_visible()
+ anchor_target.click()
+
+
+def test_breadcrumbs_best_practices(authenticated_page):
+ page = authenticated_page
+
+ page.goto(f"{E2E_TEST_URI}/best-practices")
+
+ page.wait_for_load_state("domcontentloaded")
+ check_axe_report(page)
+
+ response = page.request.get(f"{E2E_TEST_URI}/test/feature-flags")
+ feature_flags = response.json()
+ feature_best_practices_enabled = feature_flags.get("FEATURE_BEST_PRACTICES_ENABLED")
+
+ if feature_best_practices_enabled:
+ page.get_by_role("link", name="Clear goals", exact=True).click()
+ page.locator("ol").get_by_role("link", name="Best Practices").click()