Merge branch 'main' of https://github.com/GSA/notifications-admin into e2e-test-invite-to-service

This commit is contained in:
Andrew Shumway
2024-04-15 10:36:36 -06:00
23 changed files with 695 additions and 55 deletions

View File

@@ -82,7 +82,7 @@ dead-code:
.PHONY: e2e-test .PHONY: e2e-test
e2e-test: export NEW_RELIC_ENVIRONMENT=test e2e-test: export NEW_RELIC_ENVIRONMENT=test
e2e-test: ## Run end-to-end integration tests; note that --browser webkit isn't currently working e2e-test: ## Run end-to-end integration tests; note that --browser webkit isn't currently working
poetry run pytest -v --browser chromium --browser firefox tests/end_to_end poetry run pytest -vv --browser chromium --browser firefox tests/end_to_end
.PHONY: js-lint .PHONY: js-lint
js-lint: ## Run javascript linting scanners js-lint: ## Run javascript linting scanners

View File

@@ -52,6 +52,7 @@ from app.formatters import (
format_datetime_human, format_datetime_human,
format_datetime_normal, format_datetime_normal,
format_datetime_relative, format_datetime_relative,
format_datetime_scheduled_notification,
format_datetime_table, format_datetime_table,
format_day_of_week, format_day_of_week,
format_delta, format_delta,
@@ -551,6 +552,7 @@ def add_template_filters(application):
format_datetime, format_datetime,
format_datetime_24h, format_datetime_24h,
format_datetime_normal, format_datetime_normal,
format_datetime_scheduled_notification,
format_datetime_table, format_datetime_table,
valid_phone_number, valid_phone_number,
linkable_name, linkable_name,

View File

@@ -22,7 +22,7 @@ from notifications_utils.recipients import InvalidPhoneError, validate_phone_num
from notifications_utils.take import Take from notifications_utils.take import Take
from app.utils.csv import get_user_preferred_timezone from app.utils.csv import get_user_preferred_timezone
from app.utils.time import parse_dt, parse_naive_dt from app.utils.time import parse_naive_dt
def apply_html_class(tags, html_file): def apply_html_class(tags, html_file):
@@ -93,16 +93,34 @@ def format_datetime_normal(date):
) )
def format_datetime_scheduled_notification(date):
# e.g. April 09, 2024 at 04:00 PM US/Eastern.
# Everything except scheduled notifications, the time is always "now".
# Scheduled notifications are the exception to the rule.
# Here we are formating and displaying the datetime without converting datetime to a different timezone.
datetime_obj = parse_naive_dt(date)
format_time_without_tz = datetime_obj.replace(tzinfo=timezone.utc).strftime(
"%I:%M %p"
)
return "{} at {} {}".format(
format_date_normal(date), format_time_without_tz, get_user_preferred_timezone()
)
def format_datetime_table(date): def format_datetime_table(date):
# example: 03-18-2024 at 04:53 PM, intended for datetimes in tables # example: 03-18-2024 at 04:53 PM, intended for datetimes in tables
return "{} at {}".format(format_date_numeric(date), format_time_12h(date)) return "{} at {}".format(format_date_numeric(date), format_time_12h(date))
def format_time_12h(date): def format_time_12h(date):
date = parse_dt(date) date = parse_naive_dt(date)
preferred_tz = pytz.timezone(get_user_preferred_timezone()) preferred_tz = pytz.timezone(get_user_preferred_timezone())
return date.astimezone(preferred_tz).strftime("%I:%M %p") return (
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p")
)
def format_datetime_relative(date): def format_datetime_relative(date):

View File

@@ -662,7 +662,21 @@ def create_global_stats(services):
"email": {"delivered": 0, "failed": 0, "requested": 0}, "email": {"delivered": 0, "failed": 0, "requested": 0},
"sms": {"delivered": 0, "failed": 0, "requested": 0}, "sms": {"delivered": 0, "failed": 0, "requested": 0},
} }
# Issue #1323. The back end is now sending 'failure' instead of
# 'failed'. Adjust it here, but keep it flexible in case
# the backend reverts to 'failed'.
for service in services: for service in services:
if service["statistics"]["sms"].get("failure") is not None:
service["statistics"]["sms"]["failed"] = service["statistics"]["sms"][
"failure"
]
if service["statistics"]["email"].get("failure") is not None:
service["statistics"]["email"]["failed"] = service["statistics"]["email"][
"failure"
]
for service in services:
for msg_type, status in itertools.product( for msg_type, status in itertools.product(
("sms", "email"), ("delivered", "failed", "requested") ("sms", "email"), ("delivered", "failed", "requested")
): ):

View File

