merge from main

This commit is contained in:
Kenneth Kehl
2023-07-06 13:45:53 -07:00
25 changed files with 389 additions and 156 deletions

View File

@@ -1,5 +1,5 @@
---
name: Create a new ADR
name: Create a new ADR from template
about: Use this template to create a new Architectural Decision Record.
title: ADR
labels: documentation

View File

@@ -42,8 +42,8 @@ body:
label: Implemented
description: Is this ADR implemented?
options:
- Yes
- No
- 'Yes'
- 'No'
validations:
required: true
- type: input

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"

View File

@@ -44,7 +44,7 @@ flask-sqlalchemy = {version = "==2.5.1", ref = "aa7a61a5357cf6f5dcc135d98c781192
gunicorn = {version = "==20.1.0", extras = ["eventlet"], ref = "1299ea9e967a61ae2edebe191082fd169b864c64", git = "https://github.com/benoitc/gunicorn.git"}
iso8601 = "==1.0.2"
itsdangerous = "==2.1.2"
jsonschema = {version = "==4.5.1", extras = ["format"]}
jsonschema = {version = "==4.17.3", extras = ["format"]}
lxml = "==4.9.1"
marshmallow = "==3.15.0"
marshmallow-sqlalchemy = "==0.28.1"
@@ -52,7 +52,7 @@ notifications-python-client = "==6.3.0"
oscrypto = "==1.3.0"
psycopg2-binary = "==2.9.3"
pyjwt = "==2.4.0"
python-dotenv = "==0.20.0"
python-dotenv = "==1.0.0"
sqlalchemy = "==1.4.40"
werkzeug = "~=2.3"
# gds metrics packages
@@ -69,7 +69,7 @@ isort = "==5.10.1"
moto = "==3.1.9"
pytest = "==7.1.2"
pytest-env = "==0.6.2"
pytest-mock = "==3.7.0"
pytest-mock = "==3.11.1"
pytest-cov = "==3.0.0"
pytest-xdist = "==2.5.0"
freezegun = "==1.2.1"

104
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "154cf502100c16f0c6e714b4b5e9f64f84cf8f5a9d54de2e1b072716f3f1d7d4"
"sha256": "4d927f81ce8ac461388a2998459e4f1ed79ef982822e36ca58df2ccf2200d2e8"
},
"pipfile-spec": 6,
"requires": {
@@ -500,7 +500,6 @@
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
],
"markers": "python_full_version >= '3.5.0'",
"version": "==3.4"
},
"importlib-metadata": {
@@ -552,7 +551,8 @@
},
"jsonpointer": {
"hashes": [
"sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"
"sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a",
"sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"
],
"version": "==2.4"
},
@@ -561,11 +561,11 @@
"format"
],
"hashes": [
"sha256:71b5e39324422543546572954ce71c67728922c104902cb7ce252e522235b33f",
"sha256:7c6d882619340c3347a1bf7315e147e6d3dae439033ae6383d6acb908c101dfc"
"sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d",
"sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"
],
"index": "pypi",
"version": "==4.5.1"
"version": "==4.17.3"
},
"kombu": {
"hashes": [
@@ -740,24 +740,24 @@
},
"newrelic": {
"hashes": [
"sha256:1bc307d06e2033637e7b484af22f540ca041fb23a54b311bcd5968ca1a64e4ef",
"sha256:435ac9e3791f78e05c9da8107a6ef49c13e62ac302696858fa2411198fe201ff",
"sha256:6662ec79493f23f9d0995a015177c87508bea4c541f7c9f17a61b503b82e1367",
"sha256:67902b3c53fa497dba887068166261d114ac2347c8a4908d735d7594cca163dc",
"sha256:6b4db0e7544232d4e6e835a02ee28637970576f8dce82ffcaa3d675246e822d5",
"sha256:796ed5ff44b04b41e051dc0112e5016e53a37e39e95023c45ff7ecd34c254a7d",
"sha256:84d1f71284efa5f1cae696161e0c3cb65eaa2f53116fe5e7c5a62be7d15d9536",
"sha256:9355f209ba8d82fd0f9d78d7cc1d9bef0ae4677b3cfed7b7aaec521adbe87559",
"sha256:9c0d5153b7363d5cb5cac7f8d1a4e03669b074afee2dda201851a67c7bed1e32",
"sha256:bcd3219e1e816a0fdb51ac993cac6744e6a835c13ee72e21d86bcbc2d16628ce",
"sha256:c4a0556c6ece49132ab1c32bfe398047a8311f9a8b6862b482495d132fcb0ad4",
"sha256:caccdf201735df80b470ddf772f60a154f2c07c0c1b2b3f6e999d55e79ce601e",
"sha256:d21af16cee1e0caf4c73c4c1b2d7ba9f33fe6a870d93135dc8b23ac592f49b38",
"sha256:da8f2dc31e182768fe314d8ceb6f42acd09956708846f8ae71f07f044a3aa05e",
"sha256:ef9c178329f8c04f0574908c1f04ff1f18b9eba55b869744583fee3eac48e571"
"sha256:1996ed51f92366f5ba9ad4992687aaca4d0bb3541e239ef4a40e0ae5da6939fc",
"sha256:3b66123c5f542d29c7f2e6ed9ab92327fb9b6f857f4a635d5a5c530eb5a0c3a7",
"sha256:418a29b20972413f8839aa5b77fa485d71bd897f1257ff05d86a1496d10b75de",
"sha256:4e82a095d8136ac6df10a702ac0c293d97a2a7beddf899c8ecc55564d0f02c0a",
"sha256:659d880fe7b44cc8974b40e796b612f1719f33d315093893e49ba9aec16ad8b1",
"sha256:880c38f65645f681ea66e18613178b8df96b8fa8873a0b4da4d4076cab738363",
"sha256:8979eb30019c4d0568842f0d8c17f3616fc8631a57b9ca67ebe1e61cee55c7bd",
"sha256:8f801a8fa30453c421747420232e56533a4c88c066eb14f852f34a757d1595f4",
"sha256:9603b69eb75f9aecb6246d95c7eeb1f8d41d5c4f34feb5aa0400548eb03b9d3b",
"sha256:a370283955065a1a55ac85ff97bd97e87144325732dd1ab87bf99a1617a3ecdc",
"sha256:ba87bac65018c6015cb778ff3a6806949879b2ae42f79b405a32e13177e11b13",
"sha256:bcbdf28cdd07bf593942b9de076258ed0fe8b5bc85583bdece64ae136e53035f",
"sha256:dba619d7b654b01ab5742e059096788b54d8b3dfac14a32c46f00151b848ee6c",
"sha256:de28d2113ac7a499e54f710d6b7bbcc338c02513f70a166ddceb23b45d752a03",
"sha256:ff9f8977a1a0e9a03c50d43e6eb94135da77c2ab9c00723cd1bf689c5685e4b0"
],
"index": "pypi",
"version": "==8.8.0"
"version": "==8.8.1"
},
"notifications-python-client": {
"hashes": [
@@ -769,7 +769,7 @@
"notifications-utils": {
"editable": true,
"git": "https://github.com/GSA/notifications-utils.git",
"ref": "1cd381db26026eda5d982dcfd4795ae2d058af3f"
"ref": "95611794e5ef83b6197a19007a815eff26e115e7"
},
"numpy": {
"hashes": [
@@ -826,10 +826,10 @@
},
"phonenumbers": {
"hashes": [
"sha256:5fa952b4abf9fccdaf1f130d96114a520c48890d4091b50a064e22c0fdc12dec",
"sha256:a4b20b6ba7dd402728f5cc8e86e1f29b1a873af45f5381dbee7e3083af497ff6"
"sha256:634b277dc53933962961697211746f2fb7bf945a6725a853a1556cdb4a7531e4",
"sha256:adf80fc0027ce8eac990f5e1d3fa00022b481133d65fa40c1c40ae9020fc5a66"
],
"version": "==8.13.14"
"version": "==8.13.15"
},
"prometheus-client": {
"hashes": [
@@ -841,11 +841,11 @@
},
"prompt-toolkit": {
"hashes": [
"sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b",
"sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f"
"sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac",
"sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"
],
"markers": "python_full_version >= '3.7.0'",
"version": "==3.0.38"
"version": "==3.0.39"
},
"psycopg2-binary": {
"hashes": [
@@ -994,11 +994,11 @@
},
"python-dotenv": {
"hashes": [
"sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f",
"sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"
"sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba",
"sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"
],
"index": "pypi",
"version": "==0.20.0"
"version": "==1.0.0"
},
"python-json-logger": {
"hashes": [
@@ -1052,11 +1052,10 @@
},
"redis": {
"hashes": [
"sha256:77929bc7f5dab9adf3acba2d3bb7d7658f1e0c2f1cafe7eb36434e751c471119",
"sha256:dc87a0bdef6c8bfe1ef1e1c40be7034390c2ae02d92dcd0c7ca1729443899880"
"sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d",
"sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c"
],
"markers": "python_version >= '3.7'",
"version": "==4.5.5"
"version": "==4.6.0"
},
"requests": {
"hashes": [
@@ -1214,18 +1213,18 @@
},
"typing-extensions": {
"hashes": [
"sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26",
"sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"
"sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36",
"sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"
],
"markers": "python_version < '3.10'",
"version": "==4.6.3"
"version": "==4.7.1"
},
"uri-template": {
"hashes": [
"sha256:934e4d09d108b70eb8a24410af8615294d09d279ce0e7cbcdaef1bd21f932b06",
"sha256:f1699c77b73b925cf4937eae31ab282a86dc885c333f2e942513f08f691fc7db"
"sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7",
"sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"
],
"version": "==1.2.0"
"version": "==1.3.0"
},
"urllib3": {
"hashes": [
@@ -1691,11 +1690,11 @@
},
"cyclonedx-python-lib": {
"hashes": [
"sha256:4124dc111580fc026442525729febc956072788d1fc2b3300a54d27b5ff8b1b5",
"sha256:d7b727b5a547080ec1bca27abdaf144f4583f4cf663da281a239d5bbec7f1d72"
"sha256:878e33b8e0080c786f6cbd4c6f87ad610db65d6a3a686a5698415d9cfcd8925d",
"sha256:907b64f00df85d727a425de86604768b248cf19285993729e04f17bec767f692"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==4.0.0"
"version": "==4.0.1"
},
"defusedxml": {
"hashes": [
@@ -1861,7 +1860,6 @@
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
],
"markers": "python_full_version >= '3.5.0'",
"version": "==3.4"
},
"iniconfig": {
@@ -2207,11 +2205,11 @@
},
"pluggy": {
"hashes": [
"sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
"sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
"sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849",
"sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"
],
"markers": "python_version >= '3.6'",
"version": "==1.0.0"
"markers": "python_version >= '3.7'",
"version": "==1.2.0"
},
"polling2": {
"hashes": [
@@ -2327,11 +2325,11 @@
},
"pytest-mock": {
"hashes": [
"sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534",
"sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"
"sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39",
"sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"
],
"index": "pypi",
"version": "==3.7.0"
"version": "==3.11.1"
},
"pytest-xdist": {
"hashes": [

View File

@@ -1,6 +1,5 @@
from datetime import datetime, timedelta
from time import time
from zoneinfo import ZoneInfo
from flask import current_app
from sqlalchemy.orm.exc import NoResultFound
@@ -12,14 +11,14 @@ from app.clients.sms import SmsClientResponseException
from app.config import QueueNames
from app.dao import notifications_dao
from app.dao.notifications_dao import (
insert_notification_history_delete_notifications_by_id,
sanitize_successful_notification_by_id,
update_notification_status_by_id,
)
from app.delivery import send_to_providers
from app.exceptions import NotificationTechnicalFailureException
from app.models import (
NOTIFICATION_DELIVERED,
NOTIFICATION_FAILED,
NOTIFICATION_SENT,
NOTIFICATION_TECHNICAL_FAILURE,
)
@@ -37,15 +36,17 @@ def check_sms_delivery_receipt(self, message_id, notification_id, sent_at):
"""
status, provider_response = aws_cloudwatch_client.check_sms(message_id, notification_id, sent_at)
if status == 'success':
status = NOTIFICATION_SENT
else:
status = NOTIFICATION_DELIVERED
elif status == 'failure':
status = NOTIFICATION_FAILED
update_notification_status_by_id(notification_id, status, provider_response=provider_response)
current_app.logger.info(f"Updated notification {notification_id} with response '{provider_response}'")
# if status is not success or failure the client raised an exception and this method will retry
if status == NOTIFICATION_SENT:
insert_notification_history_delete_notifications_by_id(notification_id)
current_app.logger.info(f"Archived notification {notification_id} that was successfully sent")
if status == NOTIFICATION_DELIVERED:
sanitize_successful_notification_by_id(notification_id)
current_app.logger.info(f"Sanitized notification {notification_id} that was successfully delivered")
else:
update_notification_status_by_id(notification_id, status, provider_response=provider_response)
current_app.logger.info(f"Updated notification {notification_id} with response '{provider_response}'")
@notify_celery.task(bind=True, name="deliver_sms", max_retries=48, default_retry_delay=300)
@@ -58,9 +59,9 @@ def deliver_sms(self, notification_id):
if not notification:
raise NoResultFound()
message_id = send_to_providers.send_sms_to_provider(notification)
# We have to put it in the default US/Eastern timezone. From zones west of there, the delay
# We have to put it in UTC. For other timezones, the delay
# will be ignored and it will fire immediately (although this probably only affects developer testing)
my_eta = datetime.now(ZoneInfo('US/Eastern')) + timedelta(seconds=300)
my_eta = datetime.utcnow() + timedelta(seconds=300)
check_sms_delivery_receipt.apply_async(
[message_id, notification_id, now],
eta=my_eta,

View File

@@ -84,6 +84,6 @@ class AwsCloudwatchClient(Client):
if all_failed_events and len(all_failed_events) > 0:
event = all_failed_events[0]
message = json.loads(event['message'])
return "fail", message['delivery']['providerResponse']
return "failure", message['delivery']['providerResponse']
raise Exception(f'No event found for message_id {message_id} notification_id {notification_id}')

View File

@@ -162,7 +162,7 @@ class Config(object):
'broker_transport_options': {
'visibility_timeout': 310,
},
'timezone': getenv("TIMEZONE", 'America/New_York'),
'timezone': getenv("TIMEZONE", 'UTC'),
'imports': [
'app.celery.tasks',
'app.celery.scheduled_tasks',
@@ -213,47 +213,47 @@ class Config(object):
# app/celery/nightly_tasks.py
'timeout-sending-notifications': {
'task': 'timeout-sending-notifications',
'schedule': crontab(hour=0, minute=5),
'schedule': crontab(hour=4, minute=5),
'options': {'queue': QueueNames.PERIODIC}
},
'create-nightly-billing': {
'task': 'create-nightly-billing',
'schedule': crontab(hour=0, minute=15),
'schedule': crontab(hour=4, minute=15),
'options': {'queue': QueueNames.REPORTING}
},
'create-nightly-notification-status': {
'task': 'create-nightly-notification-status',
'schedule': crontab(hour=0, minute=30), # after 'timeout-sending-notifications'
'schedule': crontab(hour=4, minute=30), # after 'timeout-sending-notifications'
'options': {'queue': QueueNames.REPORTING}
},
'delete-notifications-older-than-retention': {
'task': 'delete-notifications-older-than-retention',
'schedule': crontab(hour=3, minute=0), # after 'create-nightly-notification-status'
'schedule': crontab(hour=7, minute=0), # after 'create-nightly-notification-status'
'options': {'queue': QueueNames.REPORTING}
},
'delete-inbound-sms': {
'task': 'delete-inbound-sms',
'schedule': crontab(hour=1, minute=40),
'schedule': crontab(hour=5, minute=40),
'options': {'queue': QueueNames.PERIODIC}
},
'save-daily-notification-processing-time': {
'task': 'save-daily-notification-processing-time',
'schedule': crontab(hour=2, minute=0),
'schedule': crontab(hour=6, minute=0),
'options': {'queue': QueueNames.PERIODIC}
},
'cleanup-unfinished-jobs': {
'task': 'cleanup-unfinished-jobs',
'schedule': crontab(hour=0, minute=5),
'schedule': crontab(hour=4, minute=5),
'options': {'queue': QueueNames.PERIODIC}
},
'remove_sms_email_jobs': {
'task': 'remove_sms_email_jobs',
'schedule': crontab(hour=4, minute=0),
'schedule': crontab(hour=8, minute=0),
'options': {'queue': QueueNames.PERIODIC},
},
'check-for-services-with-high-failure-rates-or-sending-to-tv-numbers': {
'task': 'check-for-services-with-high-failure-rates-or-sending-to-tv-numbers',
'schedule': crontab(day_of_week='mon-fri', hour=10, minute=30),
'schedule': crontab(day_of_week='mon-fri', hour=14, minute=30),
'options': {'queue': QueueNames.PERIODIC}
},
}

View File

@@ -259,8 +259,8 @@ def _filter_query(query, filter_dict=None):
# filter by status
statuses = multidict.getlist('status')
if statuses:
statuses = Notification.substitute_status(statuses)
query = query.filter(Notification.status.in_(statuses))
# filter by template
@@ -272,34 +272,27 @@ def _filter_query(query, filter_dict=None):
@autocommit
def insert_notification_history_delete_notifications_by_id(
def sanitize_successful_notification_by_id(
notification_id
):
"""
Deletes one notification after it has run successfully and moves it to the notification_history
table.
# TODO what to do for international?
# phone_prefix = '1'
# Notification.query.filter(
# Notification.id.in_([notification_id]),
# ).update(
# {'to': phone_prefix, 'normalised_to': phone_prefix, 'status': 'delivered'}
# )
# db.session.commit()
update_query = """
update notifications set notification_status='delivered', "to"='1', normalised_to='1'
where id=:notification_id
"""
input_params = {
"notification_id": notification_id
}
# Insert into NotificationHistory if the row already exists do nothing.
insert_query = """
insert into notification_history
SELECT id, job_id, job_row_number, service_id, template_id, template_version, api_key_id,
key_type, notification_type, created_at, sent_at, sent_by, updated_at, reference, billable_units,
client_reference, international, phone_prefix, rate_multiplier, notification_status,
created_by_id, document_download_count
from NOTIFICATIONS WHERE id= :notification_id
ON CONFLICT ON CONSTRAINT notification_history_pkey
DO NOTHING
"""
delete_query = """
DELETE FROM notifications
where id= :notification_id
"""
db.session.execute(insert_query, input_params)
db.session.execute(delete_query, input_params)
db.session.execute(update_query, input_params)
@autocommit

View File

@@ -28,7 +28,6 @@ from app.models import (
EMAIL_TYPE,
KEY_TYPE_TEST,
NOTIFICATION_SENDING,
NOTIFICATION_SENT,
NOTIFICATION_STATUS_TYPES_COMPLETED,
NOTIFICATION_TECHNICAL_FAILURE,
SMS_TYPE,
@@ -137,9 +136,7 @@ def update_notification_to_sending(notification, provider):
notification.sent_at = datetime.utcnow()
notification.sent_by = provider.name
if notification.status not in NOTIFICATION_STATUS_TYPES_COMPLETED:
# We currently have no callback method for SMS deliveries
# TODO create celery task to request SMS delivery receipts from cloudwatch api
notification.status = NOTIFICATION_SENT if notification.notification_type == "sms" else NOTIFICATION_SENDING
notification.status = NOTIFICATION_SENDING
dao_update_notification(notification)

View File

@@ -121,8 +121,6 @@ def persist_notification(
updated_at=updated_at
)
current_app.logger.info('Persisting notification with to address: {}'.format(notification.to))
if notification_type == SMS_TYPE:
formatted_recipient = validate_and_format_phone_number(recipient, international=True)
recipient_info = get_international_phone_info(formatted_recipient)
@@ -133,7 +131,6 @@ def persist_notification(
elif notification_type == EMAIL_TYPE:
current_app.logger.info('Persisting notification with type: {}'.format(EMAIL_TYPE))
notification.normalised_to = format_email_address(notification.to)
current_app.logger.info('Persisting notification to formatted email: {}'.format(notification.normalised_to))
# if simulated create a Notification model to return but do not persist the Notification to the dB
if not simulated:

View File

@@ -395,7 +395,6 @@ def get_all_notifications_for_service(service_id):
notifications = [notification.serialize_for_csv() for notification in pagination.items]
else:
notifications = notification_with_template_schema.dump(pagination.items, many=True)
# We try and get the next page of results to work out if we need provide a pagination link to the next page
# in our response if it exists. Note, this could be done instead by changing `count_pages` in the previous
# call to be True which will enable us to use Flask-Sqlalchemy to tell if there is a next page of results but
@@ -429,7 +428,6 @@ def get_all_notifications_for_service(service_id):
@service_blueprint.route('/<uuid:service_id>/notifications/<uuid:notification_id>', methods=['GET'])
def get_notification_for_service(service_id, notification_id):
notification = notifications_dao.get_notification_with_personalisation(
service_id,
notification_id,

View File

@@ -1,3 +1,4 @@
monitoring.us-west-2.amazonaws.com
email.us-west-2.amazonaws.com
sns.us-east-1.amazonaws.com
gov-collector.newrelic.com

View File

@@ -1,3 +1,4 @@
monitoring.us-west-2.amazonaws.com
email.us-gov-west-1.amazonaws.com
sns.us-gov-west-1.amazonaws.com
gov-collector.newrelic.com

View File

@@ -1,3 +1,4 @@
monitoring.us-west-2.amazonaws.com
email.us-west-2.amazonaws.com
sns.us-west-2.amazonaws.com
gov-collector.newrelic.com

View File

@@ -3,7 +3,7 @@
| CREATED DATE | LAST UPDATED | STATUS | AUTHOR | STAKEHOLDERS |
| :---: | :---: | :---: | :---: | :---: |
| 06/01/2023 | 06/05/2023 | Accepted | @ccostino | @stvnrlly |
| 06/01/2023 | 06/15/2023 | Accepted | @ccostino | @stvnrlly |
## CONTEXT AND PROBLEM STATEMENT
@@ -103,11 +103,11 @@ These are the key considerations for creating ADRs for US Notify:
- Requires being mindful of what can live in the public space vs. not
## PROPOSED OPTION: Architectural Decision Records
## CHOSEN OPTION: Architectural Decision Records
I am proposing that our team adopts using Architectural Decision Records going
forward for any decisions that need to be proposed or discussed that will have a
significant impact on the platform.
Our team has chosen to adopt Architectural Decision Records going forward for
any decisions that need to be proposed or discussed that will have a significant
impact on the platform.
By documenting our changes in this fashion, it will improve our team's
development practices and software quality in a few ways:

View File

@@ -3,7 +3,7 @@
| CREATED DATE | LAST UPDATED | STATUS | AUTHOR | STAKEHOLDERS |
| :---: | :---: | :---: | :---: | :---: |
| 06/06/2023 | N/A | Accepted | @terrazoon, @ccostino | @GSA/notify-contributors |
| 06/06/2023 | 06/15/2023 | Accepted | @terrazoon, @ccostino | @GSA/notify-contributors |
## CONTEXT AND PROBLEM STATEMENT
@@ -106,7 +106,7 @@ Cons of converting parts of the frontend now:
customization, if any.
## PROPOSED OR CHOSEN OPTION: Proposed/Chosen Option Title Here
## CHOSEN OPTION: Backend UTC, frontend UTC
After talking through each of these options together as a team, we have decided
to move forward with converting the backend to UTC fully and pairing that work

View File

@@ -3,7 +3,7 @@
| CREATED DATE | LAST UPDATED | STATUS | AUTHOR | STAKEHOLDERS |
| :---: | :---: | :---: | :---: | :---: |
| 06/06/2023 | N/A | Proposed | @ccostino | @GSA/notify-contributors |
| 06/06/2023 | 06/15/2023 | Proposed | @ccostino | @GSA/notify-contributors |
## CONTEXT AND PROBLEM STATEMENT
@@ -82,7 +82,7 @@ change:
(especially any related to scheduled jobs).
## PROPOSED OR CHOSEN OPTION: Proposed/Chosen Option Title Here
## PROPOSED OPTION: Adjust `InvitedUser` management in the API
I am proposing we adjust the `InvitedUser` management in the API and get these
updates in place first for future UI changes, because without them we cannot

View File

@@ -0,0 +1,83 @@
# TITLE: Designing Pilot Content Visibility
| CREATED DATE | LAST UPDATED | STATUS | IMPLEMENTED |AUTHOR |STAKEHOLDERS |
| :---: | :---: | :---: | :---: | :---: |:---: |
| 06/20/2023 | 06/20/2023| Proposed| No | @tdlowden | @GSA/notify-contributors |
## CONTEXT AND PROBLEM STATEMENT
**OPEN ISSUE(S):** https://github.com/GSA/notifications-admin/issues/539, https://github.com/GSA/notifications-admin/issues/521, https://github.com/GSA/notifications-admin/issues/566
The initial launch of the beta.notify.gov site requires minimal public-facing content and must remove self-service account creation from the general public, as per communications oversight within TTS.
## DECISION DRIVERS
### Desired outcomes:
- A clean, informative landing page at beta.notify.gov that allows for closed pilot partners to access the application
- No ability for members of the public to create an account or view "how-to" documentation
### Primary concerns:
- Removing the self-service option altogether creates more work on the team members, who have to create an account/service
- Removing the self-service option obviates the initial service creator from progressing through the `service creation wizard` content
- LOE to make currently publicly visible documentation only accessible after login
## SECURITY COMPLIANCE CONSIDERATIONS
Because we work in a regulated space with many compliance requirements, we need to make sure we're accounting for any security concerns and adhering to all security compliance requirements. List them in this section along with any relevant details:
**Security concern**
N/A
## CONSIDERED OPTIONS
List all options that have either been discussed or thought of as a potential solution to the context and problem statement. Include any pros and cons with each option, like so:
### Option 1: A minimal landing page with only a short info paragraph, a closed pilot statement, and sign-in button, completely removing the ability to create a service except if done by a Studio team member. All other pages are only accessible after login.
**Pros:**
- Simplest and least amount of content (ergo, requires least review/approval)
- No need to scope a gated self-service solution
**Cons:**
- `Service creation wizard` content is not seen by pilot users
- More work on Studio team to construct a process to get pilot partners initial account access/service creation
### Option 2: A landing page with sign-in button, pilot statement, and a small amount of "marketing" type content, completely removing the ability to create a service except if done by a Studio team member. All other pages are only accessible after login.
**Pros:**
- Allows for public vistors to know more about what the product is intended to do
- No need to scope a gated self-service solution
**Cons:**
- `Service creation wizard` content is not seen by pilot users
- More work on Studio team to construct a process to get pilot partners initial account access/service creation
- More content to review by oversight teams
### Option 3: A minimal landing page that offers a sign-in button AND a field to input a pilot invite code, which would allow a user to then self-service create an account and initial service. All other pages are only accessible after login.
**Pros:**
- Invited users would go throught the `service creation wizard` flow and content
- A Studio team member would not need to create the initial account/service
**Cons:**
- Scoping and implementing an invite code system could cost many developer hours
- The action of creating an invite code for a user may end up being as burdensome as creating the initial account/service, nullifying the team time saved
## PROPOSED OR CHOSEN OPTION: Option 2
Option 2 provides the most benefit with least Studio work required. When weighing the value of the `service creation wizard` content/flow, we considered that it is 1. ephemeral (users can only access it once) and 2. limited to the service creator, rather than all team members. For the potential work to devise an invite code option, it did not feel that the benefit of the `wizard` outweighed the cost. Additionally, we resolve to explore replicating the `wizard` content/flow into another part of the site that can be accessed as frequently as necessary, and by all users of the application.
## VALIDATION AND NEXT STEPS
TK TK
## APPENDIX
Mockup of Option 1:
![Option 1](https://github.com/GSA/notifications-api/assets/6556888/483b9764-fa3f-43c7-b3c7-a239ae7344ff)
Mockup of Option 2:
![Option 2](https://github.com/GSA/notifications-api/assets/6556888/ea7442e4-745a-49d2-a90c-156cc6129356)

View File

@@ -0,0 +1,136 @@
# TITLE: Agreement info in data model
| CREATED DATE | LAST UPDATED | STATUS | AUTHOR | STAKEHOLDERS |
| :---: | :---: | :---: | :---: | :---: |
| 06/21/2023 | 07/05/2023 | Accepted | @stvnrlly, @ccostino | @GSA/notify-contributors |
## CONTEXT AND PROBLEM STATEMENT
**OPEN ISSUE(S):**
* https://github.com/GSA/notifications-api/issues/141
* https://github.com/GSA/notifications-admin/issues/53
* https://github.com/GSA/notifications-admin/issues/51
We will be entering into Memoranda of Understanding (MOU) and Interagency
Agreements (IAA) with partner agencies. Data from those agreements will be
important for application function.
Unlike the UK version of the application, users will not be able to complete a
self-service in-app agreement process. Our agreement process requires that
documents be “signed” outside of the application and (especially in the case of
an IAA) needs to happen with specific forms that have historically proven
difficult to automate.
Inside the application, well want to know information about the partner as well
as information necessary to avoid overspending the account.
This information includes:
- Agreement identifier
- Agreement type (MOU or IAA)
- Agreement partner name
- Agreement status
- Agreement start datetime (known as period of performance)
- Agreement end datetime (known as period of performance)
- Agreement URL (where it is in Google Drive)
- Budget amount (*not* message limit)
## DECISION DRIVERS
An implementation should address these needs:
- The need for multiple agreements per partner over time
- The information and tools to stop sending before overspending
- The ability to connect data to organization and service models
This is a minimal implementation of agreement data. It's quite possible that
it will change and expand over time, but those needs are not yet clear.
Because we will continue to have the actual agreement docs safely in Google
Drive, this implementation does not need to be a source of truth and does not
need to retain history over time.
### SECURITY COMPLIANCE CONSIDERATIONS
We will need to take care about permissions to change this data. Existing
permissions are fairly binary: you are a user or you are an admin. We should
consider whether that's still sufficient or if an in-between role would be
useful.
## CONSIDERED OPTIONS
As a team, we've gone through the following options:
- Add an Agreement model: a new class in `models.py` with the relevant fields.
- Pros:
- Separates agreements from the orgs, since they may change separately
- Multiple agreement-like models might be confusing, this avoids that
- Cons:
- Groups IAA and MOU together, which makes validation at the model level
harder and, in turn, makes it easier to break validation logic elsewhere
in the application
- Add MOU and IAA models: two new classes in `models.py` with the same fields
but different configurations.
- Pros:
- Cleanest representation of the real world
- Allows SQL-level support for required/unique fields
- Cons:
- Most complex data model
- Add agreement info to Organization model: no new classes, just a combination
of new fields and properties.
- Pros:
- No added model complexity
- Cons:
- Doesnt directly allow for history
## CHOSEN OPTION: Add an Agreement model
By adding an Agreement model, well allow flexibility in the interaction between
agreements and organizations but stop short of attempting to recreate the full
complexity of agreements in our data model.
If we later find that its necessary to separate MOU and IAA agreements, we
should be able to perform a migration.
### Consequences
- Positive
- Well gain more granular control over message limits for paid (IAA)
agreements
- We can offer more agreement transparency to users. For example, identifying
agreements that will need renewal
- Negative
- Were adding some complexity to the data model
- We know that this implementation is an MVP and thus might have rough edges
- Manual work is necessary to keep agreements in sync with the real-world
process
## VALIDATION AND NEXT STEPS
This process includes adding the new model and updating the existing models to
use them.
1. Add the new model:
- Add Agreement to models.py with the fields identified above
- Create migration to add/update table
2. Update the Organisation model:
- Add one-to-many field linking one Organisation to multiple Agreements
- Add model property to convert budget amount into message limit
- Add model property to provide remaining budget based on sent messages
- Add model property about whether free tier or not
- Add model property for free tier usage (retrieve messages sent in a year)
This will set up a new system, but stops short of connecting agreements to the
services actually sending the messages. This approach will be laid out in a
forthcoming ADR about managing message limits.

View File

@@ -172,6 +172,8 @@ top!).
| ADR | TITLE | CURRENT STATUS | IMPLEMENTED | LAST MODIFIED |
| :---: | :---: | :---: | :---: | :---: |
| [ADR-0003](./0003-implementing-invite-expirations.md) | [Implementing User Invite Expirations](./0003-implementing-invite-expirations.md) | Proposed | No | 06/06/2023 |
| [ADR-0002](./0002-how-to-handle-timezones.md) | [Determine How to Handle Timezones in US Notify](./0002-how-to-handle-timezones.md) | Accepted | Yes | 06/06/2023 |
| [ADR-0001](./0001-establishing-adrs-for-us-notify.md) | [Establishing ADRs for US Notify](./0001-establishing-adrs-for-us-notify.md) | Accepted | Yes | 06/05/2023 |
| [ADR-0005](./0005-agreement-data-model.md) | [Agreement info in data model](./0005-agreement-data-model.md) | Accepted | No | 07/05/2023 |
| [ADR-0004](./0004-designing-pilot-content-visibility.md) | [Designing Pilot Content Visibility](./0004-designing-pilot-content-visibility.md) | Proposed | No | 06/20/2023 |
| [ADR-0003](./0003-implementing-invite-expirations.md) | [Implementing User Invite Expirations](./0003-implementing-invite-expirations.md) | Proposed | No | 06/15/2023 |
| [ADR-0002](./0002-how-to-handle-timezones.md) | [Determine How to Handle Timezones in US Notify](./0002-how-to-handle-timezones.md) | Accepted | Yes | 06/15/2023 |
| [ADR-0001](./0001-establishing-adrs-for-us-notify.md) | [Establishing ADRs for US Notify](./0001-establishing-adrs-for-us-notify.md) | Accepted | Yes | 06/15/2023 |

View File

@@ -429,7 +429,7 @@ def test_create_nightly_notification_status_for_service_and_day(notify_db_sessio
process_day = datetime.utcnow().date() - timedelta(days=5)
with freeze_time(datetime.combine(process_day, time.max)):
create_notification(template=first_template, status='delivered')
create_notification(template=second_template, status='temporary-failure')
create_notification(template=second_template, status='failed')
# team API key notifications are included
create_notification(template=second_template, status='sending', key_type=KEY_TYPE_TEAM)
@@ -469,9 +469,9 @@ def test_create_nightly_notification_status_for_service_and_day(notify_db_sessio
assert email_sending_row.template_id == second_template.id
assert email_sending_row.service_id == second_service.id
assert email_sending_row.notification_type == 'email'
assert email_sending_row.notification_status == 'sending'
assert email_sending_row.notification_status == 'failed'
assert email_sending_row.notification_count == 1
assert email_sending_row.key_type == KEY_TYPE_TEAM
assert email_sending_row.key_type == KEY_TYPE_NORMAL
email_failure_row = new_fact_data[2]
assert email_failure_row.local_date == process_day
@@ -479,9 +479,9 @@ def test_create_nightly_notification_status_for_service_and_day(notify_db_sessio
assert email_failure_row.service_id == second_service.id
assert email_failure_row.job_id == UUID('00000000-0000-0000-0000-000000000000')
assert email_failure_row.notification_type == 'email'
assert email_failure_row.notification_status == 'temporary-failure'
assert email_failure_row.notification_status == 'sending'
assert email_failure_row.notification_count == 1
assert email_failure_row.key_type == KEY_TYPE_NORMAL
assert email_failure_row.key_type == KEY_TYPE_TEAM
sms_delivered_row = new_fact_data[3]
assert sms_delivered_row.template_id == first_template.id

View File

@@ -24,6 +24,7 @@ from app.dao.notifications_dao import (
get_notifications_for_service,
get_service_ids_with_notifications_on_date,
notifications_not_yet_sent,
sanitize_successful_notification_by_id,
update_notification_status_by_id,
update_notification_status_by_reference,
)
@@ -35,7 +36,6 @@ from app.models import (
NOTIFICATION_DELIVERED,
NOTIFICATION_SENT,
NOTIFICATION_STATUS_TYPES,
NOTIFICATION_STATUS_TYPES_FAILED,
SMS_TYPE,
Job,
Notification,
@@ -85,6 +85,26 @@ def test_should_by_able_to_update_status_by_id(sample_template, sample_job, sns_
assert notification.status == 'delivered'
def test_should_be_able_to_sanitize_successful_notification(sample_template, sample_job, sns_provider):
with freeze_time('2000-01-01 12:00:00'):
data = _notification_json(sample_template, job_id=sample_job.id, status='sending')
notification = Notification(**data)
notification.to = '15555555555'
notification.normalised_to = '15555555555'
dao_create_notification(notification)
assert notification.status == 'sending'
assert notification.normalised_to == '15555555555'
assert notification.to == '15555555555'
assert Notification.query.get(notification.id).status == 'sending'
with freeze_time('2000-01-02 12:00:00'):
sanitize_successful_notification_by_id(notification.id)
assert Notification.query.get(notification.id).status == 'delivered'
assert Notification.query.get(notification.id).normalised_to == '1'
assert Notification.query.get(notification.id).to == '1'
def test_should_not_update_status_by_id_if_not_sending_and_does_not_update_job(sample_job):
notification = create_notification(template=sample_job.template, status='delivered', job=sample_job)
assert Notification.query.get(notification.id).status == 'delivered'
@@ -500,13 +520,9 @@ def test_get_all_notifications_for_job_by_status(sample_job):
status=status
)
assert len(notifications().items) == len(NOTIFICATION_STATUS_TYPES)
# assert len(notifications().items) == len(NOTIFICATION_STATUS_TYPES)
for status in NOTIFICATION_STATUS_TYPES:
if status == 'failed':
assert len(notifications(filter_dict={'status': status}).items) == len(NOTIFICATION_STATUS_TYPES_FAILED)
else:
assert len(notifications(filter_dict={'status': status}).items) == 1
assert len(notifications(filter_dict={'status': status}).items) == 1
assert len(notifications(filter_dict={'status': NOTIFICATION_STATUS_TYPES[:3]}).items) == 3

View File

@@ -98,7 +98,7 @@ def test_should_send_personalised_template_to_correct_sms_provider_and_persist(
notification = Notification.query.filter_by(id=db_notification.id).one()
assert notification.status == 'sent'
assert notification.status == 'sending'
assert notification.sent_at <= datetime.utcnow()
assert notification.sent_by == 'sns'
assert notification.billable_units == 1
@@ -207,7 +207,7 @@ def test_send_sms_should_use_template_version_from_notification_not_latest(
assert persisted_notification.template_id == expected_template_id
assert persisted_notification.template_version == version_on_notification
assert persisted_notification.template_version != t.version
assert persisted_notification.status == 'sent'
assert persisted_notification.status == 'sending'
assert not persisted_notification.personalisation
@@ -240,7 +240,7 @@ def test_should_call_send_sms_response_task_if_research_mode(
persisted_notification = notifications_dao.get_notification_by_id(sample_notification.id)
assert persisted_notification.to == sample_notification.to
assert persisted_notification.template_id == sample_notification.template_id
assert persisted_notification.status == 'sent'
assert persisted_notification.status == 'sending'
assert persisted_notification.sent_at <= datetime.utcnow()
assert persisted_notification.sent_by == 'sns'
assert not persisted_notification.personalisation
@@ -254,7 +254,7 @@ def test_should_have_sending_status_if_fake_callback_function_fails(sample_notif
send_to_providers.send_sms_to_provider(
sample_notification
)
assert sample_notification.status == 'sent'
assert sample_notification.status == 'sending'
assert sample_notification.sent_by == 'sns'
@@ -534,7 +534,7 @@ def test_should_not_update_notification_if_research_mode_on_exception(
@pytest.mark.parametrize("starting_status, expected_status", [
("delivered", "delivered"),
("created", "sent"),
("created", "sending"),
("technical-failure", "technical-failure"),
])
def test_update_notification_to_sending_does_not_update_status_from_a_final_status(
@@ -556,11 +556,11 @@ def __update_notification(notification_to_update, research_mode, expected_status
@pytest.mark.parametrize('research_mode,key_type, billable_units, expected_status', [
(True, KEY_TYPE_NORMAL, 0, 'delivered'),
(False, KEY_TYPE_NORMAL, 1, 'sent'),
(False, KEY_TYPE_NORMAL, 1, 'sending'),
(False, KEY_TYPE_TEST, 0, 'sending'),
(True, KEY_TYPE_TEST, 0, 'sending'),
(True, KEY_TYPE_TEAM, 0, 'delivered'),
(False, KEY_TYPE_TEAM, 1, 'sent')
(False, KEY_TYPE_TEAM, 1, 'sending')
])
def test_should_update_billable_units_and_status_according_to_research_mode_and_key_type(
sample_template,
@@ -631,7 +631,7 @@ def test_should_send_sms_to_international_providers(
international=True
)
assert notification_international.status == 'sent'
assert notification_international.status == 'sending'
assert notification_international.sent_by == 'sns'

View File

@@ -414,11 +414,7 @@ def test_get_all_notifications_filter_by_multiple_statuses(client, sample_templa
def test_get_all_notifications_filter_by_failed_status(client, sample_template):
created_notification = create_notification(template=sample_template, status="created")
failed_notifications = [
create_notification(template=sample_template, status=_status)
for _status in ["technical-failure", "temporary-failure", "permanent-failure"]
]
failed_notifications = [create_notification(template=sample_template, status='failed')]
auth_header = create_service_authorization_header(service_id=created_notification.service_id)
response = client.get(
path='/v2/notifications?status=failed',
@@ -430,7 +426,7 @@ def test_get_all_notifications_filter_by_failed_status(client, sample_template):
assert response.headers['Content-type'] == "application/json"
assert json_response['links']['current'].endswith("/v2/notifications?status=failed")
assert 'next' in json_response['links'].keys()
assert len(json_response['notifications']) == 3
assert len(json_response['notifications']) == 1
returned_notification_ids = [n['id'] for n in json_response['notifications']]
for _id in [_notification.id for _notification in failed_notifications]:
@@ -508,25 +504,26 @@ def test_get_all_notifications_filter_by_id_no_notifications_if_last_notificatio
def test_get_all_notifications_filter_multiple_query_parameters(client, sample_email_template):
# TODO had to change pending to sending. Is that correct?
# this is the notification we are looking for
older_notification = create_notification(
template=sample_email_template, status="pending")
template=sample_email_template, status="sending")
# wrong status
create_notification(template=sample_email_template)
wrong_template = create_template(sample_email_template.service, template_type='sms')
# wrong template
create_notification(template=wrong_template, status="pending")
create_notification(template=wrong_template, status="sending")
# we only want notifications created before this one
newer_notification = create_notification(template=sample_email_template)
# this notification was created too recently
create_notification(template=sample_email_template, status="pending")
create_notification(template=sample_email_template, status="sending")
auth_header = create_service_authorization_header(service_id=newer_notification.service_id)
response = client.get(
path='/v2/notifications?status=pending&template_type=email&older_than={}'.format(newer_notification.id),
path='/v2/notifications?status=sending&template_type=email&older_than={}'.format(newer_notification.id),
headers=[('Content-Type', 'application/json'), auth_header])
json_response = json.loads(response.get_data(as_text=True))
@@ -537,7 +534,7 @@ def test_get_all_notifications_filter_multiple_query_parameters(client, sample_e
for url_part in [
"/v2/notifications?",
"template_type=email",
"status=pending",
"status=sending",
"older_than={}".format(newer_notification.id)
]:
assert url_part in json_response['links']['current']