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

    -

    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 %} + {{ item.alt_text }} + {% 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()