@@ -650,7 +650,9 @@ def check_messages(service_id, template_id, upload_id, row_index=2):
@user_has_permissions("send_messages", restrict_admin_usage=True) @user_has_permissions("send_messages", restrict_admin_usage=True)
def preview_job(service_id, template_id, upload_id, row_index=2): def preview_job(service_id, template_id, upload_id, row_index=2):
session["scheduled_for"] = request.form.get("scheduled_for", "") session["scheduled_for"] = request.form.get("scheduled_for", "")
data = _check_messages(service_id, template_id, upload_id, row_index, force_hide_sender=True) data = _check_messages(
service_id, template_id, upload_id, row_index, force_hide_sender=True
)
return render_template( return render_template(
"views/check/preview.html", "views/check/preview.html",
@@ -911,7 +913,9 @@ def preview_notification(service_id, template_id):
return render_template( return render_template(
"views/notifications/preview.html", "views/notifications/preview.html",
**_check_notification(service_id, template_id, show_recipient=False, force_hide_sender=True), **_check_notification(
service_id, template_id, show_recipient=False, force_hide_sender=True
),
scheduled_for=session["scheduled_for"], scheduled_for=session["scheduled_for"],
recipient=recipient, recipient=recipient,
) )

View File

@@ -63,6 +63,10 @@ def _get_access_token(code, state):
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code" url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
headers = {"Authorization": "Bearer %s" % token} headers = {"Authorization": "Bearer %s" % token}
response = requests.post(url, headers=headers) response = requests.post(url, headers=headers)
if response.json().get("access_token") is None:
# Capture the response json here so it hopefully shows up in error reports
current_app.logger.error(f"Error when getting access token {response.json()}")
raise KeyError(f"'access_token' {response.json()}")
access_token = response.json()["access_token"] access_token = response.json()["access_token"]
return access_token return access_token
@@ -84,13 +88,17 @@ def _do_login_dot_gov():
code = request.args.get("code") code = request.args.get("code")
state = request.args.get("state") state = request.args.get("state")
login_gov_error = request.args.get("error") login_gov_error = request.args.get("error")
if code and state:
access_token = _get_access_token(code, state) if login_gov_error:
user_email, user_uuid = _get_user_email_and_uuid(access_token) current_app.logger.error(f"login.gov error: {login_gov_error}")
redirect_url = request.args.get("next") raise Exception(f"Could not login with login.gov {login_gov_error}")
elif code and state:
# activate the user # activate the user
try: try:
access_token = _get_access_token(code, state)
user_email, user_uuid = _get_user_email_and_uuid(access_token)
redirect_url = request.args.get("next")
user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email)
activate_user(user["id"]) activate_user(user["id"])
except BaseException as be: # noqa B036 except BaseException as be: # noqa B036
@@ -99,9 +107,6 @@ def _do_login_dot_gov():
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
elif login_gov_error:
current_app.logger.error(f"login.gov error: {login_gov_error}")
raise Exception(f"Could not login with login.gov {login_gov_error}")
# end login.gov # end login.gov

View File

@@ -0,0 +1,30 @@
{% if help %}
{% include 'partials/tour.html' %}
{% else %}
<nav class="nav margin-bottom-4">
<a class="usa-button margin-top-1 margin-bottom-5 width-full"
href="{{ url_for('.choose_template', service_id=current_service.id) }}">Send messages</a>
<ul class="usa-sidenav">
{% if current_user.has_permissions() %}
{% if current_user.has_permissions('view_activity') %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('dashboard') }}" href="{{ url_for('.service_dashboard', service_id=current_service.id) }}">Dashboard</a></li>
{% endif %}
{% if not current_user.has_permissions('view_activity') %}
<li class="usa-sidenav__item"><a class="{{ casework_navigation.is_selected('sent-messages') }}" href="{{ url_for('.view_notifications', service_id=current_service.id, status='sending,delivered,failed') }}">Sent messages</a></li>
{% endif %}
{% if current_user.has_permissions('manage_service', allow_org_user=True) %}
{# <li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('usage') }}" href="{{ url_for('.usage', service_id=current_service.id) }}">Usage</a></li> #}
{% endif %}
<!-- {% if current_user.has_permissions('manage_api_keys', 'manage_service') %}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('settings') }}" href="{{ url_for('.service_settings', service_id=current_service.id) }}">Settings</a></li>
{% endif %} -->
{% if current_user.has_permissions('manage_api_keys') %}
<!-- <li><a class="usa-link{{ main_navigation.is_selected('api-integration') }}" href="{{ url_for('.api_integration', service_id=current_service.id) }}">API integration</a></li> -->
{% endif %}
{% elif current_user.has_permissions(allow_org_user=True) %}
<li class="usa-sidenav__item"><a class="usa-link{{ main_navigation.is_selected('usage') }}" href="{{ url_for('.usage', service_id=current_service.id) }}">Usage</a></li>
<li class="usa-sidenav__item"><a class="usa-link{{ main_navigation.is_selected('team-members') }}" href="{{ url_for('.manage_users', service_id=current_service.id) }}">Team members</a></li>
{% endif %}
</ul>
</nav>
{% endif %}

View File

