Merge branch 'main' into aanand-sqlalchemy-version-update

This commit is contained in:
Aditi Anand
2024-03-29 15:51:11 -04:00
29 changed files with 630 additions and 451 deletions
+3 -3
View File
@@ -9,10 +9,10 @@ runs:
sudo apt-get update \
&& sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev
- name: Set up Python 3.9
uses: actions/setup-python@v3
- name: Set up Python 3.12
uses: actions/setup-python@v4
with:
python-version: "3.9"
python-version: "3.12"
- name: Install poetry
shell: bash
run: pip install --upgrade poetry
+3
View File
@@ -57,6 +57,8 @@ jobs:
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
LOGIN_DOT_GOV_REGISTRATION_URL: "https://secure.login.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=https://notify-demo.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -70,6 +72,7 @@ jobs:
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
- name: Check for changes to egress config
id: changed-egress-config
+3
View File
@@ -61,6 +61,8 @@ jobs:
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
LOGIN_DOT_GOV_REGISTRATION_URL: "https://secure.login.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=https://beta.notify.gov/set-up-your-profile&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -74,6 +76,7 @@ jobs:
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
- name: Check for changes to egress config
id: changed-egress-config
+3
View File
@@ -62,6 +62,8 @@ jobs:
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
LOGIN_DOT_GOV_REGISTRATION_URL: "https://secure.login.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:notify-gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=https://notify-staging.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop"
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -75,6 +77,7 @@ jobs:
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
- name: Check for changes to egress config
id: changed-egress-config
+53 -10
View File
@@ -41,7 +41,7 @@ You will need the following items:
This project currently works with these major versions of the following main
components:
- Python 3.9.x
- Python 3.12.x
- PostgreSQL 15.x (version 12.x is used in the hosted environments)
These instructions will walk you through how to set your machine up with all of
@@ -175,12 +175,12 @@ session to make the changes take effect.
Now we're ready to install the Python version we need with `pyenv`, like so:
```sh
pyenv install 3.9
pyenv install 3.12
```
This will install the latest version of Python 3.9.
This will install the latest version of Python 3.12.
_NOTE: This project currently runs on Python 3.9.x._
_NOTE: This project currently runs on Python 3.12.x._
#### Python Dependency Installation
@@ -253,7 +253,7 @@ Once all of pre-requisites for the project are installed and you have a
cloud.gov account, you can now set up the API project and get things running
locally!
First, clone the respository in the directory of your choosing on your machine:
First, clone the repository in the directory of your choosing on your machine:
```sh
git clone git@github.com:GSA/notifications-api.git
@@ -261,12 +261,12 @@ git clone git@github.com:GSA/notifications-api.git
Now go into the project directory (`notifications-api` by default), create a
virtual environment, and set the local Python version to point to the virtual
environment (assumes version Python `3.9.18` is what is installed on your
environment (assumes version Python `3.12.2` is what is installed on your
machine):
```sh
cd notifications-api
pyenv virtualenv 3.9.18 notify-api
pyenv virtualenv 3.12.2 notify-api
pyenv local notify-api
```
@@ -278,6 +278,9 @@ in the command line by using this command:
```sh
cf login -a api.fr.cloud.gov --sso
```
If you are offered a choice of orgs, select `gsa-tts-benefits-studio`.
For the space, choose `notify-local-dev` to start with (assuming you are
setting up local development).
_REMINDER: Ensure you have access to the `notify-local-dev` and `notify-staging` spaces in cloud.gov_
@@ -289,9 +292,12 @@ cd terraform/development
./run.sh
```
In addition to some infrastructure setup, this will also create a local `.env`
file for you in the project's root directory, which will include a handful of
project-specific environment variables.
If this runs correctly, Terraform will ask you if you want to create some
resources. Answer `yes`.
The script will also create a local `.env` file for you in the project's
root directory, which will include a handful of project-specific environment
variables.
Lastly, if you didn't already start PostgreSQL and Redis above, be sure to do
so now:
@@ -301,6 +307,40 @@ brew services start postgresql@15
brew services start redis
```
#### Upgrading Python in existing projects
If you're upgrading an existing project to a newer version of Python, you can
follow these steps to get yourself up-to-date.
First, use `pyenv` to install the newer version of Python you'd like to use;
we'll use `3.12` in our example here since we recently upgraded to this version:
```sh
pyenv install 3.12
```
Next, delete the virtual environment you previously had set up. If you followed
the instructions above with the first-time set up, you can do this with `pyenv`:
```sh
pyenv virtualenv-delete notify-api
```
Now, make sure you are in your project directory and recreate the same virtual
environment with the newer version of Python you just installed:
```sh
cd notifications-api
pyenv virtualenv 3.12.2 notify-api
pyenv local notify-api
```
At this point, proceed with the rest of the instructions here in the README and
you'll be set with an upgraded version of Python.
_If you're not sure about the details of your current virtual environment, you can run `poetry env info` to get more information. If you've been using `pyenv` for everything, you can also see all available virtual environments with `pyenv virtualenvs`._
### Final environment setup
There's one final thing to adjust in the newly created `.env` file. This
@@ -351,6 +391,9 @@ Now you can run the web server and background workers for asynchronous jobs:
make run-procfile
```
If it runs correctly, you will be able to visit http://127.0.0.1:6011/ and see
JSON from the API in your web browser.
This will run all of the services within the same shell session. If you need to
run them separately to help with debugging or tracing logs, you can do so by
opening three sepearate shell sessions and running one of these commands in each
+77 -2
View File
@@ -3,15 +3,16 @@ import secrets
import string
import time
import uuid
from contextlib import contextmanager
from time import monotonic
from celery import current_task
from celery import Celery, Task, current_task
from flask import current_app, g, has_request_context, jsonify, make_response, request
from flask.ctx import has_app_context
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy
from notifications_utils import logging, request_helper
from notifications_utils.celery import NotifyCelery
from notifications_utils.clients.encryption.encryption_client import Encryption
from notifications_utils.clients.redis.redis_client import RedisClient
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
@@ -27,6 +28,25 @@ from app.clients.email.aws_ses_stub import AwsSesStubClient
from app.clients.sms.aws_sns import AwsSnsClient
class NotifyCelery(Celery):
def init_app(self, app):
self.task_cls = make_task(app)
# Configure Celery app with options from the main app config.
self.config_from_object(app.config["CELERY"])
def send_task(self, name, args=None, kwargs=None, **other_kwargs):
other_kwargs["headers"] = other_kwargs.get("headers") or {}
if has_request_context() and hasattr(request, "request_id"):
other_kwargs["headers"]["notify_request_id"] = request.request_id
elif has_app_context() and "request_id" in g:
other_kwargs["headers"]["notify_request_id"] = g.request_id
return super().send_task(name, args, kwargs, **other_kwargs)
class SQLAlchemy(_SQLAlchemy):
"""We need to subclass SQLAlchemy in order to override create_engine options"""
@@ -366,3 +386,58 @@ def setup_sqlalchemy_events(app):
@event.listens_for(db.engine, "checkin")
def checkin(dbapi_connection, connection_record): # noqa
pass
def make_task(app):
class NotifyTask(Task):
abstract = True
start = None
@property
def queue_name(self):
delivery_info = self.request.delivery_info or {}
return delivery_info.get("routing_key", "none")
@property
def request_id(self):
# Note that each header is a direct attribute of the
# task context (aka "request").
return self.request.get("notify_request_id")
@contextmanager
def app_context(self):
with app.app_context():
# Add 'request_id' to 'g' so that it gets logged.
g.request_id = self.request_id
yield
def on_success(self, retval, task_id, args, kwargs): # noqa
# enables request id tracing for these logs
with self.app_context():
elapsed_time = time.monotonic() - self.start
app.logger.info(
"Celery task {task_name} (queue: {queue_name}) took {time}".format(
task_name=self.name,
queue_name=self.queue_name,
time="{0:.4f}".format(elapsed_time),
)
)
def on_failure(self, exc, task_id, args, kwargs, einfo): # noqa
# enables request id tracing for these logs
with self.app_context():
app.logger.exception(
"Celery task {task_name} (queue: {queue_name}) failed".format(
task_name=self.name,
queue_name=self.queue_name,
)
)
def __call__(self, *args, **kwargs):
# ensure task has flask context to access config, logger, etc
with self.app_context():
self.start = time.monotonic()
return super().__call__(*args, **kwargs)
return NotifyTask
+1 -2
View File
@@ -114,8 +114,7 @@ def extract_phones(job):
job_row = 0
for row in job:
row = row.split(",")
current_app.logger.info(f"PHONE INDEX IS NOW {phone_index}")
current_app.logger.info(f"LENGTH OF ROW IS {len(row)}")
if phone_index >= len(row):
phones[job_row] = "Unavailable"
current_app.logger.error(
+2 -1
View File
@@ -91,6 +91,7 @@ def check_sms_delivery_receipt(self, message_id, notification_id, sent_at):
bind=True, name="deliver_sms", max_retries=48, default_retry_delay=300
)
def deliver_sms(self, notification_id):
"""Branch off to the final step in delivering the notification to sns and get delivery receipts."""
try:
current_app.logger.info(
"Start sending SMS for notification id: {}".format(notification_id)
@@ -108,7 +109,7 @@ def deliver_sms(self, notification_id):
current_app.logger.warning(
ansi_green + f"AUTHENTICATION CODE: {notification.content}" + ansi_reset
)
# Code branches off to send_to_providers.py
message_id = send_to_providers.send_sms_to_provider(notification)
# 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)
+13 -1
View File
@@ -31,6 +31,7 @@ from app.v2.errors import TotalRequestsError
@notify_celery.task(name="process-job")
def process_job(job_id, sender_id=None):
"""Update job status, get csv data from s3, and begin processing csv rows."""
start = datetime.utcnow()
job = dao_get_job_by_id(job_id)
current_app.logger.info(
@@ -74,6 +75,7 @@ def process_job(job_id, sender_id=None):
for row in recipient_csv.get_rows():
process_row(row, template, job, service, sender_id=sender_id)
# End point/Exit point for message send flow.
job_complete(job, start=start)
@@ -109,6 +111,7 @@ def get_recipient_csv_and_template_and_sender_id(job):
def process_row(row, template, job, service, sender_id=None):
"""Branch off based on notification type, sms or email."""
template_type = template.template_type
encrypted = encryption.encrypt(
{
@@ -121,6 +124,8 @@ def process_row(row, template, job, service, sender_id=None):
}
)
# Both save_sms and save_email have the same general
# persist logic.
send_fns = {NotificationType.SMS: save_sms, NotificationType.EMAIL: save_email}
send_fn = send_fns[template_type]
@@ -130,6 +135,7 @@ def process_row(row, template, job, service, sender_id=None):
task_kwargs["sender_id"] = sender_id
notification_id = create_uuid()
# Kick-off persisting notification in save_sms/save_email.
send_fn.apply_async(
(
str(service.id),
@@ -163,7 +169,11 @@ def __total_sending_limits_for_job_exceeded(service, job, job_id):
@notify_celery.task(bind=True, name="save-sms", max_retries=5, default_retry_delay=300)
def save_sms(self, service_id, notification_id, encrypted_notification, sender_id=None):
"""Persist notification to db and place notification in queue to send to sns."""
notification = encryption.decrypt(encrypted_notification)
# SerialisedService and SerialisedTemplate classes are
# used here to grab the same service and template from the cache
# to improve performance.
service = SerialisedService.from_id(service_id)
template = SerialisedTemplate.from_id_and_service_id(
notification["template"],
@@ -177,7 +187,8 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i
).sms_sender
else:
reply_to_text = template.reply_to_text
# Return False when trial mode services try sending notifications
# to non-team and non-simulated recipients.
if not service_allowed_to_send_to(notification["to"], service, KeyType.NORMAL):
current_app.logger.debug(
"SMS {} failed as restricted service".format(notification_id)
@@ -208,6 +219,7 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i
reply_to_text=reply_to_text,
)
# Kick off sns process in provider_tasks.py
provider_tasks.deliver_sms.apply_async(
[str(saved_notification.id)], queue=QueueNames.SEND_SMS
)
+1 -1
View File
@@ -161,7 +161,7 @@
"",
"Open this link to create an account on Notify.gov:",
"",
"((url))",
"[Join Organization](((url)))",
"",
"",
"This invitation will stop working at midnight tomorrow. This is to keep ((organization_name)) secure."
-1
View File
@@ -36,7 +36,6 @@ def get_login_gov_user(login_uuid, email_address):
should be removed.
"""
print(User.query.filter_by(login_uuid=login_uuid).first())
user = User.query.filter_by(login_uuid=login_uuid).first()
if user:
if user.email_address != email_address:
+6
View File
@@ -22,6 +22,11 @@ from app.serialised_models import SerialisedService, SerialisedTemplate
def send_sms_to_provider(notification):
"""Final step in the message send flow.
Get data for recipient, template,
notification and send it to sns.
"""
# we no longer store the personalisation in the db,
# need to retrieve from s3 before generating content
# However, we are still sending the initial verify code through personalisation
@@ -41,6 +46,7 @@ def send_sms_to_provider(notification):
return
if notification.status == NotificationStatus.CREATED:
# We get the provider here (which is only aws sns)
provider = provider_to_use(NotificationType.SMS, notification.international)
if not provider:
technical_failure(notification=notification)
+1 -5
View File
@@ -1,8 +1,4 @@
from strenum import StrEnum # type: ignore [import-not-found]
# In 3.11 this is in the enum library. We will not need this external library any more.
# The line will simply change from importing from strenum to importing from enum.
# And the strenum library can then be removed from poetry.
from enum import StrEnum
class TemplateType(StrEnum):
+2 -1
View File
@@ -166,6 +166,7 @@ def get_jobs_by_service(service_id):
@job_blueprint.route("", methods=["POST"])
def create_job(service_id):
"""Entry point from UI for one-off messages as well as CSV uploads."""
service = dao_fetch_service_by_id(service_id)
if not service.active:
raise InvalidRequest("Create job is not allowed: service is inactive ", 403)
@@ -204,7 +205,7 @@ def create_job(service_id):
dao_create_job(job)
sender_id = data.get("sender_id")
# Kick off job in tasks.py
if job.job_status == JobStatus.PENDING:
process_job.apply_async(
[str(job.id)], {"sender_id": sender_id}, queue=QueueNames.JOBS
+19 -5
View File
@@ -1,4 +1,5 @@
import json
import os
from flask import Blueprint, current_app, jsonify, request
from itsdangerous import BadData, SignatureExpired
@@ -27,6 +28,7 @@ from app.organization.organization_schema import (
post_update_invited_org_user_status_schema,
)
from app.schema_validation import validate
from app.utils import hilite
organization_invite_blueprint = Blueprint("organization_invite", __name__)
@@ -58,10 +60,7 @@ def invite_user_to_org(organization_id):
else invited_org_user.invited_by.name
),
"organization_name": invited_org_user.organization.name,
"url": invited_org_user_url(
invited_org_user.id,
data.get("invite_link_host"),
),
"url": os.environ["LOGIN_DOT_GOV_REGISTRATION_URL"],
}
saved_notification = persist_notification(
template_id=template.id,
@@ -74,13 +73,28 @@ def invite_user_to_org(organization_id):
key_type=KeyType.NORMAL,
reply_to_text=invited_org_user.invited_by.email_address,
)
saved_notification.personalisation = personalisation
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=1800,
)
saved_notification.personalisation = personalisation
# This is for the login.gov path, note 24 hour expiry to match
# The expiration of invitations.
redis_key = f"organization-invite-{invited_org_user.email_address}"
redis_store.set(
redis_key,
organization_id,
ex=3600 * 24,
)
current_app.logger.info(
hilite(f"STORING THIS ORGANIZATION ID IN REDIS {redis_store.get(redis_key)}")
)
current_app.logger.info(
hilite(f"URL: {os.environ['LOGIN_DOT_GOV_REGISTRATION_URL']}")
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
return jsonify(data=invited_org_user.serialize()), 201
+9
View File
@@ -1,6 +1,9 @@
import json
from flask import Blueprint, abort, current_app, jsonify, request
from sqlalchemy.exc import IntegrityError
from app import redis_store
from app.config import QueueNames
from app.dao.annual_billing_dao import set_default_free_allowance_for_service
from app.dao.dao_utils import transaction
@@ -210,6 +213,12 @@ def send_notifications_on_mou_signed(organization_id):
reply_to_text=notify_service.get_default_reply_to_email_address(),
)
saved_notification.personalisation = personalisation
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
personalisation = {
+4
View File
@@ -1,5 +1,6 @@
import itertools
from flask import current_app
from notifications_utils.recipients import allowed_to_send_to
from app.dao.services_dao import dao_fetch_service_by_id
@@ -45,6 +46,9 @@ def service_allowed_to_send_to(
member.recipient for member in service.guest_list if allow_guest_list_recipients
]
# As per discussion we have decided to allow official simulated
# numbers to go out in trial mode for development purposes.
guest_list_members.extend(current_app.config["SIMULATED_SMS_NUMBERS"])
if (key_type == KeyType.NORMAL and service.restricted) or (
key_type == KeyType.TEAM
):
+27 -6
View File
@@ -1,4 +1,5 @@
import json
import os
from datetime import datetime
from flask import Blueprint, current_app, jsonify, request
@@ -24,6 +25,7 @@ from app.notifications.process_notifications import (
send_notification_to_queue,
)
from app.schemas import invited_user_schema
from app.utils import hilite
service_invite = Blueprint("service_invite", __name__)
@@ -39,7 +41,7 @@ def _create_service_invite(invited_user, invite_link_host):
personalisation = {
"user_name": invited_user.from_user.name,
"service_name": invited_user.service.name,
"url": invited_user_url(invited_user.id, invite_link_host),
"url": os.environ["LOGIN_DOT_GOV_REGISTRATION_URL"],
}
saved_notification = persist_notification(
@@ -47,11 +49,7 @@ def _create_service_invite(invited_user, invite_link_host):
template_version=template.version,
recipient=invited_user.email_address,
service=service,
personalisation={
"user_name": invited_user.from_user.name,
"service_name": invited_user.service.name,
"url": invited_user_url(invited_user.id, invite_link_host),
},
personalisation={},
notification_type=NotificationType.EMAIL,
api_key_id=None,
key_type=KeyType.NORMAL,
@@ -63,6 +61,29 @@ def _create_service_invite(invited_user, invite_link_host):
json.dumps(personalisation),
ex=1800,
)
# The raw permissions are in the form "a,b,c,d"
# but need to be in the form ["a", "b", "c", "d"]
data = {}
permissions = invited_user.permissions
permissions = permissions.split(",")
permission_list = []
for permission in permissions:
permission_list.append(f"{permission}")
data["from_user_id"] = (str(invited_user.from_user.id),)
data["service_id"] = str(invited_user.service.id)
data["permissions"] = permission_list
data["folder_permissions"] = invited_user.folder_permissions
# This is for the login.gov service invite on the
# "Set Up Your Profile" path.
redis_store.set(
f"service-invite-{invited_user.email_address}",
json.dumps(data),
ex=3600 * 24,
)
current_app.logger.info(
hilite(f"STORING ALL THIS IN REDIS FOR SERVICE INVITE {json.dumps(data)}")
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
+33 -3
View File
@@ -140,6 +140,11 @@ def update_user_attribute(user_id):
)
saved_notification.personalisation = personalisation
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
return jsonify(data=user_to_update.serialize()), 200
@@ -361,6 +366,12 @@ def create_2fa_code(
# Assume that we never want to observe the Notify service's research mode
# setting for this notification - we still need to be able to log into the
# admin even if we're doing user research using this service:
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
@@ -394,6 +405,11 @@ def send_user_confirm_new_email(user_id):
)
saved_notification.personalisation = personalisation
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
return jsonify({}), 204
@@ -487,6 +503,12 @@ def send_already_registered_email(user_id):
current_app.logger.info("Sending notification to queue")
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
current_app.logger.info("Sent notification to queue")
@@ -544,6 +566,9 @@ def get_user_login_gov_user():
login_uuid = request_args["login_uuid"]
email = request_args["email"]
user = get_login_gov_user(login_uuid, email)
if user is None:
return jsonify({})
result = user.serialize()
return jsonify(data=result)
@@ -614,6 +639,11 @@ def send_user_reset_password():
)
saved_notification.personalisation = personalisation
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=60 * 60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)
return jsonify({}), 204
@@ -688,9 +718,9 @@ def get_orgs_and_services(user):
"id": service.id,
"name": service.name,
"restricted": service.restricted,
"organization": service.organization.id
if service.organization
else None,
"organization": (
service.organization.id if service.organization else None
),
}
for service in user.services
if service.active
+8
View File
@@ -123,3 +123,11 @@ def get_reference_from_personalisation(personalisation):
if personalisation:
return personalisation.get("reference")
return None
# Function used for debugging.
# Do print(hilite(message)) while debugging, then remove your print statements
def hilite(message):
ansi_green = "\033[32m"
ansi_reset = "\033[0m"
return f"{ansi_green}{message}{ansi_reset}"
+1 -1
View File
@@ -2,7 +2,7 @@ env: production
web_instances: 2
web_memory: 1G
worker_instances: 1
worker_memory: 512M
worker_memory: 1G
scheduler_memory: 256M
public_api_route: notify-api.app.cloud.gov
admin_base_url: https://beta.notify.gov
+12 -7
View File
@@ -542,19 +542,24 @@ All commands use the `-g` or `--generate` to determine how many instances to loa
# How messages are queued and sent
Services used during message-send flow:
1. AWS S3
2. AWS SNS
3. AWS Cloudwatch
4. Redis
5. PostgreSQL
There are several ways for notifications to come into the API.
- Messages sent through the API enter through `app/notifications/post_notifications.py`
- One-off messages sent from the UI enter through `create_one_off_notification` in `app/service/rest.py`
- CSV uploads enter through `app/job/rest.py`
- One-off messages and CSV uploads both enter from the UI through `app/job/rest.py:create_job`
API messages and one-off UI messages come in one at a time, and take slightly-separate routes
that both end up at `persist_notification`, which writes to the database, and `provider_tasks.deliver_sms`,
API messages come in one at a time, and end up at `persist_notification`, which writes to the database, and `provider_tasks.deliver_sms`,
which enqueues the sending.
For CSV uploads, the CSV is first stored in S3 and queued as a `Job`. When the job runs, it iterates
through the rows, running `process_job.save_sms` to send notifications through `persist_notification` and
`provider_tasks.deliver_sms`.
One-off messages and batch messages both upload a CSV, which are then first stored in S3 and queued as a `Job`. When the job runs, it iterates
through the rows from `tasks.py:process_row`, running `tasks.py:save_sms` (email notifications branch off through `tasks.py:save_email`) to write to the db with `persist_notification` and begin the process of delivering the notification to the provider
through `provider_tasks.deliver_sms`. The exit point to the provider is in `send_to_providers.py:send_sms`.
# Writing public APIs
+17
View File
@@ -0,0 +1,17 @@
# notify.gov Data Dictionary
This document serves as a comprehensive guide to the data structure of notify.gov. It outlines the key data elements, their types, and relationships within the system. From user information to message details, this data dictionary is a valuable resource for understanding and maintaining the underlying data model of this application. Use this guide to ensure consistency, clarity, and effective management of the data that powers our messaging functionality.
# Table: Global
| Field | Type | Length | Description |
|-------------------------------------|-----------|--------|------------------------------------------------------------------|
| service_id | Integer | | Service ID - reference for most data related to a service |
## Table: Dashboard
| Field | Type | Length | Description |
|-------------------------------------|-----------|--------|--------------------------------------------|
| global_message_limit | Integer | | Message limit set by platform admin |
| daily_global_messages_remaining | Integer | | Remaining messages in database |
+1
View File
@@ -46,6 +46,7 @@ applications:
ADMIN_BASE_URL: ((admin_base_url))
NOTIFY_E2E_TEST_EMAIL: ((NOTIFY_E2E_TEST_EMAIL))
NOTIFY_E2E_TEST_PASSWORD: ((NOTIFY_E2E_TEST_PASSWORD))
LOGIN_DOT_GOV_REGISTRATION_URL: ((LOGIN_DOT_GOV_REGISTRATION_URL))
# Credentials variables
INTERNAL_CLIENT_API_KEYS: '{"notify-admin":["((ADMIN_CLIENT_SECRET))"]}'
Generated
+300 -392
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -7,13 +7,13 @@ readme = "README.md"
[tool.poetry.dependencies]
python = ">=3.9,<3.12"
python = "^3.12.2"
alembic = "==1.13.1"
amqp = "==5.2.0"
beautifulsoup4 = "==4.12.3"
boto3 = "^1.29.6"
botocore = "^1.32.6"
cachetools = "==5.3.2"
cachetools = "==5.3.3"
celery = {version = "==5.3.6", extras = ["redis"]}
certifi = ">=2022.12.7"
cffi = "==1.16.0"
@@ -49,14 +49,14 @@ pyjwt = "==2.8.0"
python-dotenv = "==1.0.0"
sqlalchemy = "==2.0.27"
werkzeug = "^3.0.1"
strenum = "^0.4.15"
faker = "^23.3.0"
setuptools = "^69.2.0"
[tool.poetry.group.dev.dependencies]
awscli = "^1.29.74"
bandit = "*"
black = "^23.12.1"
black = "^24.3.0"
cloudfoundry-client = "*"
exceptiongroup = "==1.2.0"
flake8 = "^7.0.0"
@@ -65,7 +65,7 @@ freezegun = "^1.4.0"
honcho = "*"
isort = "^5.13.2"
jinja2-cli = {version = "==0.8.2", extras = ["yaml"]}
moto = "==4.2.13"
moto = "==5.0.3"
pip-audit = "*"
pre-commit = "^3.6.0"
pytest = "^7.4.4"
+1 -1
View File
@@ -1 +1 @@
python-3.9.x
python-3.12.x
+21 -2
View File
@@ -34,6 +34,7 @@ from app.serialised_models import (
SerialisedService,
SerialisedTemplate,
)
from app.service.utils import service_allowed_to_send_to
from app.utils import get_template_instance
from app.v2.errors import BadRequestError, RateLimitError, TotalRequestsError
from tests.app.db import (
@@ -498,7 +499,7 @@ def test_check_service_over_api_rate_limit_when_exceed_rate_limit_request_fails_
with pytest.raises(RateLimitError) as e:
check_service_over_api_rate_limit(serialised_service, serialised_api_key)
assert app.redis_store.exceeded_rate_limit.called_with(
app.redis_store.exceeded_rate_limit.assert_called_with(
f"{sample_service.id}-{api_key.key_type}",
sample_service.rate_limit,
60,
@@ -527,7 +528,7 @@ def test_check_service_over_api_rate_limit_when_rate_limit_has_not_exceeded_limi
)[0]
check_service_over_api_rate_limit(serialised_service, serialised_api_key)
assert app.redis_store.exceeded_rate_limit.called_with(
app.redis_store.exceeded_rate_limit.assert_called_with(
f"{sample_service.id}-{api_key.key_type}",
3000,
60,
@@ -802,3 +803,21 @@ def test_check_service_over_total_message_limit(mocker, sample_service):
sample_service,
)
assert service_stats == 0
def test_service_allowed_to_send_to_simulated_numbers():
trial_mode_service = create_service(service_name="trial mode", restricted=True)
can_send = service_allowed_to_send_to(
"+14254147755",
trial_mode_service,
KeyType.NORMAL,
allow_guest_list_recipients=True,
)
can_not_send = service_allowed_to_send_to(
"+15555555555",
trial_mode_service,
KeyType.NORMAL,
allow_guest_list_recipients=True,
)
assert can_send is True
assert can_not_send is False
+4 -2
View File
@@ -1,3 +1,4 @@
import os
import uuid
import pytest
@@ -36,6 +37,7 @@ def test_create_invited_org_user(
platform_admin,
expected_invited_by,
):
os.environ["LOGIN_DOT_GOV_REGISTRATION_URL"] = "http://foo.fake.gov"
mocked = mocker.patch("app.celery.provider_tasks.deliver_email.apply_async")
email_address = "invited_user@example.com"
sample_user.platform_admin = platform_admin
@@ -67,8 +69,8 @@ def test_create_invited_org_user(
assert len(notification.personalisation.keys()) == 3
assert notification.personalisation["organization_name"] == "sample organization"
assert notification.personalisation["user_name"] == expected_invited_by
assert notification.personalisation["url"].startswith(expected_start_of_invite_url)
assert len(notification.personalisation["url"]) > len(expected_start_of_invite_url)
# assert notification.personalisation["url"].startswith(expected_start_of_invite_url)
# assert len(notification.personalisation["url"]) > len(expected_start_of_invite_url)
mocked.assert_called_once_with(
[(str(notification.id))], queue="notify-internal-tasks"