@@ -0,0 +1,11 @@
<nav class="navigation">
<ul>
<li><a class="usa-link{{ org_navigation.is_selected('dashboard') }}" href="{{ url_for('.organization_dashboard', org_id=current_org.id) }}">Usage</a></li>
<li><a class="usa-link{{ org_navigation.is_selected('team-members') }}" href="{{ url_for('.manage_org_users', org_id=current_org.id) }}">Team members</a></li>
{% if current_user.platform_admin %}
<li><a class="usa-link{{ org_navigation.is_selected('settings') }}" href="{{ url_for('.organization_settings', org_id=current_org.id) }}">Settings</a></li>
<li><a class="usa-link{{ org_navigation.is_selected('trial-services') }}" href="{{ url_for('.organization_trial_mode_services', org_id=current_org.id) }}">Trial mode services</a></li>
<li><a class="usa-link{{ org_navigation.is_selected('billing') }}" href="{{ url_for('.organization_billing', org_id=current_org.id) }}">Billing</a></li>
{% endif %}
</ul>
</nav>

View File

@@ -0,0 +1,15 @@
<div class="navigation-service margin-top-5 display-flex flex-align-end flex-justify border-bottom padding-bottom-1">
{% if current_service.organization_id %}
{% if current_user.platform_admin or
(current_user.belongs_to_organization(current_service.organization_id) and current_service.live) %}
<a href="{{ url_for('.organization_dashboard', org_id=current_service.organization_id) }}" class="usa-link navigation-organization-link">{{ current_service.organization_name }}</a>
{% endif %}
{% endif %}
<div class="font-body-2xl text-bold">
{{ current_service.name }}
{% if not current_service.active %}
<span class="navigation-service-name navigation-service-type--suspended">Suspended</span>
{% endif %}
</div>
<a href="{{ url_for('main.choose_account') }}" class="usa-link">Switch service</a>
</div>

View File

@@ -0,0 +1,16 @@
{% if help %}
{% include 'partials/tour.html' %}
{% else %}
<nav class="nav">
<ul class="usa-sidenav">
{# {% if current_user.has_permissions() %} #}
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('settings') }}"
href="{{ url_for('main.service_settings', service_id=current_service.id) }}">General</a></li>
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('user-profile') }}"
href="{{ url_for('main.user_profile', service_id=current_service.id) }}">User profile</a></li>
<li class="usa-sidenav__item"><a class="{{ main_navigation.is_selected('team-members') }}"
href="{{ url_for('main.manage_users', service_id=current_service.id) }}">Team members</a></li>
{# {% endif %} #}
</ul>
</nav>
{% endif %}

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "/new/base.html" %}
{% block per_page_title %} {% block per_page_title %}
{% block org_page_title %}{% endblock %} {{ current_org.name }} {% block org_page_title %}{% endblock %} {{ current_org.name }}
@@ -17,7 +17,7 @@
</div> </div>
<div class="grid-row"> <div class="grid-row">
<div class="grid-col-3"> <div class="grid-col-3">
{% include "org_nav.html" %} {% include "/new/components/org_nav.html" %}
</div> </div>
<div class="grid-col-9"> <div class="grid-col-9">
{% block beforeContent %} {% block beforeContent %}

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "/new/base.html" %}
{% block per_page_title %} {% block per_page_title %}
{% block service_page_title %}{% endblock %} {{ current_service.name }} {% block service_page_title %}{% endblock %} {{ current_service.name }}
@@ -7,9 +7,9 @@
{% block main %} {% block main %}
<div class="grid-container"> <div class="grid-container">
{% block serviceNavigation %} {% block serviceNavigation %}
{% include "service_navigation.html" %} {% include "new/components/service_navigation.html" %}
{% endblock %} {% endblock %}
<!-- The withnav_template can be used to replace the settings_template. when it comes to setting_template and withnav_template, this service_navigation.html is only used in withnav_template and in settings_template, it was not used. That is one out of the two differences between settings template and withnav template. Child templates that extends settings_template, include the block serviceNavigation but leave it empty. Within the app, the only pages settings_template.html is used: manage-users.html, service-settings.html, and user-profile.html --> <!-- The withnav_template can be used to replace the settings_template. When it comes to setting_template and withnav_template, this service_navigation.html on line 10 is only used in withnav_template. In settings_template, it was not used. That is one out of the two differences between settings template and withnav template. Child templates that extends settings_template, include the block serviceNavigation but leave it empty because we don't need to call in service_navigation. Within the app, the only pages settings_template.html is used: manage-users.html, service-settings.html, and user-profile.html -->
<div class="grid-row margin-top-5"> <div class="grid-row margin-top-5">
{% if help %} {% if help %}
<div class="grid-col-3"> <div class="grid-col-3">
@@ -17,8 +17,8 @@
<div class="grid-col-3"> <div class="grid-col-3">
{% endif %} {% endif %}
{% block sideNavigation %} {% block sideNavigation %}
{% include "main_nav.html" %} {% include "/new/components/main_nav.html" %}
<!-- include settings_nav.html for child templates that used settings_template --> <!-- instead of "main_nav.html" include "settings_nav.html" for child templates that extends settings_template -->
{% endblock %} {% endblock %}
</div> </div>
{% if help %} {% if help %}

View File

@@ -14,6 +14,10 @@ This document serves as a glossary for the templates directory structure of the
- `head.html`: Template for the site's <head>, included in `base.html`. - `head.html`: Template for the site's <head>, included in `base.html`.
- `header.html`: Template for the site's header, included in `base.html`. - `header.html`: Template for the site's header, included in `base.html`.
- `footer.html`: Template for the site's footer, included in `base.html`. - `footer.html`: Template for the site's footer, included in `base.html`.
- `settings_navigation.html`: The settings navigation used in `withnav_template.html` that previously extended `settings_template.html`.
- `org_nav.html`: The organization's navigation used solely in `org_template.html`.
- `main_nav.html`: The main navigation used in `withnav_template.html`
- `service_navigation.html`: The service navigation used in `withnav_template.html`. In withnav_template.html, the `serviceNavigation` block will be left empty in any child templates that previously extended `settings_template.html`.
- **/views** (or **/pages**): Individual page templates that use the base layouts, components, and partials to present content. - **/views** (or **/pages**): Individual page templates that use the base layouts, components, and partials to present content.
### Best Practices ### Best Practices
@@ -30,9 +34,9 @@ This document serves as a glossary for the templates directory structure of the
- withoutnav_template.html Delete - withoutnav_template.html Delete
- main_template.html Delete - main_template.html Delete
- settings_templates.html `withnav_template` can be used to replace `settings_template`. - settings_templates.html `withnav_template` can be used to replace `settings_template`.
- settings_nav.html (move to /new/navigation directory) - settings_nav.html (move to /components/ directory)
- main_nav.html (move to /new/navigation directory) - main_nav.html (move to /components/ directory)
- service_navigation.html (move to /new/navigation directory) - service_navigation.html (move to /components/ directory)
- org_template, could be under it's own directory called /layout/organization - org_template, could be under it's own directory called /layout/organization
- org_nav.html (move to /new/navigation directory) - org_nav.html (move to /components/ directory)
- content_template.html Delete - content_template.html Delete

View File

@@ -21,7 +21,7 @@
{{ page_header('Preview') }} {{ page_header('Preview') }}
<div> <div>
<p class="sms-message-scheduler">Scheduled: {{ scheduled_for |format_datetime_normal if scheduled_for else 'Now'}}</p> <p class="sms-message-scheduler">Scheduled: {{ scheduled_for |format_datetime_scheduled_notification if scheduled_for else 'Now'}}</p>
<p class="sms-message-file-name">File: {{original_file_name}}</p> <p class="sms-message-file-name">File: {{original_file_name}}</p>
<p class="sms-message-template">Template: {{template.name}}</p> <p class="sms-message-template">Template: {{template.name}}</p>
<p class="sms-message-sender" >From: {{ template.sender }}</p> <p class="sms-message-sender" >From: {{ template.sender }}</p>

View File

@@ -43,7 +43,7 @@
{{ page_header('Preview') }} {{ page_header('Preview') }}
{% endif %} {% endif %}
<div> <div>
<p class="sms-message-scheduler">Scheduled: {{ scheduled_for |format_datetime_normal if scheduled_for else 'Now'}}</p> <p class="sms-message-scheduler">Scheduled: {{ scheduled_for |format_datetime_scheduled_notification if scheduled_for else 'Now'}}</p>
<p class="sms-message-template">Template: {{template.name}}</p> <p class="sms-message-template">Template: {{template.name}}</p>
<p class="sms-message-sender" >From: {{ template.sender }}</p> <p class="sms-message-sender" >From: {{ template.sender }}</p>
<p class="sms-message-sender" >To: {{ recipient }}</p> <p class="sms-message-sender" >To: {{ recipient }}</p>

View File

@@ -17,7 +17,7 @@ def get_template(
redact_missing_personalisation=False, redact_missing_personalisation=False,
email_reply_to=None, email_reply_to=None,
sms_sender=None, sms_sender=None,
force_hide_sender=False force_hide_sender=False,
): ):
if "email" == template["template_type"]: if "email" == template["template_type"]:
return EmailPreviewTemplate( return EmailPreviewTemplate(

View File

@@ -22,8 +22,3 @@ def is_less_than_days_ago(date_from_db, number_of_days):
def parse_naive_dt(dt): def parse_naive_dt(dt):
return parser.parse(dt, ignoretz=True) return parser.parse(dt, ignoretz=True)
def parse_dt(dt):
# Parse datetime without ignoring the timezone
return parser.parse(dt)

31
poetry.lock generated
View File

@@ -171,17 +171,17 @@ files = [
[[package]] [[package]]
name = "boto3" name = "boto3"
version = "1.34.79" version = "1.34.83"
description = "The AWS SDK for Python" description = "The AWS SDK for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "boto3-1.34.79-py3-none-any.whl", hash = "sha256:265b0b4865e8c07e27abb32a31d2bd9129bb009b1d89ca0783776ec084886123"}, {file = "boto3-1.34.83-py3-none-any.whl", hash = "sha256:33cf93f6de5176f1188c923f4de1ae149ed723b89ed12e434f2b2f628491769e"},
{file = "boto3-1.34.79.tar.gz", hash = "sha256:139dd2d94eaa0e3213ff37ba7cf4cb2e3823269178fe8f3e33c965f680a9ddde"}, {file = "boto3-1.34.83.tar.gz", hash = "sha256:9733ce811bd82feab506ad9309e375a79cabe8c6149061971c17754ce8997551"},
] ]
[package.dependencies] [package.dependencies]
botocore = ">=1.34.79,<1.35.0" botocore = ">=1.34.83,<1.35.0"
jmespath = ">=0.7.1,<2.0.0" jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0" s3transfer = ">=0.10.0,<0.11.0"
@@ -190,13 +190,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]] [[package]]
name = "botocore" name = "botocore"
version = "1.34.79" version = "1.34.83"
description = "Low-level, data-driven core of boto 3." description = "Low-level, data-driven core of boto 3."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "botocore-1.34.79-py3-none-any.whl", hash = "sha256:a42a014d3dbaa9ef123810592af69f9e55b456c5be3ac9efc037325685519e83"}, {file = "botocore-1.34.83-py3-none-any.whl", hash = "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161"},
{file = "botocore-1.34.79.tar.gz", hash = "sha256:6b59b0f7de219d383a2a633f6718c2600642ebcb707749dc6c67a6a436474b7a"}, {file = "botocore-1.34.83.tar.gz", hash = "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636"},
] ]
[package.dependencies] [package.dependencies]
@@ -1080,13 +1080,13 @@ license = ["ukkonen"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.6" version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
files = [ files = [
{file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
] ]
[[package]] [[package]]
@@ -1516,6 +1516,7 @@ files = [
{file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"},
{file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"},
{file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"},
{file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"},
{file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
] ]
@@ -1602,7 +1603,7 @@ requests = ">=2.0.0"
[[package]] [[package]]
name = "notifications-utils" name = "notifications-utils"
version = "0.4.5" version = "0.4.6"
description = "" description = ""
optional = false optional = false
python-versions = "^3.12.2" python-versions = "^3.12.2"
@@ -1613,8 +1614,8 @@ develop = false
async-timeout = "^4.0.2" async-timeout = "^4.0.2"
bleach = "^6.1.0" bleach = "^6.1.0"
blinker = "^1.6.2" blinker = "^1.6.2"
boto3 = "^1.34.77" boto3 = "^1.34.83"
botocore = "^1.34.79" botocore = "^1.34.83"
cachetools = "^5.3.0" cachetools = "^5.3.0"
certifi = "^2024.2.2" certifi = "^2024.2.2"
cffi = "^1.16.0" cffi = "^1.16.0"
@@ -1625,7 +1626,7 @@ flask = "^2.3.2"
flask-redis = "^0.4.0" flask-redis = "^0.4.0"
geojson = "^3.0.1" geojson = "^3.0.1"
govuk-bank-holidays = "^0.14" govuk-bank-holidays = "^0.14"
idna = "^3.6" idna = "^3.7"
itsdangerous = "^2.1.2" itsdangerous = "^2.1.2"
jinja2 = "^3.1.3" jinja2 = "^3.1.3"
jmespath = "^1.0.1" jmespath = "^1.0.1"
@@ -1654,7 +1655,7 @@ werkzeug = "^3.0.1"
type = "git" type = "git"
url = "https://github.com/GSA/notifications-utils.git" url = "https://github.com/GSA/notifications-utils.git"
reference = "HEAD" reference = "HEAD"
resolved_reference = "7d1d2e9bb3791316231e97433c71da6a70c4d2ab" resolved_reference = "d0db6073406bd160d2007edb9d00e41c9d5d44b7"
[[package]] [[package]]
name = "numpy" name = "numpy"

View File

@@ -48,7 +48,6 @@ def test_form_class_not_mutated(notify_admin):
(False, "phone number", "sms", "2028675309", None), (False, "phone number", "sms", "2028675309", None),
(False, "phone number", "sms", "+1 (202) 867-5309", None), (False, "phone number", "sms", "+1 (202) 867-5309", None),
(True, "phone number", "sms", "+123", "Not enough digits"), (True, "phone number", "sms", "+123", "Not enough digits"),
(True, "phone number", "sms", "+44(0)7900 900-123", None),
(True, "phone number", "sms", "+1-2345-678890", None), (True, "phone number", "sms", "+1-2345-678890", None),
(False, "anything else", "sms", "", "Cannot be empty"), (False, "anything else", "sms", "", "Cannot be empty"),
(False, "anything else", "email", "", "Cannot be empty"), (False, "anything else", "email", "", "Cannot be empty"),

View File

@@ -354,10 +354,9 @@ def test_inbound_messages_shows_count_of_messages_when_there_are_no_messages(
"(202) 867-5300 message-2 1 hour ago", "(202) 867-5300 message-2 1 hour ago",
"(202) 867-5300 message-3 1 hour ago", "(202) 867-5300 message-3 1 hour ago",
"(202) 867-5302 message-4 3 hours ago", "(202) 867-5302 message-4 3 hours ago",
"+33 1 12 34 56 78 message-5 5 hours ago", "+33(0)1 12345678 message-5 5 hours ago",
"(202) 555-0104 message-6 7 hours ago", "(202) 555-0104 message-6 7 hours ago",
"(202) 555-0104 message-7 9 hours ago", "(202) 555-0104 message-7 9 hours ago",
"+682 12345 message-8 9 hours ago",
] ]
), ),
) )
@@ -519,10 +518,10 @@ def test_download_inbox(
"(202) 867-5300,message-2,07-01-2016 10:59 US/Eastern\r\n" "(202) 867-5300,message-2,07-01-2016 10:59 US/Eastern\r\n"
"(202) 867-5300,message-3,07-01-2016 10:59 US/Eastern\r\n" "(202) 867-5300,message-3,07-01-2016 10:59 US/Eastern\r\n"
"(202) 867-5302,message-4,07-01-2016 08:59 US/Eastern\r\n" "(202) 867-5302,message-4,07-01-2016 08:59 US/Eastern\r\n"
"+33 1 12 34 56 78,message-5,07-01-2016 06:59 US/Eastern\r\n" "+33(0)1 12345678,message-5,07-01-2016 06:59 US/Eastern\r\n"
"(202) 555-0104,message-6,07-01-2016 04:59 US/Eastern\r\n" "(202) 555-0104,message-6,07-01-2016 04:59 US/Eastern\r\n"
"(202) 555-0104,message-7,07-01-2016 02:59 US/Eastern\r\n" "(202) 555-0104,message-7,07-01-2016 02:59 US/Eastern\r\n"
"+682 12345,message-8,07-01-2016 02:59 US/Eastern\r\n" "+68212345,message-8,07-01-2016 02:59 US/Eastern\r\n"
) )

View File

@@ -979,7 +979,7 @@ def test_upload_valid_csv_shows_preview_and_table(
'<td class="table-field-left-aligned"> <div> A </div> </td>', '<td class="table-field-left-aligned"> <div> A </div> </td>',
( (
'<td class="table-field-left-aligned"> ' '<td class="table-field-left-aligned"> '
'<div> ' "<div> "
"<ul> " "<ul> "
"<li>foo</li> <li>foo</li> <li>foo</li> " "<li>foo</li> <li>foo</li> <li>foo</li> "
"</ul> " "</ul> "
@@ -992,7 +992,7 @@ def test_upload_valid_csv_shows_preview_and_table(
'<td class="table-field-left-aligned"> <div> B </div> </td>', '<td class="table-field-left-aligned"> <div> B </div> </td>',
( (
'<td class="table-field-left-aligned"> ' '<td class="table-field-left-aligned"> '
'<div> ' "<div> "
"<ul> " "<ul> "
"<li>foo</li> <li>foo</li> <li>foo</li> " "<li>foo</li> <li>foo</li> <li>foo</li> "
"</ul> " "</ul> "
@@ -1005,7 +1005,7 @@ def test_upload_valid_csv_shows_preview_and_table(
'<td class="table-field-left-aligned"> <div> C </div> </td>', '<td class="table-field-left-aligned"> <div> C </div> </td>',
( (
'<td class="table-field-left-aligned"> ' '<td class="table-field-left-aligned"> '
'<div> ' "<div> "
"<ul> " "<ul> "
"<li>foo</li> <li>foo</li> " "<li>foo</li> <li>foo</li> "
"</ul> " "</ul> "

View File

@@ -0,0 +1,188 @@
import datetime
import os
import re
import uuid
from playwright.sync_api import expect
E2E_TEST_URI = os.getenv("NOTIFY_E2E_TEST_URI")
def _setup(page):
# Prepare for adding a new service later in the test.
current_date_time = datetime.datetime.now()
new_service_name = "E2E Federal Test Service {now} - {browser_type}".format(
now=current_date_time.strftime("%m/%d/%Y %H:%M:%S"),
browser_type=page.context.browser.browser_type.name,
)
page.goto(f"{E2E_TEST_URI}/accounts")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check to make sure that we've arrived at the next page.
# Check the page title exists and matches what we expect.
expect(page).to_have_title(re.compile("Choose service"))
# Check for the sign in heading.
sign_in_heading = page.get_by_role("heading", name="Choose service")
expect(sign_in_heading).to_be_visible()
# Retrieve some prominent elements on the page for testing.
add_service_button = page.get_by_role(
"button", name=re.compile("Add a new service")
)
expect(add_service_button).to_be_visible()
existing_service_link = page.get_by_role("link", name=new_service_name)
# Check to see if the service was already created - if so, we should fail.
# TODO: Figure out how to make this truly isolated, and/or work in a
# delete service workflow.
expect(existing_service_link).to_have_count(0)
# Click on add a new service.
add_service_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check for the sign in heading.
about_heading = page.get_by_role("heading", name="About your service")
expect(about_heading).to_be_visible()
# Retrieve some prominent elements on the page for testing.
service_name_input = page.locator('xpath=//input[@name="name"]')
add_service_button = page.get_by_role("button", name=re.compile("Add service"))
expect(service_name_input).to_be_visible()
expect(add_service_button).to_be_visible()
# Fill in the form.
service_name_input.fill(new_service_name)
# Click on add service.
add_service_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check for the service name title and heading.
service_heading = page.get_by_text(new_service_name, exact=True)
expect(service_heading).to_be_visible()
expect(page).to_have_title(re.compile(new_service_name))
return new_service_name
def create_new_template(page):
current_service_link = page.get_by_text("Current service")
expect(current_service_link).to_be_visible()
current_service_link.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
send_messages_button = page.get_by_role("link", name="Send messages")
expect(send_messages_button).to_be_visible()
send_messages_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
create_template_button = page.get_by_role("button", name="New template")
expect(create_template_button).to_be_visible()
create_template_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
start_with_a_blank_template_radio = page.get_by_text("Start with a blank template")
expect(start_with_a_blank_template_radio).to_be_visible()
start_with_a_blank_template_radio.click()
continue_button = page.get_by_role("button", name="Continue")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
template_name_input = page.get_by_text("Template name")
expect(template_name_input).to_be_visible()
template_name = str(uuid.uuid4())
template_name_input.fill(template_name)
message_input = page.get_by_role("textbox", name="Message")
expect(message_input).to_be_visible()
message = "Test message for e2e test"
message_input.fill(message)
save_button = page.get_by_text("Save")
expect(save_button).to_be_visible()
save_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
use_this_template_button = page.get_by_text("Use this template")
expect(use_this_template_button).to_be_visible()
use_this_template_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
use_my_phone_number_link = page.get_by_text("Use my phone number")
expect(use_my_phone_number_link).to_be_visible()
use_my_phone_number_link.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
preview_button = page.get_by_text("Preview")
expect(preview_button).to_be_visible()
preview_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# We are not going to send the message for this test, we just want to confirm
# that the template has been created and we are now seeing the message from the
# template in the preview.
assert "Test message for e2e test" in page.content()
def test_create_new_template(authenticated_page):
page = authenticated_page
_setup(page)
create_new_template(page)
_teardown(page)
def _teardown(page):
page.click("text='Settings'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
page.click("text='Delete this service'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
page.click("text='Yes, delete'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check to make sure that we've arrived at the next page.
# Check the page title exists and matches what we expect.
expect(page).to_have_title(re.compile("Choose service"))

View File

@@ -0,0 +1,339 @@
import datetime
import os
import re
import uuid
from playwright.sync_api import expect
E2E_TEST_URI = os.getenv("NOTIFY_E2E_TEST_URI")
def _setup(page):
# Prepare for adding a new service later in the test.
current_date_time = datetime.datetime.now()
new_service_name = "E2E Federal Test Service {now} - {browser_type}".format(
now=current_date_time.strftime("%m/%d/%Y %H:%M:%S"),
browser_type=page.context.browser.browser_type.name,
)
page.goto(f"{E2E_TEST_URI}/accounts")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check to make sure that we've arrived at the next page.
# Check the page title exists and matches what we expect.
expect(page).to_have_title(re.compile("Choose service"))
# Check for the sign in heading.
sign_in_heading = page.get_by_role("heading", name="Choose service")
expect(sign_in_heading).to_be_visible()
# Retrieve some prominent elements on the page for testing.
add_service_button = page.get_by_role(
"button", name=re.compile("Add a new service")
)
expect(add_service_button).to_be_visible()
existing_service_link = page.get_by_role("link", name=new_service_name)
# Check to see if the service was already created - if so, we should fail.
# TODO: Figure out how to make this truly isolated, and/or work in a
# delete service workflow.
expect(existing_service_link).to_have_count(0)
# Click on add a new service.
add_service_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check for the sign in heading.
about_heading = page.get_by_role("heading", name="About your service")
expect(about_heading).to_be_visible()
# Retrieve some prominent elements on the page for testing.
service_name_input = page.locator('xpath=//input[@name="name"]')
add_service_button = page.get_by_role("button", name=re.compile("Add service"))
expect(service_name_input).to_be_visible()
expect(add_service_button).to_be_visible()
# Fill in the form.
service_name_input.fill(new_service_name)
# Click on add service.
add_service_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check for the service name title and heading.
service_heading = page.get_by_text(new_service_name, exact=True)
expect(service_heading).to_be_visible()
expect(page).to_have_title(re.compile(new_service_name))
return new_service_name
def handle_no_existing_template_case(page):
"""
This is the test path for local development. Developers have their own
databases and typically they have service or two already created, which
means they won't see the "Example text message template" which is done
as an introduction. Instead they will see "Create your first template".
Note that this test goes all the way through sending and downloading the
report and verifying the phone number, etc. in the report. This is because
developer systems are fully connected to AWS.
"""
create_template_button = page.get_by_text("Create your first template")
expect(create_template_button).to_be_visible()
create_template_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
new_template_button = page.get_by_text("New template")
expect(new_template_button).to_be_visible()
new_template_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
start_with_a_blank_template_radio = page.get_by_text("Start with a blank template")
expect(start_with_a_blank_template_radio).to_be_visible()
start_with_a_blank_template_radio.click()
continue_button = page.get_by_role("button", name="Continue")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
template_name_input = page.get_by_text("Template name")
expect(template_name_input).to_be_visible()
template_name = str(uuid.uuid4())
template_name_input.fill(template_name)
message_input = page.get_by_role("textbox", name="Message")
expect(message_input).to_be_visible()
message = "Test message"
message_input.fill(message)
save_button = page.get_by_text("Save")
expect(save_button).to_be_visible()
save_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
use_this_template_button = page.get_by_text("Use this template")
expect(use_this_template_button).to_be_visible()
use_this_template_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
use_my_phone_number_link = page.get_by_text("Use my phone number")
expect(use_my_phone_number_link).to_be_visible()
use_my_phone_number_link.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
preview_button = page.get_by_text("Preview")
expect(preview_button).to_be_visible()
preview_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
send_button = page.get_by_role("button", name="Send")
expect(send_button).to_be_visible()
send_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
dashboard_button = page.get_by_text("Dashboard")
expect(dashboard_button).to_be_visible()
dashboard_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
download_link = page.get_by_text("Download")
expect(download_link).to_be_visible()
# Start waiting for the download
with page.expect_download() as download_info:
# Perform the action that initiates download
download_link.click()
download = download_info.value
# Wait for the download process to complete and save the downloaded file somewhere
download.save_as("download_test_file")
f = open("download_test_file", "r")
content = f.read()
f.close()
# We don't want to wait 5 minutes to get a response from AWS about the message we sent
# So we are using this invalid phone number the e2e_test_user signed up with (12025555555)
# to shortcircuit the sending process. Our phone number validator will insta-fail the
# message and it won't be sent, but the report will still be generated, which is all
# we care about here.
assert (
"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time"
in content
)
assert "12025555555" in content
assert "one-off-e2e_test_user" in content
os.remove("download_test_file")
def handle_existing_template_case(page):
"""
This is the test path used in the Github action. Right now the e2e tests
are not connected to AWS, so we can't actually send messages. Hence, the
test stops when it verifies that the 'Send' button exists on the page. In
the future we would like to change the way e2e tests run so they run against
staging, in which case either the code in this method can be uncommented,
or perhaps this path will be unnecessary (because staging is not completely clean)
and we will just use the same path as for local development.
"""
existing_template_link = page.get_by_text("Example text message template")
expect(existing_template_link).to_be_visible()
existing_template_link.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
continue_button = page.get_by_role("button", name="Continue")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
continue_button = page.get_by_role("button", name="Continue")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# day_of_week_input = page.locator('xpath=//input[@name="day of week"]')
# day_of_week_input = page.get_by_text("day of week")
day_of_week_input = page.get_by_role("textbox", name="day of week")
expect(day_of_week_input).to_be_visible()
day_of_week_input.fill("Monday")
continue_button = page.get_by_role("button", name="Continue")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
color_input = page.get_by_role("textbox", name="color")
expect(color_input).to_be_visible()
color_input.fill("Green")
# continue_button = page.get_by_text("Continue")
expect(continue_button).to_be_visible()
continue_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
preview_button = page.get_by_text("Preview")
expect(preview_button).to_be_visible()
preview_button.click()
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
send_button = page.get_by_role("button", name="Send")
expect(send_button).to_be_visible()
# send_button.click()
# Check to make sure that we've arrived at the next page.
# page.wait_for_load_state("domcontentloaded")
# dashboard_button = page.get_by_text("Dashboard")
# expect(dashboard_button).to_be_visible()
# dashboard_button.click()
# Check to make sure that we've arrived at the next page.
# page.wait_for_load_state("domcontentloaded")
# download_link = page.get_by_text("Download")
# expect(download_link).to_be_visible()
# Start waiting for the download
# with page.expect_download() as download_info:
# Perform the action that initiates download
# download_link.click()
# download = download_info.value
# Wait for the download process to complete and save the downloaded file somewhere
# download.save_as("download_test_file")
# f = open("download_test_file", "r")
# content = f.read()
# f.close()
# We don't want to wait 5 minutes to get a response from AWS about the message we sent
# So we are using this invalid phone number the e2e_test_user signed up with (12025555555)
# to shortcircuit the sending process. Our phone number validator will insta-fail the
# message and it won't be sent, but the report will still be generated, which is all
# we care about here.
# assert (
# "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time"
# in content
# )
# assert "12025555555" in content
# assert "one-off-e2e_test_user" in content
# os.remove("download_test_file")
def test_send_message_from_existing_template(authenticated_page):
page = authenticated_page
_setup(page)
if page.get_by_text("Create your first template").count() > 0:
handle_no_existing_template_case(page)
else:
handle_existing_template_case(page)
_teardown(page)
def _teardown(page):
page.click("text='Settings'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
page.click("text='Delete this service'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
page.click("text='Yes, delete'")
# Check to make sure that we've arrived at the next page.
page.wait_for_load_state("domcontentloaded")
# Check to make sure that we've arrived at the next page.
# Check the page title exists and matches what we expect.
expect(page).to_have_title(re.compile("Choose service"))